@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
|
@@ -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',
|