@open-mercato/core 0.10.1-develop.7922.1.417ebe275c → 0.10.1-develop.7925.1.ec40f75262

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.
Files changed (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/products/create/page.js +16 -4
  3. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  4. package/dist/modules/catalog/components/products/ProductMediaManager.js +5 -2
  5. package/dist/modules/catalog/components/products/ProductMediaManager.js.map +2 -2
  6. package/dist/modules/entities/api/records.js +18 -2
  7. package/dist/modules/entities/api/records.js.map +2 -2
  8. package/dist/modules/staff/api/timesheets/time-entries/route.js +65 -5
  9. package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
  10. package/dist/modules/staff/backend/staff/time-tracking/entries/page.js +49 -25
  11. package/dist/modules/staff/backend/staff/time-tracking/entries/page.js.map +2 -2
  12. package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js +13 -6
  13. package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js.map +2 -2
  14. package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js +23 -0
  15. package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js.map +2 -2
  16. package/dist/modules/staff/lib/timesheets/timeEntryTotals.js +149 -0
  17. package/dist/modules/staff/lib/timesheets/timeEntryTotals.js.map +7 -0
  18. package/dist/modules/staff/widgets/injection/time-entries-summary-footer/widget.js +24 -0
  19. package/dist/modules/staff/widgets/injection/time-entries-summary-footer/widget.js.map +7 -0
  20. package/dist/modules/staff/widgets/injection-table.js +4 -0
  21. package/dist/modules/staff/widgets/injection-table.js.map +2 -2
  22. package/package.json +7 -7
  23. package/src/modules/catalog/backend/catalog/products/create/page.tsx +14 -1
  24. package/src/modules/catalog/components/products/ProductMediaManager.tsx +5 -1
  25. package/src/modules/entities/api/records.ts +18 -2
  26. package/src/modules/entities/i18n/de.json +1 -0
  27. package/src/modules/entities/i18n/en.json +1 -0
  28. package/src/modules/entities/i18n/es.json +1 -0
  29. package/src/modules/entities/i18n/ko.json +1 -0
  30. package/src/modules/entities/i18n/pl.json +1 -0
  31. package/src/modules/staff/AGENTS.md +1 -0
  32. package/src/modules/staff/api/timesheets/time-entries/route.ts +77 -4
  33. package/src/modules/staff/backend/staff/time-tracking/entries/page.tsx +29 -7
  34. package/src/modules/staff/i18n/de.json +1 -0
  35. package/src/modules/staff/i18n/en.json +1 -0
  36. package/src/modules/staff/i18n/es.json +1 -0
  37. package/src/modules/staff/i18n/ko.json +1 -0
  38. package/src/modules/staff/i18n/pl.json +1 -0
  39. package/src/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.tsx +25 -8
  40. package/src/modules/staff/lib/time-tracking-ui/timeEntryListData.ts +30 -0
  41. package/src/modules/staff/lib/timesheets/timeEntryTotals.ts +271 -0
  42. package/src/modules/staff/widgets/injection/time-entries-summary-footer/widget.tsx +32 -0
  43. package/src/modules/staff/widgets/injection-table.ts +4 -0
@@ -31,7 +31,11 @@ import { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/facto
31
31
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
32
32
  import { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
33
33
  import { createLogger } from '@open-mercato/shared/lib/logger'
34
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
34
35
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
36
+ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
37
+ import { isIdsParamProvided, mergeIdFilter, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'
38
+ import { mergeAdvancedFiltersFromQuery } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
35
39
  import { sanitizeSearchTerm } from '../../helpers'
36
40
  import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
37
41
  import { StaffTimeEntry, StaffTimeEntryTag, StaffTimeProject } from '../../../data/entities'
@@ -42,6 +46,7 @@ import { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'
42
46
  import { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from '../../../lib/time-tracking/access'
43
47
  import { readTimeTrackingSettings } from '../../../lib/time-tracking/settings'
44
48
  import { decorateTimeEntryRows } from '../../../lib/timesheets/timeEntryDecoration'
49
+ import { computeTimeEntryTotals } from '../../../lib/timesheets/timeEntryTotals'
45
50
  import { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'
46
51
 
47
52
  const logger = createLogger('staff').child({ component: 'api/timesheets/time-entries' })
@@ -120,6 +125,11 @@ const listSchema = z
120
125
  tagIds: z.string().optional(),
121
126
  sortField: z.string().optional(),
122
127
  sortDir: z.enum(['asc', 'desc']).optional(),
128
+ /**
129
+ * `true` adds a `totals` object computed over the WHOLE filtered set (not the
130
+ * page). Opt-in so callers that only need rows pay for no aggregate query.
131
+ */
132
+ includeTotals: z.string().optional(),
123
133
  })
124
134
  .passthrough()
125
135
 
@@ -306,7 +316,7 @@ export async function buildScopedTimeEntryListFilters(
306
316
  }
307
317
 
308
318
  const access = await resolveListProjectAccess(ctx)
309
- if (access.canManageAll) return filters
319
+ if (access.canManageAll) return mergeAdvancedFiltersFromQuery(filters, query as Record<string, unknown>)
310
320
 
311
321
  const branches: Record<string, unknown>[] = []
312
322
  if (access.projectIds.length > 0) {
@@ -319,10 +329,10 @@ export async function buildScopedTimeEntryListFilters(
319
329
  // A caller with no membership and no staff record gets no row at all — not a
320
330
  // redacted one — so an entry's note and its project never leave the server.
321
331
  filters[F.id] = { $in: [IMPOSSIBLE_ID] }
322
- return filters
332
+ return mergeAdvancedFiltersFromQuery(filters, query as Record<string, unknown>)
323
333
  }
324
334
  filters.$or = branches
325
- return filters
335
+ return mergeAdvancedFiltersFromQuery(filters, query as Record<string, unknown>)
326
336
  }
327
337
 
328
338
  /**
@@ -398,6 +408,53 @@ export async function decorateTimeEntryList(payload: unknown, ctx: CrudCtx): Pro
398
408
  })
399
409
  }
400
410
 
411
+ function resolveListOrganizationIds(ctx: CrudCtx): string[] {
412
+ if (Array.isArray(ctx.organizationIds)) return Array.from(new Set(ctx.organizationIds))
413
+ const fallback = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
414
+ return fallback ? [fallback] : []
415
+ }
416
+
417
+ /**
418
+ * Adds `totals` for the whole filtered set when the caller asked for them with
419
+ * `?includeTotals=true`. Reuses the list's scoped filters and merges them the
420
+ * way the CRUD factory does (advanced filters, then `?ids=`, then `withDeleted`),
421
+ * so the totals never count a row the list would not return. A failure leaves
422
+ * the list intact and omits `totals`. The aggregate runs on every request that
423
+ * asks for it, list-cache hits included, because totals are not cached.
424
+ */
425
+ export async function attachTimeEntryTotals(
426
+ payload: unknown,
427
+ ctx: CrudCtx & { query: EntryListQuery },
428
+ ): Promise<void> {
429
+ if (parseBooleanToken(ctx.query?.includeTotals ?? null) !== true) return
430
+ if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { items?: unknown }).items)) return
431
+ delete (payload as Record<string, unknown>).totals
432
+ const tenantId = ctx.auth?.tenantId ?? null
433
+ if (!tenantId) return
434
+ try {
435
+ const { organizationId } = resolveCtxScope(ctx)
436
+ const query = ctx.query as Record<string, unknown>
437
+ const scopedFilters = await buildScopedTimeEntryListFilters(ctx.query, ctx)
438
+ const filters = mergeIdFilter(scopedFilters, parseIdsParam(query.ids), {
439
+ idsParamProvided: isIdsParamProvided(query.ids),
440
+ }) as Record<string, unknown>
441
+ const rates = await resolveFeatureAccess(ctx.container, ctx.auth?.sub ?? null, [RATES_FEATURE], {
442
+ tenantId,
443
+ organizationId,
444
+ })
445
+ const em = ctx.container.resolve('em') as EntityManager
446
+ ;(payload as Record<string, unknown>).totals = await computeTimeEntryTotals(em.getKysely(), filters, {
447
+ tenantId,
448
+ organizationIds: resolveListOrganizationIds(ctx),
449
+ canSeeRates: rates.allowed,
450
+ includeDeleted: parseBooleanToken(typeof query.withDeleted === 'string' ? query.withDeleted : null) === true,
451
+ })
452
+ } catch (err) {
453
+ logger.error('staff.timesheets.time-entries totals failed', { err })
454
+ getTelemetryRuntime()?.reportError(err, { module: 'staff', code: 'staff.time_entry_totals_failed' })
455
+ }
456
+ }
457
+
401
458
  const crud = makeCrudRoute({
402
459
  metadata: routeMetadata,
403
460
  orm: {
@@ -423,6 +480,9 @@ const crud = makeCrudRoute({
423
480
  },
424
481
  buildFilters: buildScopedTimeEntryListFilters,
425
482
  },
483
+ hooks: {
484
+ afterList: attachTimeEntryTotals,
485
+ },
426
486
  actions: {
427
487
  create: {
428
488
  commandId: staffTimeEntryCommandIds.create,
@@ -507,11 +567,24 @@ const timeEntryListItemSchema = z.object({
507
567
  currencyCode: z.string().nullable().optional(),
508
568
  })
509
569
 
570
+ const timeEntryTotalsSchema = z.object({
571
+ entryCount: z.number(),
572
+ /** Sum of `duration_minutes` across the whole filtered set. */
573
+ durationMinutes: z.number(),
574
+ /** Sum of `rounded_minutes` across the whole filtered set. */
575
+ roundedMinutes: z.number(),
576
+ /** Present only for a caller holding `staff.timesheets.rates.view`; one entry per currency, never added together. */
577
+ money: z.array(z.object({ currencyCode: z.string().nullable(), amount: z.number() })).optional(),
578
+ })
579
+
510
580
  export const openApi = createStaffCrudOpenApi({
511
581
  resourceName: 'TimeEntry',
512
582
  pluralName: 'TimeEntries',
513
583
  querySchema: listSchema,
514
- listResponseSchema: createPagedListResponseSchema(timeEntryListItemSchema),
584
+ listResponseSchema: createPagedListResponseSchema(timeEntryListItemSchema).extend({
585
+ /** Present only when the request sends `includeTotals=true`; covers every row matching the filters, not just the page. */
586
+ totals: timeEntryTotalsSchema.optional(),
587
+ }),
515
588
  create: {
516
589
  schema: staffTimeEntryCreateSchema,
517
590
  description:
@@ -83,7 +83,8 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
83
83
  import { TimeEntryDialog } from '../../../../lib/time-tracking-ui/TimeEntryDialog'
84
84
  import { REPORTS_PATH, reportDetailHref } from '../../../../lib/time-tracking-ui/reportLinks'
85
85
  import { TimeEntryDurationCell } from '../../../../lib/time-tracking-ui/TimeEntryDurationCell'
86
- import { TimeEntriesSummaryFooter } from '../../../../lib/time-tracking-ui/TimeEntriesSummaryFooter'
86
+ import type { TimeEntriesTableInjectionContext } from '../../../../widgets/injection/time-entries-summary-footer/widget'
87
+ import type { TimeEntryTotals } from '../../../../lib/timesheets/timeEntryTotals'
87
88
  import {
88
89
  collectDirectoryIds,
89
90
  currentWeekRange,
@@ -91,6 +92,7 @@ import {
91
92
  formatEntryDay,
92
93
  isEntryLockedError,
93
94
  readCopyDayTargetConflict,
95
+ readTimeEntryTotals,
94
96
  summarizeTimeEntries,
95
97
  toTimeEntryListRow,
96
98
  type TimeEntryDirectory,
@@ -129,6 +131,7 @@ type EntriesResponse = {
129
131
  total?: number
130
132
  totalIsCapped?: boolean
131
133
  totalPages?: number
134
+ totals?: unknown
132
135
  }
133
136
 
134
137
  type CopyDayResponse = {
@@ -240,6 +243,7 @@ export default function TimeTrackingEntriesPage() {
240
243
  const [rows, setRows] = React.useState<TimeEntryListRow[]>([])
241
244
  const [total, setTotal] = React.useState(0)
242
245
  const [totalIsCapped, setTotalIsCapped] = React.useState(false)
246
+ const [filteredTotals, setFilteredTotals] = React.useState<TimeEntryTotals | null>(null)
243
247
  const [totalPages, setTotalPages] = React.useState(1)
244
248
  const [isLoading, setIsLoading] = React.useState(true)
245
249
  const [isRefreshing, setIsRefreshing] = React.useState(false)
@@ -514,7 +518,11 @@ export default function TimeTrackingEntriesPage() {
514
518
  if (hasLoadedOnceRef.current) setIsRefreshing(true)
515
519
  else setIsLoading(true)
516
520
  try {
517
- const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) })
521
+ const params = new URLSearchParams({
522
+ page: String(page),
523
+ pageSize: String(PAGE_SIZE),
524
+ includeTotals: 'true',
525
+ })
518
526
  const sort = sorting[0]
519
527
  params.set('sortField', sort?.id ?? 'date')
520
528
  params.set('sortDir', sort?.desc === false ? 'asc' : 'desc')
@@ -563,6 +571,7 @@ export default function TimeTrackingEntriesPage() {
563
571
  )
564
572
  setTotal(typeof listPayload.total === 'number' ? listPayload.total : items.length)
565
573
  setTotalIsCapped(listPayload.totalIsCapped === true)
574
+ setFilteredTotals(readTimeEntryTotals(listPayload.totals))
566
575
  setTotalPages(
567
576
  typeof listPayload.totalPages === 'number'
568
577
  ? listPayload.totalPages
@@ -672,13 +681,14 @@ export default function TimeTrackingEntriesPage() {
672
681
  }),
673
682
  'success',
674
683
  )
684
+ handleRefresh()
675
685
  } catch (error) {
676
686
  setRows(previousRows)
677
687
  setTotal((current) => current + 1)
678
688
  reportFailure(error, labels.errors.delete, 'staff.time_tracking.entries delete failed')
679
689
  }
680
690
  },
681
- [labels.errors.delete, labels.noTask, mutationContext, reportFailure, rows, runMutation, t],
691
+ [handleRefresh, labels.errors.delete, labels.noTask, mutationContext, reportFailure, rows, runMutation, t],
682
692
  )
683
693
 
684
694
  const handleDuplicate = React.useCallback(
@@ -1089,12 +1099,22 @@ export default function TimeTrackingEntriesPage() {
1089
1099
  ])
1090
1100
 
1091
1101
  const summary = React.useMemo(() => summarizeTimeEntries(rows), [rows])
1102
+ const tableId = extensionPoints.hosts.timeEntriesTable.tableId
1103
+ const tableInjectionContext = React.useMemo<TimeEntriesTableInjectionContext>(
1104
+ () => ({
1105
+ tableId,
1106
+ title: labels.title,
1107
+ entriesSummary: { summary, totalCount: total, canSeeMoney, totals: filteredTotals },
1108
+ }),
1109
+ [tableId, labels.title, summary, total, canSeeMoney, filteredTotals],
1110
+ )
1092
1111
 
1093
1112
  return (
1094
1113
  <Page>
1095
1114
  <PageBody>
1096
1115
  <DataTable<TimeEntryListRow>
1097
- extensionTableId={extensionPoints.hosts.timeEntriesTable.tableId}
1116
+ extensionTableId={tableId}
1117
+ injectionContext={tableInjectionContext}
1098
1118
  title={labels.title}
1099
1119
  titleHeadingLevel={1}
1100
1120
  data={rows}
@@ -1110,8 +1130,12 @@ export default function TimeTrackingEntriesPage() {
1110
1130
  dropDeepLinkParams(DEEP_LINK_PARAMS.filter((id) => !values[id]))
1111
1131
  }}
1112
1132
  onFiltersClear={clearAllFilters}
1133
+ showActiveFilterChips={false}
1113
1134
  activeFilterChips={
1114
- <div className="flex flex-col gap-2">
1135
+ <div
1136
+ className="flex flex-col gap-2 border-b border-border px-4 py-3"
1137
+ data-staff-entries-filter-chips=""
1138
+ >
1115
1139
  <EntryFilterPresets
1116
1140
  presets={presets}
1117
1141
  activePresetId={activePresetId}
@@ -1210,8 +1234,6 @@ export default function TimeTrackingEntriesPage() {
1210
1234
  }}
1211
1235
  />
1212
1236
 
1213
- <TimeEntriesSummaryFooter summary={summary} totalCount={total} canSeeMoney={canSeeMoney} />
1214
-
1215
1237
  {!canManage ? (
1216
1238
  <p className="mt-2 text-xs text-muted-foreground">
1217
1239
  {t('staff.time_tracking.entries.readOnlyNotice', 'You can view these entries but not change them.')}
@@ -1410,6 +1410,7 @@
1410
1410
  "staff.time_tracking.entries.source.mobile": "Mobil",
1411
1411
  "staff.time_tracking.entries.source.timer": "Timer",
1412
1412
  "staff.time_tracking.entries.summary.count": "Gesamt ({visible} von {total} Einträgen)",
1413
+ "staff.time_tracking.entries.summary.filteredCount": "Gesamt aller {total} passenden Einträge",
1413
1414
  "staff.time_tracking.entries.summary.multiCurrencyHint": "Diese Einträge werden in mehreren Währungen abgerechnet und werden daher getrennt statt summiert angezeigt.",
1414
1415
  "staff.time_tracking.entries.table.durationAria": "Dauer des Eintrags",
1415
1416
  "staff.time_tracking.entries.table.noTask": "Keine Aufgabe",
@@ -1410,6 +1410,7 @@
1410
1410
  "staff.time_tracking.entries.source.mobile": "Mobile",
1411
1411
  "staff.time_tracking.entries.source.timer": "Timer",
1412
1412
  "staff.time_tracking.entries.summary.count": "Total ({visible} of {total} entries)",
1413
+ "staff.time_tracking.entries.summary.filteredCount": "Total of all {total} matching entries",
1413
1414
  "staff.time_tracking.entries.summary.multiCurrencyHint": "These entries are billed in more than one currency, so they are shown separately instead of added up.",
1414
1415
  "staff.time_tracking.entries.table.durationAria": "Entry duration",
1415
1416
  "staff.time_tracking.entries.table.noTask": "No task",
@@ -1410,6 +1410,7 @@
1410
1410
  "staff.time_tracking.entries.source.mobile": "Móvil",
1411
1411
  "staff.time_tracking.entries.source.timer": "Cronómetro",
1412
1412
  "staff.time_tracking.entries.summary.count": "Total ({visible} de {total} registros)",
1413
+ "staff.time_tracking.entries.summary.filteredCount": "Total de los {total} registros coincidentes",
1413
1414
  "staff.time_tracking.entries.summary.multiCurrencyHint": "Estos registros se facturan en más de una moneda, por eso se muestran por separado en lugar de sumarse.",
1414
1415
  "staff.time_tracking.entries.table.durationAria": "Duración del registro",
1415
1416
  "staff.time_tracking.entries.table.noTask": "Sin tarea",
@@ -1410,6 +1410,7 @@
1410
1410
  "staff.time_tracking.entries.source.mobile": "모바일",
1411
1411
  "staff.time_tracking.entries.source.timer": "타이머",
1412
1412
  "staff.time_tracking.entries.summary.count": "합계 (전체 {total}건 중 {visible}건)",
1413
+ "staff.time_tracking.entries.summary.filteredCount": "일치하는 항목 {total}건 전체 합계",
1413
1414
  "staff.time_tracking.entries.summary.multiCurrencyHint": "이 기록들은 두 가지 이상의 통화로 청구되므로 합산하지 않고 따로 표시합니다.",
1414
1415
  "staff.time_tracking.entries.table.durationAria": "기록 소요 시간",
1415
1416
  "staff.time_tracking.entries.table.noTask": "작업 없음",
@@ -1410,6 +1410,7 @@
1410
1410
  "staff.time_tracking.entries.source.mobile": "Mobilnie",
1411
1411
  "staff.time_tracking.entries.source.timer": "Stoper",
1412
1412
  "staff.time_tracking.entries.summary.count": "Razem ({visible} z {total} wpisów)",
1413
+ "staff.time_tracking.entries.summary.filteredCount": "Razem wszystkie pasujące wpisy: {total}",
1413
1414
  "staff.time_tracking.entries.summary.multiCurrencyHint": "Te wpisy są rozliczane w więcej niż jednej walucie, więc pokazujemy je osobno zamiast sumować.",
1414
1415
  "staff.time_tracking.entries.table.durationAria": "Czas trwania wpisu",
1415
1416
  "staff.time_tracking.entries.table.noTask": "Bez zadania",
@@ -10,6 +10,7 @@ import { formatCurrency } from '@open-mercato/ui/utils/format'
10
10
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
11
  import { formatDuration } from '../time-tracking/duration'
12
12
  import type { TimeEntriesSummary } from './timeEntryListData'
13
+ import type { TimeEntryTotals } from '../timesheets/timeEntryTotals'
13
14
 
14
15
  /**
15
16
  * The mockup's table footer: `Razem (5 z 23 wpisów) · 15:15 · 4 315,00 PLN`.
@@ -21,28 +22,43 @@ import type { TimeEntriesSummary } from './timeEntryListData'
21
22
  * financially false, and a footer is exactly where somebody would trust it.
22
23
  */
23
24
  export type TimeEntriesSummaryFooterProps = {
25
+ /** Sums of the rows on the current page. */
24
26
  summary: TimeEntriesSummary
25
27
  totalCount: number
26
28
  canSeeMoney: boolean
29
+ /**
30
+ * Sums of the WHOLE filtered set, from the list API's `includeTotals=true`.
31
+ * When present the footer shows these instead of the page sums; absent or
32
+ * `null` keeps the page-only behaviour.
33
+ */
34
+ totals?: TimeEntryTotals | null
27
35
  }
28
36
 
29
- function DefaultTimeEntriesSummaryFooter({ summary, totalCount, canSeeMoney }: TimeEntriesSummaryFooterProps) {
37
+ function DefaultTimeEntriesSummaryFooter({ summary, totalCount, canSeeMoney, totals }: TimeEntriesSummaryFooterProps) {
30
38
  const t = useT()
31
- const money = canSeeMoney ? summary.money : []
39
+ const wholeSet = totals ?? null
40
+ const money = canSeeMoney ? (wholeSet ? wholeSet.money ?? [] : summary.money) : []
41
+ const minutes = wholeSet ? wholeSet.durationMinutes : summary.totalMinutes
32
42
 
33
43
  return (
34
44
  <div
35
- className="mt-2 flex flex-wrap items-center justify-end gap-x-4 gap-y-1 rounded-md border border-border bg-muted/40 px-4 py-2 text-sm"
45
+ className="flex flex-wrap items-center justify-end gap-x-4 gap-y-1 text-sm"
36
46
  data-testid="entries-summary-footer"
47
+ data-staff-entries-summary-footer=""
48
+ data-summary-scope={wholeSet ? 'filtered' : 'page'}
37
49
  >
38
50
  <span className="text-muted-foreground">
39
- {t('staff.time_tracking.entries.summary.count', 'Total ({visible} of {total} entries)', {
40
- visible: summary.visibleCount,
41
- total: totalCount,
42
- })}
51
+ {wholeSet
52
+ ? t('staff.time_tracking.entries.summary.filteredCount', 'Total of all {total} matching entries', {
53
+ total: wholeSet.entryCount,
54
+ })
55
+ : t('staff.time_tracking.entries.summary.count', 'Total ({visible} of {total} entries)', {
56
+ visible: summary.visibleCount,
57
+ total: totalCount,
58
+ })}
43
59
  </span>
44
60
  <span className="font-mono font-semibold tabular-nums text-foreground" data-testid="entries-summary-duration">
45
- {formatDuration(summary.totalMinutes, 'clock')}
61
+ {formatDuration(minutes, 'clock')}
46
62
  </span>
47
63
  {money.length > 1 ? (
48
64
  <span
@@ -76,6 +92,7 @@ const timeEntriesSummaryFooterPropsSchema: z.ZodType<TimeEntriesSummaryFooterPro
76
92
  summary: opaqueProp<TimeEntriesSummary>(),
77
93
  totalCount: z.number(),
78
94
  canSeeMoney: z.boolean(),
95
+ totals: opaqueProp<TimeEntryTotals>().nullable().optional(),
79
96
  })
80
97
 
81
98
  registerComponent<TimeEntriesSummaryFooterProps>({
@@ -29,6 +29,7 @@ import {
29
29
  readRowString,
30
30
  type ApiRow,
31
31
  } from './timeEntryDialogState'
32
+ import type { TimeEntryTotals } from '../timesheets/timeEntryTotals'
32
33
 
33
34
  /** The shared refusal code every write path answers when an entry is frozen in a report. */
34
35
  export const TIME_ENTRY_LOCKED_CODE = 'time_entry_locked'
@@ -144,6 +145,35 @@ export function summarizeTimeEntries(rows: readonly TimeEntryListRow[]): TimeEnt
144
145
  }
145
146
  }
146
147
 
148
+ function readFiniteNumber(value: unknown): number | null {
149
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
150
+ }
151
+
152
+ /**
153
+ * Reads the list API's optional `totals` (sent for `includeTotals=true`). Anything
154
+ * malformed reads as `null`, which keeps the footer on the page-only sums rather
155
+ * than showing a whole-set figure it cannot trust.
156
+ */
157
+ export function readTimeEntryTotals(value: unknown): TimeEntryTotals | null {
158
+ if (!value || typeof value !== 'object') return null
159
+ const raw = value as Record<string, unknown>
160
+ const entryCount = readFiniteNumber(raw.entryCount)
161
+ const durationMinutes = readFiniteNumber(raw.durationMinutes)
162
+ const roundedMinutes = readFiniteNumber(raw.roundedMinutes)
163
+ if (entryCount === null || durationMinutes === null || roundedMinutes === null) return null
164
+ const totals: TimeEntryTotals = { entryCount, durationMinutes, roundedMinutes }
165
+ if (Array.isArray(raw.money)) {
166
+ totals.money = raw.money.flatMap((item) => {
167
+ if (!item || typeof item !== 'object') return []
168
+ const amount = readFiniteNumber((item as Record<string, unknown>).amount)
169
+ if (amount === null) return []
170
+ const code = (item as Record<string, unknown>).currencyCode
171
+ return [{ currencyCode: typeof code === 'string' && code.length > 0 ? code : null, amount }]
172
+ })
173
+ }
174
+ return totals
175
+ }
176
+
147
177
  type ErrorBag = Record<string, unknown>
148
178
 
149
179
  /**