@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/backend/catalog/products/create/page.js +16 -4
- package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
- package/dist/modules/catalog/components/products/ProductMediaManager.js +5 -2
- package/dist/modules/catalog/components/products/ProductMediaManager.js.map +2 -2
- package/dist/modules/entities/api/records.js +18 -2
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/route.js +65 -5
- package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/entries/page.js +49 -25
- package/dist/modules/staff/backend/staff/time-tracking/entries/page.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js +13 -6
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js +23 -0
- package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js.map +2 -2
- package/dist/modules/staff/lib/timesheets/timeEntryTotals.js +149 -0
- package/dist/modules/staff/lib/timesheets/timeEntryTotals.js.map +7 -0
- package/dist/modules/staff/widgets/injection/time-entries-summary-footer/widget.js +24 -0
- package/dist/modules/staff/widgets/injection/time-entries-summary-footer/widget.js.map +7 -0
- package/dist/modules/staff/widgets/injection-table.js +4 -0
- package/dist/modules/staff/widgets/injection-table.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/backend/catalog/products/create/page.tsx +14 -1
- package/src/modules/catalog/components/products/ProductMediaManager.tsx +5 -1
- package/src/modules/entities/api/records.ts +18 -2
- package/src/modules/entities/i18n/de.json +1 -0
- package/src/modules/entities/i18n/en.json +1 -0
- package/src/modules/entities/i18n/es.json +1 -0
- package/src/modules/entities/i18n/ko.json +1 -0
- package/src/modules/entities/i18n/pl.json +1 -0
- package/src/modules/staff/AGENTS.md +1 -0
- package/src/modules/staff/api/timesheets/time-entries/route.ts +77 -4
- package/src/modules/staff/backend/staff/time-tracking/entries/page.tsx +29 -7
- 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/ko.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.tsx +25 -8
- package/src/modules/staff/lib/time-tracking-ui/timeEntryListData.ts +30 -0
- package/src/modules/staff/lib/timesheets/timeEntryTotals.ts +271 -0
- package/src/modules/staff/widgets/injection/time-entries-summary-footer/widget.tsx +32 -0
- 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 {
|
|
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({
|
|
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={
|
|
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
|
|
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
|
|
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="
|
|
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
|
-
{
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
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
|
/**
|