@open-mercato/core 0.6.6-develop.6151.1.3028861dfe → 0.6.6-develop.6156.1.5ac693a69a
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/messages/api/[id]/actions/[actionId]/route.js +32 -0
- package/dist/modules/messages/api/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/archive/route.js +63 -0
- package/dist/modules/messages/api/[id]/archive/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/attachments/route.js +63 -0
- package/dist/modules/messages/api/[id]/attachments/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/conversation/archive/route.js +32 -0
- package/dist/modules/messages/api/[id]/conversation/archive/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/conversation/read/route.js +32 -0
- package/dist/modules/messages/api/[id]/conversation/read/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/conversation/route.js +32 -0
- package/dist/modules/messages/api/[id]/conversation/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/forward/route.js +32 -0
- package/dist/modules/messages/api/[id]/forward/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/read/route.js +63 -0
- package/dist/modules/messages/api/[id]/read/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/reply/route.js +32 -0
- package/dist/modules/messages/api/[id]/reply/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +63 -0
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/messages/api/guards.js +31 -0
- package/dist/modules/messages/api/guards.js.map +7 -0
- package/dist/modules/messages/api/route.js +32 -0
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/start-timer/route.js +107 -0
- package/dist/modules/staff/api/timesheets/time-entries/start-timer/route.js.map +7 -0
- package/dist/modules/staff/commands/timesheets-entries.js +142 -1
- package/dist/modules/staff/commands/timesheets-entries.js.map +3 -3
- package/dist/modules/staff/data/validators.js +8 -0
- package/dist/modules/staff/data/validators.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +8 -22
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/startTimer.js +22 -0
- package/dist/modules/staff/lib/timesheets-ui/startTimer.js.map +7 -0
- package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +6 -16
- package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/messages/api/[id]/actions/[actionId]/route.ts +34 -0
- package/src/modules/messages/api/[id]/archive/route.ts +63 -0
- package/src/modules/messages/api/[id]/attachments/route.ts +63 -0
- package/src/modules/messages/api/[id]/conversation/archive/route.ts +33 -0
- package/src/modules/messages/api/[id]/conversation/read/route.ts +33 -0
- package/src/modules/messages/api/[id]/conversation/route.ts +33 -0
- package/src/modules/messages/api/[id]/forward/route.ts +33 -0
- package/src/modules/messages/api/[id]/read/route.ts +63 -0
- package/src/modules/messages/api/[id]/reply/route.ts +33 -0
- package/src/modules/messages/api/[id]/route.ts +65 -0
- package/src/modules/messages/api/guards.ts +59 -0
- package/src/modules/messages/api/route.ts +33 -0
- package/src/modules/staff/api/timesheets/time-entries/start-timer/route.ts +110 -0
- package/src/modules/staff/commands/timesheets-entries.ts +166 -1
- package/src/modules/staff/data/validators.ts +9 -0
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +9 -25
- package/src/modules/staff/lib/timesheets-ui/startTimer.ts +40 -0
- package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +9 -18
|
@@ -7,15 +7,18 @@ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
|
7
7
|
import { buildChanges, emitCrudSideEffects, emitCrudUndoSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
8
8
|
import { makeCreateRedo } from '@open-mercato/shared/lib/commands/redo'
|
|
9
9
|
import type { CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
|
|
10
|
-
import { StaffTimeEntry, StaffTimeProject, type StaffTimeEntrySource } from '../data/entities'
|
|
10
|
+
import { StaffTimeEntry, StaffTimeEntrySegment, StaffTimeProject, type StaffTimeEntrySource } from '../data/entities'
|
|
11
|
+
import { emitStaffEvent } from '../events'
|
|
11
12
|
|
|
12
13
|
const timeEntryCrudIndexer: CrudIndexerConfig<StaffTimeEntry> = {
|
|
13
14
|
entityType: 'staff:staff_time_entry',
|
|
14
15
|
}
|
|
15
16
|
import {
|
|
16
17
|
staffTimeEntryCreateSchema,
|
|
18
|
+
staffTimeEntryStartTimerSchema,
|
|
17
19
|
staffTimeEntryUpdateSchema,
|
|
18
20
|
type StaffTimeEntryCreateInput,
|
|
21
|
+
type StaffTimeEntryStartTimerInput,
|
|
19
22
|
type StaffTimeEntryUpdateInput,
|
|
20
23
|
} from '../data/validators'
|
|
21
24
|
import { staffTimeEntryCrudEvents } from '../lib/crud'
|
|
@@ -281,6 +284,167 @@ const createTimeEntryCommand: CommandHandler<StaffTimeEntryCreateInput, { timeEn
|
|
|
281
284
|
}),
|
|
282
285
|
}
|
|
283
286
|
|
|
287
|
+
const startTimerCommand: CommandHandler<StaffTimeEntryStartTimerInput, { timeEntryId: string }> = {
|
|
288
|
+
id: 'staff.timesheets.time_entries.start_timer',
|
|
289
|
+
async execute(rawInput, ctx) {
|
|
290
|
+
const parsed = staffTimeEntryStartTimerSchema.parse(rawInput)
|
|
291
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
292
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
293
|
+
|
|
294
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
295
|
+
|
|
296
|
+
// Ownership enforcement mirrors createTimeEntryCommand: callers without
|
|
297
|
+
// `staff.timesheets.manage_all` can only start their own timer, so the
|
|
298
|
+
// request body's staffMemberId can't start a timer under a colleague's id.
|
|
299
|
+
let effectiveStaffMemberId = parsed.staffMemberId
|
|
300
|
+
if (!(await callerHasManageAll(ctx))) {
|
|
301
|
+
const callerStaffMemberId = await resolveCallerStaffMemberId(em, ctx)
|
|
302
|
+
if (!callerStaffMemberId) {
|
|
303
|
+
const { translate } = await resolveTranslations()
|
|
304
|
+
throw new CrudHttpError(403, {
|
|
305
|
+
error: translate('staff.timesheets.errors.noStaffMember', 'No staff member linked to your account.'),
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
effectiveStaffMemberId = callerStaffMemberId
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await assertTimeProjectInScope(em, parsed.timeProjectId ?? null, parsed.tenantId, parsed.organizationId)
|
|
312
|
+
|
|
313
|
+
const scopeCtx = { tenantId: parsed.tenantId, organizationId: parsed.organizationId }
|
|
314
|
+
|
|
315
|
+
// Create the timer entry AND start it inside a single transaction so a
|
|
316
|
+
// partial failure can never leave an orphaned, never-started timer entry
|
|
317
|
+
// (issue #3311 — the legacy two-request create-then-start flow). The
|
|
318
|
+
// single-active-timer invariant (#2855) is re-checked here so a second
|
|
319
|
+
// surface cannot create a parallel running timer for the same staff member.
|
|
320
|
+
const { entry, startedAt } = await em.transactional(async (trx) => {
|
|
321
|
+
const otherRunningEntry = await findOneWithDecryption(
|
|
322
|
+
trx,
|
|
323
|
+
StaffTimeEntry,
|
|
324
|
+
{
|
|
325
|
+
tenantId: parsed.tenantId,
|
|
326
|
+
organizationId: parsed.organizationId,
|
|
327
|
+
staffMemberId: effectiveStaffMemberId,
|
|
328
|
+
startedAt: { $ne: null },
|
|
329
|
+
endedAt: null,
|
|
330
|
+
deletedAt: null,
|
|
331
|
+
},
|
|
332
|
+
{},
|
|
333
|
+
scopeCtx,
|
|
334
|
+
)
|
|
335
|
+
if (otherRunningEntry) {
|
|
336
|
+
const { translate } = await resolveTranslations()
|
|
337
|
+
throw new CrudHttpError(409, {
|
|
338
|
+
error: translate(
|
|
339
|
+
'staff.timesheets.errors.timerAlreadyRunning',
|
|
340
|
+
'Another timer is already running. Stop it before starting a new one.',
|
|
341
|
+
),
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const startedAt = new Date()
|
|
346
|
+
const entry = trx.create(StaffTimeEntry, {
|
|
347
|
+
tenantId: parsed.tenantId,
|
|
348
|
+
organizationId: parsed.organizationId,
|
|
349
|
+
staffMemberId: effectiveStaffMemberId,
|
|
350
|
+
date: parsed.date,
|
|
351
|
+
durationMinutes: 0,
|
|
352
|
+
startedAt,
|
|
353
|
+
endedAt: null,
|
|
354
|
+
notes: parsed.notes ?? null,
|
|
355
|
+
timeProjectId: parsed.timeProjectId ?? null,
|
|
356
|
+
customerId: null,
|
|
357
|
+
dealId: null,
|
|
358
|
+
orderId: null,
|
|
359
|
+
source: 'timer',
|
|
360
|
+
createdAt: startedAt,
|
|
361
|
+
updatedAt: startedAt,
|
|
362
|
+
deletedAt: null,
|
|
363
|
+
})
|
|
364
|
+
// Flush so the DB-generated id is populated before the work segment
|
|
365
|
+
// references it; both writes commit together when the transaction closes.
|
|
366
|
+
await trx.flush()
|
|
367
|
+
|
|
368
|
+
const segmentData = {
|
|
369
|
+
tenantId: parsed.tenantId,
|
|
370
|
+
organizationId: parsed.organizationId,
|
|
371
|
+
timeEntryId: entry.id,
|
|
372
|
+
startedAt,
|
|
373
|
+
segmentType: 'work' as const,
|
|
374
|
+
}
|
|
375
|
+
trx.create(StaffTimeEntrySegment, segmentData as never)
|
|
376
|
+
await trx.flush()
|
|
377
|
+
|
|
378
|
+
return { entry, startedAt }
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
await emitCrudSideEffects({
|
|
382
|
+
dataEngine: ctx.container.resolve('dataEngine'),
|
|
383
|
+
action: 'created',
|
|
384
|
+
entity: entry,
|
|
385
|
+
identifiers: { id: entry.id, organizationId: entry.organizationId, tenantId: entry.tenantId },
|
|
386
|
+
events: staffTimeEntryCrudEvents,
|
|
387
|
+
indexer: timeEntryCrudIndexer,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
void emitStaffEvent('staff.timesheets.time_entry.timer_started', {
|
|
391
|
+
id: entry.id,
|
|
392
|
+
staffMemberId: effectiveStaffMemberId,
|
|
393
|
+
tenantId: parsed.tenantId,
|
|
394
|
+
organizationId: parsed.organizationId,
|
|
395
|
+
startedAt: startedAt.toISOString(),
|
|
396
|
+
}, { persistent: true }).catch((err) => {
|
|
397
|
+
console.error('[staff.timesheets] emit timer_started failed', err)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
return { timeEntryId: entry.id }
|
|
401
|
+
},
|
|
402
|
+
captureAfter: async (_input, result, ctx) => {
|
|
403
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
404
|
+
const snapshot = await loadTimeEntrySnapshot(em, result.timeEntryId)
|
|
405
|
+
if (!snapshot) return null
|
|
406
|
+
return { snapshot }
|
|
407
|
+
},
|
|
408
|
+
buildLog: async ({ result, ctx }) => {
|
|
409
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
410
|
+
const snapshot = await loadTimeEntrySnapshot(em, result.timeEntryId)
|
|
411
|
+
if (!snapshot) return null
|
|
412
|
+
const { translate } = await resolveTranslations()
|
|
413
|
+
return {
|
|
414
|
+
actionLabel: translate('staff.audit.timesheets.time_entries.startTimer', 'Start timer'),
|
|
415
|
+
resourceKind: 'staff.timesheets.time_entry',
|
|
416
|
+
resourceId: snapshot.id,
|
|
417
|
+
tenantId: snapshot.tenantId,
|
|
418
|
+
organizationId: snapshot.organizationId,
|
|
419
|
+
snapshotAfter: snapshot,
|
|
420
|
+
payload: {
|
|
421
|
+
undo: {
|
|
422
|
+
after: snapshot,
|
|
423
|
+
} satisfies TimeEntryUndoPayload,
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
undo: async ({ logEntry, ctx }) => {
|
|
428
|
+
const payload = extractUndoPayload<TimeEntryUndoPayload>(logEntry)
|
|
429
|
+
const after = payload?.after
|
|
430
|
+
if (!after) return
|
|
431
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
432
|
+
const entry = await em.findOne(StaffTimeEntry, { id: after.id })
|
|
433
|
+
if (entry) {
|
|
434
|
+
entry.deletedAt = new Date()
|
|
435
|
+
await em.flush()
|
|
436
|
+
|
|
437
|
+
await emitCrudUndoSideEffects({
|
|
438
|
+
dataEngine: ctx.container.resolve('dataEngine'),
|
|
439
|
+
action: 'deleted',
|
|
440
|
+
entity: entry,
|
|
441
|
+
identifiers: { id: entry.id, organizationId: entry.organizationId, tenantId: entry.tenantId },
|
|
442
|
+
events: staffTimeEntryCrudEvents,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
|
|
284
448
|
const updateTimeEntryCommand: CommandHandler<StaffTimeEntryUpdateInput, { timeEntryId: string }> = {
|
|
285
449
|
id: 'staff.timesheets.time_entries.update',
|
|
286
450
|
async prepare(rawInput, ctx) {
|
|
@@ -531,5 +695,6 @@ const deleteTimeEntryCommand: CommandHandler<{ id?: string }, { timeEntryId: str
|
|
|
531
695
|
}
|
|
532
696
|
|
|
533
697
|
registerCommand(createTimeEntryCommand)
|
|
698
|
+
registerCommand(startTimerCommand)
|
|
534
699
|
registerCommand(updateTimeEntryCommand)
|
|
535
700
|
registerCommand(deleteTimeEntryCommand)
|
|
@@ -291,6 +291,14 @@ export const staffTimeEntryUpdateSchema = z.object({
|
|
|
291
291
|
notes: z.string().max(2000).optional().nullable(),
|
|
292
292
|
})
|
|
293
293
|
|
|
294
|
+
export const staffTimeEntryStartTimerSchema = z.object({
|
|
295
|
+
...scopedCreateFields,
|
|
296
|
+
staffMemberId: z.string().uuid(),
|
|
297
|
+
date: z.coerce.date(),
|
|
298
|
+
timeProjectId: z.string().uuid().optional().nullable(),
|
|
299
|
+
notes: z.string().max(2000).optional().nullable(),
|
|
300
|
+
})
|
|
301
|
+
|
|
294
302
|
export const staffTimeEntryBulkItemSchema = z.object({
|
|
295
303
|
id: z.string().uuid().optional().nullable(),
|
|
296
304
|
date: z.coerce.date(),
|
|
@@ -369,6 +377,7 @@ export const staffMyProjectVisibilityUpdateSchema = z.object({
|
|
|
369
377
|
|
|
370
378
|
export type StaffTimeEntryCreateInput = z.infer<typeof staffTimeEntryCreateSchema>
|
|
371
379
|
export type StaffTimeEntryUpdateInput = z.infer<typeof staffTimeEntryUpdateSchema>
|
|
380
|
+
export type StaffTimeEntryStartTimerInput = z.infer<typeof staffTimeEntryStartTimerSchema>
|
|
372
381
|
export type StaffTimeEntryBulkSaveInput = z.infer<typeof staffTimeEntryBulkSaveSchema>
|
|
373
382
|
export type StaffTimeEntrySegmentCreateInput = z.infer<typeof staffTimeEntrySegmentCreateSchema>
|
|
374
383
|
export type StaffTimeEntrySegmentUpdateInput = z.infer<typeof staffTimeEntrySegmentUpdateSchema>
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"staff.audit.teams.update": "Team aktualisieren",
|
|
121
121
|
"staff.audit.timesheets.time_entries.create": "Zeiteintrag erstellen",
|
|
122
122
|
"staff.audit.timesheets.time_entries.delete": "Zeiteintrag löschen",
|
|
123
|
+
"staff.audit.timesheets.time_entries.startTimer": "Timer starten",
|
|
123
124
|
"staff.audit.timesheets.time_entries.update": "Zeiteintrag aktualisieren",
|
|
124
125
|
"staff.audit.timesheets.time_project_members.assign": "Zeitprojektmitglied zuweisen",
|
|
125
126
|
"staff.audit.timesheets.time_project_members.unassign": "Zeitprojektmitglied entfernen",
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"staff.audit.teams.update": "Update team",
|
|
121
121
|
"staff.audit.timesheets.time_entries.create": "Create time entry",
|
|
122
122
|
"staff.audit.timesheets.time_entries.delete": "Delete time entry",
|
|
123
|
+
"staff.audit.timesheets.time_entries.startTimer": "Start timer",
|
|
123
124
|
"staff.audit.timesheets.time_entries.update": "Update time entry",
|
|
124
125
|
"staff.audit.timesheets.time_project_members.assign": "Assign time project member",
|
|
125
126
|
"staff.audit.timesheets.time_project_members.unassign": "Unassign time project member",
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"staff.audit.teams.update": "Actualizar equipo",
|
|
121
121
|
"staff.audit.timesheets.time_entries.create": "Crear registro de tiempo",
|
|
122
122
|
"staff.audit.timesheets.time_entries.delete": "Eliminar registro de tiempo",
|
|
123
|
+
"staff.audit.timesheets.time_entries.startTimer": "Iniciar temporizador",
|
|
123
124
|
"staff.audit.timesheets.time_entries.update": "Actualizar registro de tiempo",
|
|
124
125
|
"staff.audit.timesheets.time_project_members.assign": "Asignar miembro del proyecto de tiempo",
|
|
125
126
|
"staff.audit.timesheets.time_project_members.unassign": "Desasignar miembro del proyecto de tiempo",
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"staff.audit.teams.update": "Zaktualizuj zespół",
|
|
121
121
|
"staff.audit.timesheets.time_entries.create": "Utwórz wpis czasu",
|
|
122
122
|
"staff.audit.timesheets.time_entries.delete": "Usuń wpis czasu",
|
|
123
|
+
"staff.audit.timesheets.time_entries.startTimer": "Uruchom licznik czasu",
|
|
123
124
|
"staff.audit.timesheets.time_entries.update": "Zaktualizuj wpis czasu",
|
|
124
125
|
"staff.audit.timesheets.time_project_members.assign": "Przypisz członka projektu czasu",
|
|
125
126
|
"staff.audit.timesheets.time_project_members.unassign": "Usuń przypisanie członka projektu czasu",
|
|
@@ -8,6 +8,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
|
8
8
|
import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
9
9
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
10
10
|
import { ProjectColorDot } from './ProjectColorDot'
|
|
11
|
+
import { startTimerEntry } from './startTimer'
|
|
11
12
|
|
|
12
13
|
type ProjectOption = {
|
|
13
14
|
id: string
|
|
@@ -140,31 +141,14 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
|
|
|
140
141
|
setIsStarting(true)
|
|
141
142
|
try {
|
|
142
143
|
const today = getToday()
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
date: today,
|
|
152
|
-
durationMinutes: 0,
|
|
153
|
-
source: 'timer',
|
|
154
|
-
notes: description || null,
|
|
155
|
-
}),
|
|
156
|
-
},
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
const created = createResponse.result as Record<string, unknown> | null
|
|
160
|
-
const entryId = created?.id as string | undefined
|
|
161
|
-
|
|
162
|
-
await apiCallOrThrow(
|
|
163
|
-
`/api/staff/timesheets/time-entries/${entryId}/timer-start`,
|
|
164
|
-
{ method: 'POST' },
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
setActiveEntryId(entryId ?? null)
|
|
144
|
+
const { id: entryId } = await startTimerEntry({
|
|
145
|
+
staffMemberId,
|
|
146
|
+
timeProjectId: selectedProjectId,
|
|
147
|
+
date: today,
|
|
148
|
+
notes: description || null,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
setActiveEntryId(entryId)
|
|
168
152
|
setActiveProjectId(selectedProjectId)
|
|
169
153
|
startElapsedCounter(new Date().toISOString())
|
|
170
154
|
} catch {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
2
|
+
|
|
3
|
+
export type StartTimerEntryInput = {
|
|
4
|
+
staffMemberId: string
|
|
5
|
+
timeProjectId: string
|
|
6
|
+
date: string
|
|
7
|
+
notes?: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type StartedTimerEntry = {
|
|
11
|
+
id: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Starts a timesheet timer through the single atomic server endpoint
|
|
16
|
+
* (`/api/staff/timesheets/time-entries/start-timer`), which creates the
|
|
17
|
+
* timer-sourced entry AND starts it in one transaction. This replaces the
|
|
18
|
+
* legacy create-then-start two-request sequence so a partial failure (a failed
|
|
19
|
+
* second request, or the browser navigating between the two calls) can no
|
|
20
|
+
* longer leave an orphaned, never-started timer entry (issue #3311). Both the
|
|
21
|
+
* TimerBar and the dashboard time-reporting widget call this helper so they
|
|
22
|
+
* share error handling and refresh semantics.
|
|
23
|
+
*/
|
|
24
|
+
export async function startTimerEntry(input: StartTimerEntryInput): Promise<StartedTimerEntry> {
|
|
25
|
+
const response = await apiCallOrThrow<Record<string, unknown>>(
|
|
26
|
+
'/api/staff/timesheets/time-entries/start-timer',
|
|
27
|
+
{
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
staffMemberId: input.staffMemberId,
|
|
32
|
+
timeProjectId: input.timeProjectId,
|
|
33
|
+
date: input.date,
|
|
34
|
+
notes: input.notes ?? null,
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
const result = response.result as Record<string, unknown> | null
|
|
39
|
+
return { id: (result?.id as string | undefined) ?? null }
|
|
40
|
+
}
|
|
@@ -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 { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'
|
|
9
10
|
import { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from './config'
|
|
10
11
|
|
|
11
12
|
type ProjectOption = { id: string; name: string; code: string | null }
|
|
@@ -138,25 +139,15 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
|
|
|
138
139
|
setActionLoading(true)
|
|
139
140
|
try {
|
|
140
141
|
const today = new Date().toISOString().slice(0, 10)
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
durationMinutes: 0,
|
|
150
|
-
notes: notes.trim() || null,
|
|
151
|
-
source: 'timer',
|
|
152
|
-
}),
|
|
142
|
+
// Single atomic create+start request (issue #3311): a partial failure can
|
|
143
|
+
// no longer leave an orphaned, unstarted timer entry, and a rejected start
|
|
144
|
+
// now surfaces here instead of being silently ignored.
|
|
145
|
+
await startTimerEntry({
|
|
146
|
+
staffMemberId,
|
|
147
|
+
timeProjectId: selectedProjectId,
|
|
148
|
+
date: today,
|
|
149
|
+
notes: notes.trim() || null,
|
|
153
150
|
})
|
|
154
|
-
if (!createRes.ok) throw new Error('Failed to create entry')
|
|
155
|
-
const body = createRes.result as Record<string, unknown> | null
|
|
156
|
-
const entryId = String(body?.id ?? (body?.item as Record<string, unknown> | undefined)?.id ?? '')
|
|
157
|
-
if (!entryId) throw new Error('Failed to extract entry ID')
|
|
158
|
-
|
|
159
|
-
await apiCall(`/api/staff/timesheets/time-entries/${entryId}/timer-start`, { method: 'POST' })
|
|
160
151
|
|
|
161
152
|
onSettingsChange({ ...hydrated, lastProjectId: selectedProjectId })
|
|
162
153
|
await loadState()
|