@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
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Totals for the WHOLE filtered set of time entries, not the page on screen.
3
+ *
4
+ * The list route answers `?includeTotals=true` with one extra aggregate query
5
+ * over the same scoped filters the list itself runs (`buildScopedTimeEntryListFilters`),
6
+ * so the footer can say "Total for these 300 entries" instead of silently summing
7
+ * the 50 rows of the current page.
8
+ *
9
+ * The filters are the column-keyed shape the CRUD query engine consumes. Only the
10
+ * operators that shape actually uses are compiled; anything else throws, and the
11
+ * route then omits `totals` rather than answering a different question than the
12
+ * list did.
13
+ *
14
+ * Money is produced by `entryAmount` — the single place an amount is produced
15
+ * (D-7) — and summed in cents like `sumAmounts`, so the total always equals the
16
+ * sum of the row `cost` values and the report totals. PostgreSQL only groups the
17
+ * billable, priced entries by currency, rounded minutes and both rates, and
18
+ * counts them; pricing each group in SQL would round exact decimals
19
+ * (0.75 h × 0.30 = 0.225 → 0.23) where `entryAmount` rounds a float
20
+ * (0.22499… → 0.22). The currency is the entry's snapshot currency or else the
21
+ * project's; currencies are never added together.
22
+ */
23
+
24
+ import { sql, type Expression, type ExpressionBuilder, type Kysely, type SqlBool } from 'kysely'
25
+ import { entryAmount, round2 } from '../time-tracking/cost'
26
+
27
+ export type TimeEntryMoneyTotal = {
28
+ currencyCode: string | null
29
+ amount: number
30
+ }
31
+
32
+ export type TimeEntryTotals = {
33
+ entryCount: number
34
+ durationMinutes: number
35
+ roundedMinutes: number
36
+ /** Present only for a caller holding `staff.timesheets.rates.view`. */
37
+ money?: TimeEntryMoneyTotal[]
38
+ }
39
+
40
+ type TotalsDb = {
41
+ staff_time_entries: {
42
+ id: string
43
+ tenant_id: string
44
+ organization_id: string
45
+ staff_member_id: string
46
+ date: string
47
+ duration_minutes: number
48
+ rounded_minutes: number | null
49
+ started_at: string | null
50
+ ended_at: string | null
51
+ notes: string | null
52
+ time_project_id: string | null
53
+ task_id: string | null
54
+ customer_id: string | null
55
+ is_billable: boolean
56
+ rate_override_amount: string | null
57
+ rate_currency_code: string | null
58
+ locked_report_id: string | null
59
+ deleted_at: string | null
60
+ }
61
+ staff_time_projects: {
62
+ id: string
63
+ tenant_id: string
64
+ organization_id: string
65
+ hourly_rate: string | null
66
+ currency_code: string | null
67
+ deleted_at: string | null
68
+ }
69
+ }
70
+
71
+ const FILTERABLE_COLUMNS = new Set([
72
+ 'id',
73
+ 'staff_member_id',
74
+ 'date',
75
+ 'duration_minutes',
76
+ 'rounded_minutes',
77
+ 'started_at',
78
+ 'ended_at',
79
+ 'notes',
80
+ 'time_project_id',
81
+ 'task_id',
82
+ 'customer_id',
83
+ 'is_billable',
84
+ 'locked_report_id',
85
+ ])
86
+
87
+ type FilterableColumn = `e.${keyof TotalsDb['staff_time_entries']}`
88
+ type EntryExpressionBuilder = ExpressionBuilder<{ e: TotalsDb['staff_time_entries'] }, 'e'>
89
+
90
+ const COMPARISON_OPERATORS: Record<string, '>=' | '<=' | '>' | '<'> = {
91
+ $gte: '>=',
92
+ $lte: '<=',
93
+ $gt: '>',
94
+ $lt: '<',
95
+ }
96
+
97
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)
99
+ }
100
+
101
+ function compileColumnCondition(
102
+ eb: EntryExpressionBuilder,
103
+ column: string,
104
+ condition: unknown,
105
+ ): Expression<SqlBool> {
106
+ if (!FILTERABLE_COLUMNS.has(column)) {
107
+ throw new Error(`[internal] time entry totals: unsupported filter column "${column}"`)
108
+ }
109
+ const ref = `e.${column}` as FilterableColumn
110
+ if (condition === null) return eb(ref, 'is', null)
111
+ if (!isPlainObject(condition)) return eb(ref, '=', condition as never)
112
+ const parts: Expression<SqlBool>[] = []
113
+ for (const [operator, operand] of Object.entries(condition)) {
114
+ if (operator === '$eq') {
115
+ parts.push(operand === null ? eb(ref, 'is', null) : eb(ref, '=', operand as never))
116
+ } else if (operator === '$ne') {
117
+ parts.push(operand === null ? eb(ref, 'is not', null) : eb(ref, '<>', operand as never))
118
+ } else if (operator === '$in') {
119
+ if (!Array.isArray(operand)) throw new Error('[internal] time entry totals: $in needs an array')
120
+ parts.push(operand.length === 0 ? sql<SqlBool>`false` : eb(ref, 'in', operand as never[]))
121
+ } else if (operator in COMPARISON_OPERATORS) {
122
+ parts.push(eb(ref, COMPARISON_OPERATORS[operator], operand as never))
123
+ } else if (operator === '$ilike') {
124
+ parts.push(eb(ref, 'ilike', operand as never))
125
+ } else {
126
+ throw new Error(`[internal] time entry totals: unsupported filter operator "${operator}"`)
127
+ }
128
+ }
129
+ return parts.length === 1 ? parts[0] : eb.and(parts)
130
+ }
131
+
132
+ export function compileTimeEntryFilters(
133
+ eb: EntryExpressionBuilder,
134
+ filters: Record<string, unknown>,
135
+ ): Expression<SqlBool> {
136
+ const parts: Expression<SqlBool>[] = []
137
+ for (const [key, condition] of Object.entries(filters)) {
138
+ if (condition === undefined) continue
139
+ if (key === '$or' || key === '$and') {
140
+ if (!Array.isArray(condition) || condition.some((branch) => !isPlainObject(branch))) {
141
+ throw new Error(`[internal] time entry totals: ${key} needs an array of filter objects`)
142
+ }
143
+ const branches = condition.map((branch) => compileTimeEntryFilters(eb, branch as Record<string, unknown>))
144
+ if (key === '$or') parts.push(branches.length === 0 ? sql<SqlBool>`false` : eb.or(branches))
145
+ else parts.push(...branches)
146
+ continue
147
+ }
148
+ parts.push(compileColumnCondition(eb, key, condition))
149
+ }
150
+ if (parts.length === 0) return sql<SqlBool>`true`
151
+ return parts.length === 1 ? parts[0] : eb.and(parts)
152
+ }
153
+
154
+ export type TimeEntryTotalsScope = {
155
+ tenantId: string
156
+ /** Organizations the list itself is scoped to; an empty list matches nothing. */
157
+ organizationIds: string[]
158
+ canSeeRates: boolean
159
+ /** Mirrors the list's `withDeleted=true`: soft-deleted entries are counted too. */
160
+ includeDeleted?: boolean
161
+ }
162
+
163
+ function toNumber(value: unknown): number {
164
+ const parsed = typeof value === 'number' ? value : Number(value)
165
+ return Number.isFinite(parsed) ? parsed : 0
166
+ }
167
+
168
+ function toNullableNumber(value: unknown): number | null {
169
+ if (value === null || value === undefined) return null
170
+ const parsed = typeof value === 'number' ? value : Number(value)
171
+ return Number.isFinite(parsed) ? parsed : null
172
+ }
173
+
174
+ function baseQuery(db: Kysely<TotalsDb>, filters: Record<string, unknown>, scope: TimeEntryTotalsScope) {
175
+ const scoped = db
176
+ .selectFrom('staff_time_entries as e')
177
+ .where('e.tenant_id', '=', scope.tenantId)
178
+ .where('e.organization_id', 'in', scope.organizationIds)
179
+ const live = scope.includeDeleted ? scoped : scoped.where('e.deleted_at', 'is', null)
180
+ return live.where((eb) => compileTimeEntryFilters(eb as unknown as EntryExpressionBuilder, filters))
181
+ }
182
+
183
+ export function buildTimeEntryTotalsQuery(
184
+ db: Kysely<TotalsDb>,
185
+ filters: Record<string, unknown>,
186
+ scope: TimeEntryTotalsScope,
187
+ ) {
188
+ return baseQuery(db, filters, scope).select([
189
+ sql<string>`count(*)`.as('entry_count'),
190
+ sql<string>`coalesce(sum(e.duration_minutes), 0)`.as('duration_minutes'),
191
+ sql<string>`coalesce(sum(e.rounded_minutes), 0)`.as('rounded_minutes'),
192
+ ])
193
+ }
194
+
195
+ export function buildTimeEntryMoneyTotalsQuery(
196
+ db: Kysely<TotalsDb>,
197
+ filters: Record<string, unknown>,
198
+ scope: TimeEntryTotalsScope,
199
+ ) {
200
+ return baseQuery(db, filters, scope)
201
+ .leftJoin('staff_time_projects as p', (join) =>
202
+ join
203
+ .onRef('p.id', '=', 'e.time_project_id')
204
+ .onRef('p.tenant_id', '=', 'e.tenant_id')
205
+ .onRef('p.organization_id', '=', 'e.organization_id')
206
+ .on('p.deleted_at', 'is', null),
207
+ )
208
+ .where('e.is_billable', '=', true)
209
+ .where(sql<SqlBool>`coalesce(e.rate_override_amount, p.hourly_rate) is not null`)
210
+ .select([
211
+ sql<string | null>`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`.as('currency_code'),
212
+ sql<string>`coalesce(e.rounded_minutes, 0)`.as('rounded_minutes'),
213
+ sql<string | null>`e.rate_override_amount`.as('rate_override_amount'),
214
+ sql<string | null>`p.hourly_rate`.as('hourly_rate'),
215
+ sql<string>`count(*)`.as('entry_count'),
216
+ ])
217
+ .groupBy([
218
+ sql`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`,
219
+ sql`coalesce(e.rounded_minutes, 0)`,
220
+ sql`e.rate_override_amount`,
221
+ sql`p.hourly_rate`,
222
+ ])
223
+ .orderBy(sql`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`)
224
+ }
225
+
226
+ type MoneyGroupRow = {
227
+ currency_code: string | null
228
+ rounded_minutes: unknown
229
+ rate_override_amount: unknown
230
+ hourly_rate: unknown
231
+ entry_count: unknown
232
+ }
233
+
234
+ export function sumTimeEntryMoneyGroups(groups: readonly MoneyGroupRow[]): TimeEntryMoneyTotal[] {
235
+ const centsByCurrency = new Map<string | null, number>()
236
+ for (const group of groups) {
237
+ const amount = entryAmount(
238
+ {
239
+ isBillable: true,
240
+ roundedMinutes: toNumber(group.rounded_minutes),
241
+ rateOverrideAmount: toNullableNumber(group.rate_override_amount),
242
+ },
243
+ { hourlyRate: toNullableNumber(group.hourly_rate) },
244
+ )
245
+ if (amount === null) continue
246
+ const currencyCode = typeof group.currency_code === 'string' && group.currency_code.length > 0 ? group.currency_code : null
247
+ const cents = Math.round(round2(amount) * 100) * toNumber(group.entry_count)
248
+ centsByCurrency.set(currencyCode, (centsByCurrency.get(currencyCode) ?? 0) + cents)
249
+ }
250
+ return Array.from(centsByCurrency, ([currencyCode, cents]) => ({ currencyCode, amount: round2(cents / 100) }))
251
+ }
252
+
253
+ export async function computeTimeEntryTotals(
254
+ db: Kysely<unknown>,
255
+ filters: Record<string, unknown>,
256
+ scope: TimeEntryTotalsScope,
257
+ ): Promise<TimeEntryTotals> {
258
+ const typedDb = db as unknown as Kysely<TotalsDb>
259
+ if (scope.organizationIds.length === 0) {
260
+ return { entryCount: 0, durationMinutes: 0, roundedMinutes: 0, ...(scope.canSeeRates ? { money: [] } : {}) }
261
+ }
262
+ const row = await buildTimeEntryTotalsQuery(typedDb, filters, scope).executeTakeFirst()
263
+ const totals: TimeEntryTotals = {
264
+ entryCount: toNumber(row?.entry_count),
265
+ durationMinutes: toNumber(row?.duration_minutes),
266
+ roundedMinutes: toNumber(row?.rounded_minutes),
267
+ }
268
+ if (!scope.canSeeRates) return totals
269
+ totals.money = sumTimeEntryMoneyGroups(await buildTimeEntryMoneyTotalsQuery(typedDb, filters, scope).execute())
270
+ return totals
271
+ }
@@ -0,0 +1,32 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
5
+ import {
6
+ TimeEntriesSummaryFooter,
7
+ type TimeEntriesSummaryFooterProps,
8
+ } from '../../../lib/time-tracking-ui/TimeEntriesSummaryFooter'
9
+
10
+ export type TimeEntriesTableInjectionContext = {
11
+ tableId?: string | null
12
+ title?: string
13
+ entriesSummary?: TimeEntriesSummaryFooterProps
14
+ }
15
+
16
+ function TimeEntriesSummaryFooterWidget({ context }: { context: TimeEntriesTableInjectionContext }) {
17
+ const props = context?.entriesSummary
18
+ if (!props) return null
19
+ return <TimeEntriesSummaryFooter {...props} />
20
+ }
21
+
22
+ const widget: InjectionWidgetModule<TimeEntriesTableInjectionContext> = {
23
+ metadata: {
24
+ id: 'staff.injection.time-entries-summary-footer',
25
+ title: 'Time entries totals',
26
+ description: 'Totals for the filtered time entries, rendered inside the entries table footer.',
27
+ features: ['staff.timesheets.view'],
28
+ },
29
+ Widget: TimeEntriesSummaryFooterWidget,
30
+ }
31
+
32
+ export default widget
@@ -5,6 +5,10 @@ export const injectionTable: ModuleInjectionTable = {
5
5
  widgetId: 'staff.injection.timer-sidebar-indicator',
6
6
  priority: 90,
7
7
  },
8
+ 'data-table:staff.time_entries.list:footer': {
9
+ widgetId: 'staff.injection.time-entries-summary-footer',
10
+ priority: 100,
11
+ },
8
12
  'staff.time_tracking.settings:tabs': [
9
13
  {
10
14
  widgetId: 'staff.injection.time-tracking-settings-support',