@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'\nimport { resolveTimerActionError } from '../../../lib/timesheets-ui/timerErrors'\nimport { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from './config'\n\ntype ProjectOption = { id: string; name: string; code: string | null }\n\ntype TimerState = {\n entryId: string | null\n running: boolean\n startedAt: string | null\n projectId: string | null\n}\n\nconst TIMER_WIDGET_MUTATION_CONTEXT_ID = 'staff-timesheets-time-reporting-widget'\n\ntype TimerWidgetMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n staffMemberId: string\n action: 'timer-create' | 'timer-start' | 'timer-stop'\n retryLastMutation: () => Promise<boolean>\n}\n\nfunction formatElapsed(startedAt: string): string {\n const elapsed = Math.max(0, Date.now() - new Date(startedAt).getTime())\n const totalSeconds = Math.floor(elapsed / 1000)\n const hours = Math.floor(totalSeconds / 3600)\n const minutes = Math.floor((totalSeconds % 3600) / 60)\n const seconds = totalSeconds % 60\n return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`\n}\n\nconst TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingSettings>> = ({\n mode,\n settings = DEFAULT_SETTINGS,\n onSettingsChange,\n refreshToken,\n onRefreshStateChange,\n}) => {\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<TimerWidgetMutationContext>({\n contextId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])\n\n const [projects, setProjects] = React.useState<ProjectOption[]>([])\n const [selectedProjectId, setSelectedProjectId] = React.useState<string | null>(hydrated.lastProjectId)\n const [notes, setNotes] = React.useState('')\n const [timer, setTimer] = React.useState<TimerState>({ entryId: null, running: false, startedAt: null, projectId: null })\n const [elapsed, setElapsed] = React.useState('00:00:00')\n const [staffMemberId, setStaffMemberId] = React.useState<string | null>(null)\n const [loading, setLoading] = React.useState(true)\n const [actionLoading, setActionLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n\n const loadState = React.useCallback(async () => {\n onRefreshStateChange?.(true)\n setLoading(true)\n setError(null)\n try {\n // Assignments and the current user's staff member are independent \u2014 fetch them together.\n const [assignmentsRes, selfRes] = await Promise.all([\n readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n '/api/staff/timesheets/my-projects?pageSize=100',\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n ),\n readApiResultOrThrow<{ member?: { id: string } | null }>(\n '/api/staff/team-members/self',\n undefined,\n { errorMessage: '', fallback: { member: null } },\n ),\n ])\n\n const assignmentItems = Array.isArray(assignmentsRes.items) ? assignmentsRes.items : []\n const projectIds = assignmentItems\n .map((item) => String(item.time_project_id ?? item.timeProjectId ?? ''))\n .filter((id) => id.length > 0)\n\n const memberId = selfRes.member?.id ?? null\n setStaffMemberId(memberId)\n\n // Project details depend on the assignment ids and the active timer depends on the staff\n // member id, but the two requests are independent of each other \u2014 fetch them together.\n const [projectsRes, entriesRes] = await Promise.all([\n projectIds.length > 0\n ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-projects?ids=${projectIds.join(',')}&pageSize=100`,\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n )\n : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),\n memberId\n ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=100`,\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n )\n : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),\n ])\n\n const projectItems = Array.isArray(projectsRes.items) ? projectsRes.items : []\n setProjects(projectItems.map((item) => ({\n id: String(item.id ?? ''),\n name: String(item.name ?? ''),\n code: typeof item.code === 'string' ? item.code : null,\n })))\n\n if (memberId) {\n const entries = Array.isArray(entriesRes.items) ? entriesRes.items : []\n const running = entries.find((entry) => {\n const startedAt = entry.started_at ?? entry.startedAt\n const endedAt = entry.ended_at ?? entry.endedAt\n return startedAt != null && endedAt == null\n })\n if (running) {\n setTimer({\n entryId: String(running.id ?? ''),\n running: true,\n startedAt: String(running.started_at ?? running.startedAt ?? ''),\n projectId: String(running.time_project_id ?? running.timeProjectId ?? ''),\n })\n } else {\n setTimer({ entryId: null, running: false, startedAt: null, projectId: null })\n }\n }\n } catch (err) {\n console.error('staff.timesheets.timeReporting.load', err)\n setError(t('staff.timesheets.widgets.timeReporting.error', 'Failed to load timer state'))\n } finally {\n setLoading(false)\n onRefreshStateChange?.(false)\n }\n }, [onRefreshStateChange, t])\n\n React.useEffect(() => {\n void loadState()\n }, [loadState, refreshToken])\n\n // Tick elapsed time\n React.useEffect(() => {\n if (!timer.running || !timer.startedAt) return\n const tick = () => setElapsed(formatElapsed(timer.startedAt!))\n tick()\n const interval = setInterval(tick, 1000)\n return () => clearInterval(interval)\n }, [timer.running, timer.startedAt])\n\n const handleStart = React.useCallback(async () => {\n if (!selectedProjectId || !staffMemberId) return\n if (timer.running) return\n setActionLoading(true)\n try {\n const today = new Date().toISOString().slice(0, 10)\n // Single atomic create+start request (issue #3311): a partial failure can\n // no longer leave an orphaned, unstarted timer entry, and a rejected start\n // now surfaces here instead of being silently ignored. The call is routed\n // through the mutation guard (issue #3308) so global injection modules can\n // run onBeforeSave/onAfterSave and surface conflicts consistently.\n const startPayload = {\n staffMemberId,\n timeProjectId: selectedProjectId,\n date: today,\n notes: notes.trim() || null,\n }\n await runMutation({\n operation: () => startTimerEntry(startPayload),\n context: {\n formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: staffMemberId,\n staffMemberId,\n action: 'timer-start',\n retryLastMutation,\n },\n mutationPayload: startPayload,\n })\n\n onSettingsChange({ ...hydrated, lastProjectId: selectedProjectId })\n await loadState()\n } catch (err) {\n console.error('staff.timesheets.timeReporting.start', err)\n setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer')))\n } finally {\n setActionLoading(false)\n }\n }, [selectedProjectId, staffMemberId, timer.running, notes, hydrated, onSettingsChange, loadState, runMutation, retryLastMutation, t])\n\n const handleStop = React.useCallback(async () => {\n if (!timer.entryId || !staffMemberId) return\n setActionLoading(true)\n try {\n const stopPayload = {\n id: timer.entryId,\n action: 'timer-stop',\n staffMemberId,\n }\n await runMutation({\n operation: () =>\n apiCall(`/api/staff/timesheets/time-entries/${timer.entryId}/timer-stop`, { method: 'POST' }),\n context: {\n formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: timer.entryId,\n staffMemberId,\n action: 'timer-stop',\n retryLastMutation,\n },\n mutationPayload: stopPayload,\n })\n await loadState()\n } catch (err) {\n console.error('staff.timesheets.timeReporting.stop', err)\n setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer')))\n } finally {\n setActionLoading(false)\n }\n }, [timer.entryId, staffMemberId, loadState, runMutation, retryLastMutation, t])\n\n if (mode === 'settings') {\n return (\n <div className=\"space-y-2 text-sm\">\n <p className=\"text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.settings.description', 'No additional settings. Select a project and start tracking from the widget.')}\n </p>\n </div>\n )\n }\n\n if (loading) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p className=\"text-sm text-muted-foreground\">{t('staff.timesheets.widgets.timeReporting.loading', 'Loading...')}</p>\n </div>\n )\n }\n\n if (error) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p role=\"alert\" className=\"text-sm text-destructive\">{error}</p>\n </div>\n )\n }\n\n if (projects.length === 0) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p className=\"text-sm text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.noProjects', 'No projects assigned.')}\n </p>\n </div>\n )\n }\n\n // Timer is running\n if (timer.running) {\n const runningProject = projects.find((p) => p.id === timer.projectId)\n return (\n <div className=\"space-y-3\">\n <div className=\"text-sm text-muted-foreground\">\n {runningProject?.name ?? t('staff.timesheets.widgets.timeReporting.unknownProject', 'Unknown project')}\n </div>\n <div className=\"text-center\">\n <p className=\"font-mono text-3xl font-bold tabular-nums\">{elapsed}</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.running', 'Timer running')}\n </p>\n </div>\n <Button\n type=\"button\"\n variant=\"destructive\"\n className=\"w-full\"\n onClick={handleStop}\n disabled={actionLoading}\n >\n {t('staff.timesheets.widgets.timeReporting.stop', 'Stop Timer')}\n </Button>\n </div>\n )\n }\n\n // Timer not running \u2014 show start form\n return (\n <div className=\"space-y-3\">\n <div className=\"space-y-1.5\">\n <label className=\"text-xs font-medium text-muted-foreground\" htmlFor=\"timer-project\">\n {t('staff.timesheets.widgets.timeReporting.project', 'Project')}\n </label>\n <select\n id=\"timer-project\"\n className=\"w-full rounded-md border bg-background px-3 py-2 text-sm\"\n value={selectedProjectId ?? ''}\n onChange={(e) => setSelectedProjectId(e.target.value || null)}\n >\n <option value=\"\">{t('staff.timesheets.widgets.timeReporting.selectProject', 'Select project...')}</option>\n {projects.map((project) => (\n <option key={project.id} value={project.id}>\n {project.name}{project.code ? ` (${project.code})` : ''}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1.5\">\n <label className=\"text-xs font-medium text-muted-foreground\" htmlFor=\"timer-notes\">\n {t('staff.timesheets.widgets.timeReporting.taskNote', 'Task / Note')}\n </label>\n <Input\n id=\"timer-notes\"\n value={notes}\n onChange={(e) => setNotes(e.target.value)}\n placeholder={t('staff.timesheets.widgets.timeReporting.notesPlaceholder', 'What are you working on?')}\n />\n </div>\n <div className=\"text-center\">\n <p className=\"text-sm text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.notRunning', 'Not running')}\n </p>\n </div>\n <Button\n type=\"button\"\n className=\"w-full\"\n onClick={handleStart}\n disabled={actionLoading || !selectedProjectId}\n >\n {t('staff.timesheets.widgets.timeReporting.start', 'Start Timer')}\n </Button>\n </div>\n )\n}\n\nexport default TimeReportingWidget\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { DashboardWidgetComponentProps } from '@open-mercato/shared/modules/dashboard/widgets'\nimport { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useActiveTimesheetTimer } from '../../../lib/timesheets-ui/useActiveTimesheetTimer'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'\nimport { resolveTimerActionError } from '../../../lib/timesheets-ui/timerErrors'\nimport { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from './config'\n\ntype ProjectOption = { id: string; name: string; code: string | null }\n\nconst TIMER_WIDGET_MUTATION_CONTEXT_ID = 'staff-timesheets-time-reporting-widget'\n\ntype TimerWidgetMutationContext = {\n formId: string\n resourceKind: string\n resourceId: string\n staffMemberId: string\n action: 'timer-create' | 'timer-start' | 'timer-stop'\n retryLastMutation: () => Promise<boolean>\n}\n\nfunction formatElapsed(startedAt: string): string {\n const elapsed = Math.max(0, Date.now() - new Date(startedAt).getTime())\n const totalSeconds = Math.floor(elapsed / 1000)\n const hours = Math.floor(totalSeconds / 3600)\n const minutes = Math.floor((totalSeconds % 3600) / 60)\n const seconds = totalSeconds % 60\n return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`\n}\n\nconst TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingSettings>> = ({\n mode,\n settings = DEFAULT_SETTINGS,\n onSettingsChange,\n refreshToken,\n onRefreshStateChange,\n}) => {\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<TimerWidgetMutationContext>({\n contextId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n const hydrated = React.useMemo(() => hydrateSettings(settings), [settings])\n\n const [projects, setProjects] = React.useState<ProjectOption[]>([])\n const [selectedProjectId, setSelectedProjectId] = React.useState<string | null>(hydrated.lastProjectId)\n const [notes, setNotes] = React.useState('')\n const [elapsed, setElapsed] = React.useState('00:00:00')\n const [loading, setLoading] = React.useState(true)\n const [actionLoading, setActionLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const didLoadRef = React.useRef(false)\n const activeTimer = useActiveTimesheetTimer()\n const {\n staffMemberId,\n entryId: activeEntryId,\n running: timerRunning,\n startedAt: timerStartedAt,\n projectId: activeProjectId,\n projectName: activeProjectName,\n isLoading: activeTimerLoading,\n error: activeTimerError,\n refresh: refreshActiveTimer,\n } = activeTimer\n\n const loadState = React.useCallback(async () => {\n onRefreshStateChange?.(true)\n setLoading(true)\n setError(null)\n try {\n // Load assigned projects\n const assignmentsRes = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n '/api/staff/timesheets/my-projects?pageSize=100',\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n )\n const assignmentItems = Array.isArray(assignmentsRes.items) ? assignmentsRes.items : []\n const projectIds = assignmentItems\n .map((item) => String(item.time_project_id ?? item.timeProjectId ?? ''))\n .filter((id) => id.length > 0)\n\n if (projectIds.length > 0) {\n const projectsRes = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(\n `/api/staff/timesheets/time-projects?ids=${projectIds.join(',')}&pageSize=100`,\n undefined,\n { errorMessage: '', fallback: { items: [] } },\n )\n const items = Array.isArray(projectsRes.items) ? projectsRes.items : []\n setProjects(items.map((item) => ({\n id: String(item.id ?? ''),\n name: String(item.name ?? ''),\n code: typeof item.code === 'string' ? item.code : null,\n })))\n } else {\n setProjects([])\n }\n\n } catch (err) {\n console.error('staff.timesheets.timeReporting.load', err)\n setError(t('staff.timesheets.widgets.timeReporting.error', 'Failed to load timer state'))\n } finally {\n setLoading(false)\n onRefreshStateChange?.(false)\n }\n }, [onRefreshStateChange, t])\n\n React.useEffect(() => {\n if (!didLoadRef.current) {\n didLoadRef.current = true\n void loadState()\n return\n }\n void Promise.allSettled([loadState(), refreshActiveTimer()])\n }, [loadState, refreshActiveTimer, refreshToken])\n\n // Tick elapsed time\n React.useEffect(() => {\n if (!timerRunning || !timerStartedAt) {\n setElapsed('00:00:00')\n return\n }\n const tick = () => setElapsed(formatElapsed(timerStartedAt))\n tick()\n const interval = setInterval(tick, 1000)\n return () => clearInterval(interval)\n }, [timerRunning, timerStartedAt])\n\n const handleStart = React.useCallback(async () => {\n if (!selectedProjectId || !staffMemberId) return\n if (timerRunning) return\n setActionLoading(true)\n try {\n const today = new Date().toISOString().slice(0, 10)\n // Single atomic create+start request (issue #3311): a partial failure can\n // no longer leave an orphaned, unstarted timer entry, and a rejected start\n // now surfaces here instead of being silently ignored. The call is routed\n // through the mutation guard (issue #3308) so global injection modules can\n // run onBeforeSave/onAfterSave and surface conflicts consistently.\n const startPayload = {\n staffMemberId,\n timeProjectId: selectedProjectId,\n date: today,\n notes: notes.trim() || null,\n }\n await runMutation({\n operation: () => startTimerEntry(startPayload),\n context: {\n formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: staffMemberId,\n staffMemberId,\n action: 'timer-start',\n retryLastMutation,\n },\n mutationPayload: startPayload,\n })\n\n onSettingsChange({ ...hydrated, lastProjectId: selectedProjectId })\n await refreshActiveTimer()\n } catch (err) {\n console.error('staff.timesheets.timeReporting.start', err)\n setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer')))\n } finally {\n setActionLoading(false)\n }\n }, [selectedProjectId, staffMemberId, timerRunning, notes, runMutation, retryLastMutation, hydrated, onSettingsChange, refreshActiveTimer, t])\n\n const handleStop = React.useCallback(async () => {\n if (!activeEntryId || !staffMemberId) return\n setActionLoading(true)\n try {\n const stopPayload = {\n id: activeEntryId,\n action: 'timer-stop',\n staffMemberId,\n }\n await runMutation({\n operation: () =>\n apiCall(`/api/staff/timesheets/time-entries/${activeEntryId}/timer-stop`, { method: 'POST' }),\n context: {\n formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: activeEntryId,\n staffMemberId,\n action: 'timer-stop',\n retryLastMutation,\n },\n mutationPayload: stopPayload,\n })\n await refreshActiveTimer()\n } catch (err) {\n console.error('staff.timesheets.timeReporting.stop', err)\n setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer')))\n } finally {\n setActionLoading(false)\n }\n }, [activeEntryId, staffMemberId, runMutation, retryLastMutation, refreshActiveTimer, t])\n\n if (mode === 'settings') {\n return (\n <div className=\"space-y-2 text-sm\">\n <p className=\"text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.settings.description', 'No additional settings. Select a project and start tracking from the widget.')}\n </p>\n </div>\n )\n }\n\n if (loading || activeTimerLoading) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p className=\"text-sm text-muted-foreground\">{t('staff.timesheets.widgets.timeReporting.loading', 'Loading...')}</p>\n </div>\n )\n }\n\n if (error || activeTimerError) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p role=\"alert\" className=\"text-sm text-destructive\">\n {error ?? t('staff.timesheets.widgets.timeReporting.error', 'Failed to load timer state')}\n </p>\n </div>\n )\n }\n\n if (projects.length === 0) {\n return (\n <div className=\"flex h-full items-center justify-center py-8\">\n <p className=\"text-sm text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.noProjects', 'No projects assigned.')}\n </p>\n </div>\n )\n }\n\n // Timer is running\n if (timerRunning) {\n const runningProject = projects.find((p) => p.id === activeProjectId)\n return (\n <div className=\"space-y-3\">\n <div className=\"text-sm text-muted-foreground\">\n {runningProject?.name ?? activeProjectName ?? t('staff.timesheets.widgets.timeReporting.unknownProject', 'Unknown project')}\n </div>\n <div className=\"text-center\">\n <p className=\"font-mono text-3xl font-bold tabular-nums\">{elapsed}</p>\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.running', 'Timer running')}\n </p>\n </div>\n <Button\n type=\"button\"\n variant=\"destructive\"\n className=\"w-full\"\n onClick={handleStop}\n disabled={actionLoading}\n >\n {t('staff.timesheets.widgets.timeReporting.stop', 'Stop Timer')}\n </Button>\n </div>\n )\n }\n\n // Timer not running \u2014 show start form\n return (\n <div className=\"space-y-3\">\n <div className=\"space-y-1.5\">\n <label className=\"text-xs font-medium text-muted-foreground\" htmlFor=\"timer-project\">\n {t('staff.timesheets.widgets.timeReporting.project', 'Project')}\n </label>\n <select\n id=\"timer-project\"\n className=\"w-full rounded-md border bg-background px-3 py-2 text-sm\"\n value={selectedProjectId ?? ''}\n onChange={(e) => setSelectedProjectId(e.target.value || null)}\n >\n <option value=\"\">{t('staff.timesheets.widgets.timeReporting.selectProject', 'Select project...')}</option>\n {projects.map((project) => (\n <option key={project.id} value={project.id}>\n {project.name}{project.code ? ` (${project.code})` : ''}\n </option>\n ))}\n </select>\n </div>\n <div className=\"space-y-1.5\">\n <label className=\"text-xs font-medium text-muted-foreground\" htmlFor=\"timer-notes\">\n {t('staff.timesheets.widgets.timeReporting.taskNote', 'Task / Note')}\n </label>\n <Input\n id=\"timer-notes\"\n value={notes}\n onChange={(e) => setNotes(e.target.value)}\n placeholder={t('staff.timesheets.widgets.timeReporting.notesPlaceholder', 'What are you working on?')}\n />\n </div>\n <div className=\"text-center\">\n <p className=\"text-sm text-muted-foreground\">\n {t('staff.timesheets.widgets.timeReporting.notRunning', 'Not running')}\n </p>\n </div>\n <Button\n type=\"button\"\n className=\"w-full\"\n onClick={handleStart}\n disabled={actionLoading || !selectedProjectId}\n >\n {t('staff.timesheets.widgets.timeReporting.start', 'Start Timer')}\n </Button>\n </div>\n )\n}\n\nexport default TimeReportingWidget\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,27 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
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 { ProjectColorDot } from "../../../lib/timesheets-ui/ProjectColorDot.js";
|
|
7
|
-
|
|
8
|
-
function saveToSession(state) {
|
|
9
|
-
try {
|
|
10
|
-
if (state) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
11
|
-
else sessionStorage.removeItem(STORAGE_KEY);
|
|
12
|
-
} catch {
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
function loadFromSession() {
|
|
16
|
-
try {
|
|
17
|
-
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
18
|
-
if (!raw) return null;
|
|
19
|
-
const parsed = JSON.parse(raw);
|
|
20
|
-
if (parsed?.startedAt && parsed?.entryId) return parsed;
|
|
21
|
-
} catch {
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
6
|
+
import { useActiveTimesheetTimer } from "../../../lib/timesheets-ui/useActiveTimesheetTimer.js";
|
|
25
7
|
function formatElapsed(seconds) {
|
|
26
8
|
const h = Math.floor(seconds / 3600);
|
|
27
9
|
const m = Math.floor(seconds % 3600 / 60);
|
|
@@ -30,61 +12,11 @@ function formatElapsed(seconds) {
|
|
|
30
12
|
}
|
|
31
13
|
function TimerSidebarIndicator() {
|
|
32
14
|
const t = useT();
|
|
33
|
-
const
|
|
15
|
+
const timer = useActiveTimesheetTimer();
|
|
34
16
|
const [elapsed, setElapsed] = React.useState(0);
|
|
35
17
|
const intervalRef = React.useRef(null);
|
|
36
|
-
const updateTimer = React.useCallback((next) => {
|
|
37
|
-
setTimer(next);
|
|
38
|
-
saveToSession(next);
|
|
39
|
-
}, []);
|
|
40
|
-
const pollTimer = React.useCallback(async () => {
|
|
41
|
-
try {
|
|
42
|
-
const selfRes = await apiCall("/api/staff/team-members/self");
|
|
43
|
-
const memberId = selfRes.result?.member?.id;
|
|
44
|
-
if (!memberId) {
|
|
45
|
-
updateTimer(null);
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
const res = await apiCall(
|
|
49
|
-
`/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=50`
|
|
50
|
-
);
|
|
51
|
-
const items = res.result?.items ?? [];
|
|
52
|
-
const active = items.find((e) => e.started_at && !e.ended_at);
|
|
53
|
-
if (!active) {
|
|
54
|
-
updateTimer(null);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
const projectId = String(active.time_project_id ?? "");
|
|
58
|
-
let projectName = "";
|
|
59
|
-
let projectColor = null;
|
|
60
|
-
if (projectId) {
|
|
61
|
-
const projRes = await apiCall(
|
|
62
|
-
`/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`
|
|
63
|
-
);
|
|
64
|
-
const proj = (projRes.result?.items ?? [])[0];
|
|
65
|
-
if (proj) {
|
|
66
|
-
projectName = String(proj.name ?? "");
|
|
67
|
-
projectColor = typeof proj.color === "string" ? proj.color : null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
updateTimer({
|
|
71
|
-
entryId: String(active.id ?? ""),
|
|
72
|
-
projectName,
|
|
73
|
-
projectColor,
|
|
74
|
-
startedAt: String(active.started_at ?? "")
|
|
75
|
-
});
|
|
76
|
-
} catch {
|
|
77
|
-
}
|
|
78
|
-
}, [updateTimer]);
|
|
79
|
-
React.useEffect(() => {
|
|
80
|
-
void pollTimer();
|
|
81
|
-
const poll = setInterval(() => {
|
|
82
|
-
void pollTimer();
|
|
83
|
-
}, 3e4);
|
|
84
|
-
return () => clearInterval(poll);
|
|
85
|
-
}, [pollTimer]);
|
|
86
18
|
React.useEffect(() => {
|
|
87
|
-
if (!timer) {
|
|
19
|
+
if (!timer.running || !timer.startedAt) {
|
|
88
20
|
if (intervalRef.current) {
|
|
89
21
|
clearInterval(intervalRef.current);
|
|
90
22
|
intervalRef.current = null;
|
|
@@ -102,8 +34,8 @@ function TimerSidebarIndicator() {
|
|
|
102
34
|
intervalRef.current = null;
|
|
103
35
|
}
|
|
104
36
|
};
|
|
105
|
-
}, [timer]);
|
|
106
|
-
if (!timer) return null;
|
|
37
|
+
}, [timer.running, timer.startedAt]);
|
|
38
|
+
if (!timer.running || !timer.entryId) return null;
|
|
107
39
|
return /* @__PURE__ */ jsxs(
|
|
108
40
|
"a",
|
|
109
41
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/staff/widgets/injection/timer-sidebar-indicator/widget.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport {
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport { ProjectColorDot } from '../../../lib/timesheets-ui/ProjectColorDot'\nimport { useActiveTimesheetTimer } from '../../../lib/timesheets-ui/useActiveTimesheetTimer'\n\nfunction formatElapsed(seconds: number): string {\n const h = Math.floor(seconds / 3600)\n const m = Math.floor((seconds % 3600) / 60)\n const s = seconds % 60\n return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`\n}\n\nfunction TimerSidebarIndicator() {\n const t = useT()\n const timer = useActiveTimesheetTimer()\n const [elapsed, setElapsed] = React.useState(0)\n const intervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null)\n\n // Tick the elapsed counter locally every second\n React.useEffect(() => {\n if (!timer.running || !timer.startedAt) {\n if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null }\n setElapsed(0)\n return\n }\n\n const startTime = new Date(timer.startedAt).getTime()\n const calcElapsed = () => Math.max(0, Math.floor((Date.now() - startTime) / 1000))\n setElapsed(calcElapsed())\n\n intervalRef.current = setInterval(() => setElapsed(calcElapsed()), 1000)\n return () => {\n if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null }\n }\n }, [timer.running, timer.startedAt])\n\n if (!timer.running || !timer.entryId) return null\n\n return (\n <a\n href=\"/backend/staff/timesheets\"\n className=\"flex items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-muted transition-colors cursor-pointer\"\n title={t('staff.timesheets.sidebar.timerRunning', 'Timer running \u2014 click to view')}\n >\n <span className=\"relative flex h-2.5 w-2.5 shrink-0\">\n <span className=\"absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75\" />\n <span className=\"relative inline-flex h-2.5 w-2.5 rounded-full bg-red-500\" />\n </span>\n {timer.projectName ? (\n <span className=\"inline-flex items-center gap-1 truncate\">\n <ProjectColorDot colorKey={timer.projectColor} projectName={timer.projectName} size=\"xs\" />\n <span className=\"truncate\">{timer.projectName}</span>\n </span>\n ) : null}\n <span className=\"ml-auto font-mono tabular-nums shrink-0\">{formatElapsed(elapsed)}</span>\n </a>\n )\n}\n\nconst widget: InjectionWidgetModule = {\n metadata: {\n id: 'staff.injection.timer-sidebar-indicator',\n title: 'Active timer indicator',\n description: 'Shows a pulsing indicator in the sidebar when a timesheet timer is running.',\n features: ['staff.timesheets.manage_own'],\n },\n Widget: TimerSidebarIndicator,\n}\n\nexport default widget\n"],
|
|
5
|
+
"mappings": ";AA+CM,SACE,KADF;AA7CN,YAAY,WAAW;AACvB,SAAS,YAAY;AAErB,SAAS,uBAAuB;AAChC,SAAS,+BAA+B;AAExC,SAAS,cAAc,SAAyB;AAC9C,QAAM,IAAI,KAAK,MAAM,UAAU,IAAI;AACnC,QAAM,IAAI,KAAK,MAAO,UAAU,OAAQ,EAAE;AAC1C,QAAM,IAAI,UAAU;AACpB,SAAO,GAAG,CAAC,IAAI,OAAO,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC,IAAI,OAAO,CAAC,EAAE,SAAS,GAAG,GAAG,CAAC;AACzE;AAEA,SAAS,wBAAwB;AAC/B,QAAM,IAAI,KAAK;AACf,QAAM,QAAQ,wBAAwB;AACtC,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,CAAC;AAC9C,QAAM,cAAc,MAAM,OAA8C,IAAI;AAG5E,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,MAAM,WAAW,CAAC,MAAM,WAAW;AACtC,UAAI,YAAY,SAAS;AAAE,sBAAc,YAAY,OAAO;AAAG,oBAAY,UAAU;AAAA,MAAK;AAC1F,iBAAW,CAAC;AACZ;AAAA,IACF;AAEA,UAAM,YAAY,IAAI,KAAK,MAAM,SAAS,EAAE,QAAQ;AACpD,UAAM,cAAc,MAAM,KAAK,IAAI,GAAG,KAAK,OAAO,KAAK,IAAI,IAAI,aAAa,GAAI,CAAC;AACjF,eAAW,YAAY,CAAC;AAExB,gBAAY,UAAU,YAAY,MAAM,WAAW,YAAY,CAAC,GAAG,GAAI;AACvE,WAAO,MAAM;AACX,UAAI,YAAY,SAAS;AAAE,sBAAc,YAAY,OAAO;AAAG,oBAAY,UAAU;AAAA,MAAK;AAAA,IAC5F;AAAA,EACF,GAAG,CAAC,MAAM,SAAS,MAAM,SAAS,CAAC;AAEnC,MAAI,CAAC,MAAM,WAAW,CAAC,MAAM,QAAS,QAAO;AAE7C,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,WAAU;AAAA,MACV,OAAO,EAAE,yCAAyC,oCAA+B;AAAA,MAEjF;AAAA,6BAAC,UAAK,WAAU,sCACd;AAAA,8BAAC,UAAK,WAAU,sFAAqF;AAAA,UACrG,oBAAC,UAAK,WAAU,4DAA2D;AAAA,WAC7E;AAAA,QACC,MAAM,cACL,qBAAC,UAAK,WAAU,2CACd;AAAA,8BAAC,mBAAgB,UAAU,MAAM,cAAc,aAAa,MAAM,aAAa,MAAK,MAAK;AAAA,UACzF,oBAAC,UAAK,WAAU,YAAY,gBAAM,aAAY;AAAA,WAChD,IACE;AAAA,QACJ,oBAAC,UAAK,WAAU,2CAA2C,wBAAc,OAAO,GAAE;AAAA;AAAA;AAAA,EACpF;AAEJ;AAEA,MAAM,SAAgC;AAAA,EACpC,UAAU;AAAA,IACR,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU,CAAC,6BAA6B;AAAA,EAC1C;AAAA,EACA,QAAQ;AACV;AAEA,IAAO,iBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6453.1.6cbc2d5d47",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -253,16 +253,16 @@
|
|
|
253
253
|
"zod": "^4.4.3"
|
|
254
254
|
},
|
|
255
255
|
"peerDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6453.1.6cbc2d5d47",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6453.1.6cbc2d5d47",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6453.1.6cbc2d5d47",
|
|
259
259
|
"react": "^19.0.0",
|
|
260
260
|
"react-dom": "^19.0.0"
|
|
261
261
|
},
|
|
262
262
|
"devDependencies": {
|
|
263
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
264
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
265
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
263
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6453.1.6cbc2d5d47",
|
|
264
|
+
"@open-mercato/shared": "0.6.6-develop.6453.1.6cbc2d5d47",
|
|
265
|
+
"@open-mercato/ui": "0.6.6-develop.6453.1.6cbc2d5d47",
|
|
266
266
|
"@testing-library/dom": "^10.4.1",
|
|
267
267
|
"@testing-library/jest-dom": "^6.9.1",
|
|
268
268
|
"@testing-library/react": "^16.3.1",
|
|
@@ -5,10 +5,11 @@ import { Play, Square } from 'lucide-react'
|
|
|
5
5
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
6
6
|
import { IconButton } from '@open-mercato/ui/primitives/icon-button'
|
|
7
7
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
-
import {
|
|
8
|
+
import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
9
9
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
10
10
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
11
11
|
import { ProjectColorDot } from './ProjectColorDot'
|
|
12
|
+
import { useActiveTimesheetTimer } from './useActiveTimesheetTimer'
|
|
12
13
|
import { startTimerEntry } from './startTimer'
|
|
13
14
|
import { resolveTimerActionError } from './timerErrors'
|
|
14
15
|
|
|
@@ -58,8 +59,6 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
|
|
|
58
59
|
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
59
60
|
})
|
|
60
61
|
|
|
61
|
-
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
|
|
62
|
-
const [activeProjectId, setActiveProjectId] = useState<string | null>(null)
|
|
63
62
|
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
|
64
63
|
const [description, setDescription] = useState('')
|
|
65
64
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
|
@@ -70,10 +69,15 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
|
|
|
70
69
|
|
|
71
70
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
72
71
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
72
|
+
const activeTimer = useActiveTimesheetTimer({ staffMemberId })
|
|
73
73
|
|
|
74
|
-
const
|
|
74
|
+
const activeEntryId = activeTimer.entryId
|
|
75
|
+
const activeProjectId = activeTimer.projectId
|
|
76
|
+
const isRunning = activeTimer.running
|
|
75
77
|
|
|
76
78
|
const activeProject = projects.find((p) => p.id === activeProjectId)
|
|
79
|
+
const activeProjectName = activeProject?.name ?? activeTimer.projectName
|
|
80
|
+
const activeProjectColor = activeProject?.color ?? activeTimer.projectColor
|
|
77
81
|
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
|
78
82
|
|
|
79
83
|
const filteredProjects = projects.filter((p) =>
|
|
@@ -100,32 +104,13 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
|
|
|
100
104
|
}, [])
|
|
101
105
|
|
|
102
106
|
useEffect(() => {
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
`/api/staff/timesheets/time-entries?staffMemberId=${staffMemberId}&running=true&pageSize=50`,
|
|
108
|
-
)
|
|
109
|
-
if (!response.ok) return
|
|
110
|
-
|
|
111
|
-
const data = response.result as Record<string, unknown> | null
|
|
112
|
-
const items = (data?.items ?? data?.data ?? []) as Array<Record<string, unknown>>
|
|
113
|
-
|
|
114
|
-
const activeEntry = items.find(
|
|
115
|
-
(entry: Record<string, unknown>) =>
|
|
116
|
-
entry.started_at && !entry.ended_at,
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
if (activeEntry) {
|
|
120
|
-
setActiveEntryId(String(activeEntry.id ?? ''))
|
|
121
|
-
setActiveProjectId(String(activeEntry.time_project_id ?? ''))
|
|
122
|
-
setDescription(String(activeEntry.notes ?? ''))
|
|
123
|
-
startElapsedCounter(String(activeEntry.started_at ?? ''))
|
|
124
|
-
}
|
|
107
|
+
if (activeTimer.running && activeTimer.startedAt) {
|
|
108
|
+
startElapsedCounter(activeTimer.startedAt)
|
|
109
|
+
if (activeTimer.notes != null) setDescription(activeTimer.notes)
|
|
110
|
+
return
|
|
125
111
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}, [staffMemberId, startElapsedCounter])
|
|
112
|
+
stopElapsedCounter()
|
|
113
|
+
}, [activeTimer.running, activeTimer.startedAt, activeTimer.notes, startElapsedCounter, stopElapsedCounter])
|
|
129
114
|
|
|
130
115
|
useEffect(() => {
|
|
131
116
|
return () => {
|
|
@@ -162,7 +147,7 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
|
|
|
162
147
|
date: today,
|
|
163
148
|
notes: description || null,
|
|
164
149
|
}
|
|
165
|
-
|
|
150
|
+
await runMutation({
|
|
166
151
|
operation: () => startTimerEntry(startPayload),
|
|
167
152
|
context: {
|
|
168
153
|
formId: TIMER_MUTATION_CONTEXT_ID,
|
|
@@ -175,9 +160,7 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
|
|
|
175
160
|
mutationPayload: startPayload,
|
|
176
161
|
})
|
|
177
162
|
|
|
178
|
-
|
|
179
|
-
setActiveProjectId(selectedProjectId)
|
|
180
|
-
startElapsedCounter(new Date().toISOString())
|
|
163
|
+
await activeTimer.refresh()
|
|
181
164
|
} catch (err) {
|
|
182
165
|
flash(
|
|
183
166
|
resolveTimerActionError(err, t('staff.timesheets.my.timer.startError', 'Failed to start timer')),
|
|
@@ -215,10 +198,8 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
|
|
|
215
198
|
mutationPayload: stopPayload,
|
|
216
199
|
})
|
|
217
200
|
|
|
218
|
-
setActiveEntryId(null)
|
|
219
|
-
setActiveProjectId(null)
|
|
220
201
|
setDescription('')
|
|
221
|
-
|
|
202
|
+
await activeTimer.refresh()
|
|
222
203
|
onTimerStopped()
|
|
223
204
|
} catch (err) {
|
|
224
205
|
flash(
|
|
@@ -246,10 +227,10 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
|
|
|
246
227
|
|
|
247
228
|
<div className="relative" ref={dropdownRef}>
|
|
248
229
|
{isRunning ? (
|
|
249
|
-
|
|
230
|
+
activeProjectName ? (
|
|
250
231
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded border bg-muted">
|
|
251
|
-
<ProjectColorDot colorKey={
|
|
252
|
-
{
|
|
232
|
+
<ProjectColorDot colorKey={activeProjectColor} projectName={activeProjectName} size="xs" />
|
|
233
|
+
{activeProjectName}
|
|
253
234
|
</span>
|
|
254
235
|
) : null
|
|
255
236
|
) : (
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
|
6
|
+
|
|
7
|
+
export type ActiveTimesheetTimer = {
|
|
8
|
+
staffMemberId: string | null
|
|
9
|
+
entryId: string | null
|
|
10
|
+
running: boolean
|
|
11
|
+
startedAt: string | null
|
|
12
|
+
projectId: string | null
|
|
13
|
+
projectName: string | null
|
|
14
|
+
projectColor: string | null
|
|
15
|
+
notes: string | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ActiveTimesheetTimerResult = ActiveTimesheetTimer & {
|
|
19
|
+
isLoading: boolean
|
|
20
|
+
isFetching: boolean
|
|
21
|
+
error: Error | null
|
|
22
|
+
refresh: () => Promise<ActiveTimesheetTimer>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ActiveTimesheetTimerOptions = {
|
|
26
|
+
staffMemberId?: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ACTIVE_TIMER_REVALIDATE_MS = 30_000
|
|
30
|
+
const BASE_ACTIVE_TIMER_KEY = ['staff', 'timesheets', 'activeTimer'] as const
|
|
31
|
+
|
|
32
|
+
const EMPTY_ACTIVE_TIMER: ActiveTimesheetTimer = {
|
|
33
|
+
staffMemberId: null,
|
|
34
|
+
entryId: null,
|
|
35
|
+
running: false,
|
|
36
|
+
startedAt: null,
|
|
37
|
+
projectId: null,
|
|
38
|
+
projectName: null,
|
|
39
|
+
projectColor: null,
|
|
40
|
+
notes: null,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const activeTimesheetTimerQueryKey = () => BASE_ACTIVE_TIMER_KEY
|
|
44
|
+
|
|
45
|
+
function getString(value: unknown): string | null {
|
|
46
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getErrorMessage(result: unknown, fallback: string): string {
|
|
50
|
+
if (result && typeof result === 'object') {
|
|
51
|
+
const error = (result as Record<string, unknown>).error
|
|
52
|
+
if (typeof error === 'string' && error.length > 0) return error
|
|
53
|
+
}
|
|
54
|
+
return fallback
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadCurrentStaffMemberId(staffMemberId?: string | null): Promise<string | null> {
|
|
58
|
+
if (staffMemberId) return staffMemberId
|
|
59
|
+
const selfRes = await apiCall<{ member?: { id?: string | null } | null }>('/api/staff/team-members/self')
|
|
60
|
+
if (!selfRes.ok) {
|
|
61
|
+
throw new Error(getErrorMessage(selfRes.result, 'Failed to load current staff member.'))
|
|
62
|
+
}
|
|
63
|
+
return selfRes.result?.member?.id ?? null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function loadProjectDisplay(projectId: string): Promise<Pick<ActiveTimesheetTimer, 'projectName' | 'projectColor'>> {
|
|
67
|
+
const projectRes = await apiCall<{ items?: Array<Record<string, unknown>> }>(
|
|
68
|
+
`/api/staff/timesheets/time-projects?ids=${encodeURIComponent(projectId)}&pageSize=1`,
|
|
69
|
+
)
|
|
70
|
+
if (!projectRes.ok) {
|
|
71
|
+
return { projectName: null, projectColor: null }
|
|
72
|
+
}
|
|
73
|
+
const project = projectRes.result?.items?.[0]
|
|
74
|
+
return {
|
|
75
|
+
projectName: getString(project?.name),
|
|
76
|
+
projectColor: getString(project?.color),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function fetchActiveTimesheetTimer(staffMemberId?: string | null): Promise<ActiveTimesheetTimer> {
|
|
81
|
+
const memberId = await loadCurrentStaffMemberId(staffMemberId)
|
|
82
|
+
if (!memberId) {
|
|
83
|
+
return EMPTY_ACTIVE_TIMER
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Look up the active timer by running state rather than by today's date (issue #3717):
|
|
87
|
+
// a timer started before midnight is still running the next day, and a from=today&to=today
|
|
88
|
+
// lookup would hide it once the date rolls over, leaving an orphaned timer unstoppable.
|
|
89
|
+
const entriesRes = await apiCall<{ items?: Array<Record<string, unknown>> }>(
|
|
90
|
+
`/api/staff/timesheets/time-entries?staffMemberId=${encodeURIComponent(memberId)}&running=true&pageSize=50`,
|
|
91
|
+
)
|
|
92
|
+
if (!entriesRes.ok) {
|
|
93
|
+
throw new Error(getErrorMessage(entriesRes.result, 'Failed to load active timer.'))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const entries = Array.isArray(entriesRes.result?.items) ? entriesRes.result.items : []
|
|
97
|
+
const active = entries.find((entry) => {
|
|
98
|
+
const startedAt = entry.started_at ?? entry.startedAt
|
|
99
|
+
const endedAt = entry.ended_at ?? entry.endedAt
|
|
100
|
+
return startedAt != null && endedAt == null
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if (!active) {
|
|
104
|
+
return { ...EMPTY_ACTIVE_TIMER, staffMemberId: memberId }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const projectId = getString(active.time_project_id ?? active.timeProjectId)
|
|
108
|
+
const projectDisplay = projectId
|
|
109
|
+
? await loadProjectDisplay(projectId)
|
|
110
|
+
: { projectName: null, projectColor: null }
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
staffMemberId: memberId,
|
|
114
|
+
entryId: getString(active.id),
|
|
115
|
+
running: true,
|
|
116
|
+
startedAt: getString(active.started_at ?? active.startedAt),
|
|
117
|
+
projectId,
|
|
118
|
+
projectName: projectDisplay.projectName,
|
|
119
|
+
projectColor: projectDisplay.projectColor,
|
|
120
|
+
notes: getString(active.notes),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const activeTimesheetTimerQueryOptions = (staffMemberId?: string | null) => ({
|
|
125
|
+
queryKey: activeTimesheetTimerQueryKey(),
|
|
126
|
+
staleTime: ACTIVE_TIMER_REVALIDATE_MS,
|
|
127
|
+
gcTime: ACTIVE_TIMER_REVALIDATE_MS,
|
|
128
|
+
refetchInterval: ACTIVE_TIMER_REVALIDATE_MS,
|
|
129
|
+
queryFn: () => fetchActiveTimesheetTimer(staffMemberId),
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
export async function refreshActiveTimesheetTimer(
|
|
133
|
+
queryClient: QueryClient,
|
|
134
|
+
staffMemberId?: string | null,
|
|
135
|
+
): Promise<ActiveTimesheetTimer> {
|
|
136
|
+
return queryClient.fetchQuery({
|
|
137
|
+
...activeTimesheetTimerQueryOptions(staffMemberId),
|
|
138
|
+
staleTime: 0,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function useActiveTimesheetTimer(
|
|
143
|
+
options: ActiveTimesheetTimerOptions = {},
|
|
144
|
+
): ActiveTimesheetTimerResult {
|
|
145
|
+
const queryClient = useQueryClient()
|
|
146
|
+
const query = useQuery(activeTimesheetTimerQueryOptions(options.staffMemberId))
|
|
147
|
+
const data = query.data ?? EMPTY_ACTIVE_TIMER
|
|
148
|
+
const refresh = React.useCallback(
|
|
149
|
+
() => refreshActiveTimesheetTimer(queryClient, options.staffMemberId),
|
|
150
|
+
[queryClient, options.staffMemberId],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
...data,
|
|
155
|
+
isLoading: query.isLoading,
|
|
156
|
+
isFetching: query.isFetching,
|
|
157
|
+
error: query.error instanceof Error ? query.error : null,
|
|
158
|
+
refresh,
|
|
159
|
+
}
|
|
160
|
+
}
|