@open-mercato/core 0.10.1-develop.7923.1.35033d071b → 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/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/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
package/.turbo/turbo-build.log
CHANGED
|
@@ -3,7 +3,11 @@ import { makeCrudRoute } from "@open-mercato/shared/lib/crud/factory";
|
|
|
3
3
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
4
4
|
import { resolveCrudRecordId, parseScopedCommandInput } from "@open-mercato/shared/lib/api/scoped";
|
|
5
5
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
6
|
+
import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
|
|
6
7
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
8
|
+
import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
|
|
9
|
+
import { isIdsParamProvided, mergeIdFilter, parseIdsParam } from "@open-mercato/shared/lib/crud/ids";
|
|
10
|
+
import { mergeAdvancedFiltersFromQuery } from "@open-mercato/shared/lib/crud/advanced-filter-integration";
|
|
7
11
|
import { sanitizeSearchTerm } from "../../helpers.js";
|
|
8
12
|
import { StaffTimeEntry, StaffTimeEntryTag, StaffTimeProject } from "../../../data/entities.js";
|
|
9
13
|
import { staffTimeEntryCreateSchema, staffTimeEntryUpdateSchema } from "../../../data/validators.js";
|
|
@@ -13,6 +17,7 @@ import { resolveFeatureAccess } from "../../../lib/time-tracking/featureAccess.j
|
|
|
13
17
|
import { MANAGE_PROJECTS_FEATURE, resolveProjectAccess } from "../../../lib/time-tracking/access.js";
|
|
14
18
|
import { readTimeTrackingSettings } from "../../../lib/time-tracking/settings.js";
|
|
15
19
|
import { decorateTimeEntryRows } from "../../../lib/timesheets/timeEntryDecoration.js";
|
|
20
|
+
import { computeTimeEntryTotals } from "../../../lib/timesheets/timeEntryTotals.js";
|
|
16
21
|
import { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from "../../openapi.js";
|
|
17
22
|
const logger = createLogger("staff").child({ component: "api/timesheets/time-entries" });
|
|
18
23
|
const F = {
|
|
@@ -78,7 +83,12 @@ const listSchema = z.object({
|
|
|
78
83
|
/** One tag id, or a comma-separated list; an entry matching any of them. */
|
|
79
84
|
tagIds: z.string().optional(),
|
|
80
85
|
sortField: z.string().optional(),
|
|
81
|
-
sortDir: z.enum(["asc", "desc"]).optional()
|
|
86
|
+
sortDir: z.enum(["asc", "desc"]).optional(),
|
|
87
|
+
/**
|
|
88
|
+
* `true` adds a `totals` object computed over the WHOLE filtered set (not the
|
|
89
|
+
* page). Opt-in so callers that only need rows pay for no aggregate query.
|
|
90
|
+
*/
|
|
91
|
+
includeTotals: z.string().optional()
|
|
82
92
|
}).passthrough();
|
|
83
93
|
function parseBooleanFlagOrNull(value) {
|
|
84
94
|
if (typeof value !== "string") return null;
|
|
@@ -180,7 +190,7 @@ async function buildScopedTimeEntryListFilters(query, ctx) {
|
|
|
180
190
|
filters[F.id] = { $in: entryIds.length > 0 ? entryIds : [IMPOSSIBLE_ID] };
|
|
181
191
|
}
|
|
182
192
|
const access = await resolveListProjectAccess(ctx);
|
|
183
|
-
if (access.canManageAll) return filters;
|
|
193
|
+
if (access.canManageAll) return mergeAdvancedFiltersFromQuery(filters, query);
|
|
184
194
|
const branches = [];
|
|
185
195
|
if (access.projectIds.length > 0) {
|
|
186
196
|
branches.push({ [F.time_project_id]: { $in: access.projectIds } });
|
|
@@ -190,10 +200,10 @@ async function buildScopedTimeEntryListFilters(query, ctx) {
|
|
|
190
200
|
}
|
|
191
201
|
if (branches.length === 0) {
|
|
192
202
|
filters[F.id] = { $in: [IMPOSSIBLE_ID] };
|
|
193
|
-
return filters;
|
|
203
|
+
return mergeAdvancedFiltersFromQuery(filters, query);
|
|
194
204
|
}
|
|
195
205
|
filters.$or = branches;
|
|
196
|
-
return filters;
|
|
206
|
+
return mergeAdvancedFiltersFromQuery(filters, query);
|
|
197
207
|
}
|
|
198
208
|
const timeEntryListFields = [
|
|
199
209
|
F.id,
|
|
@@ -242,6 +252,40 @@ async function decorateTimeEntryList(payload, ctx) {
|
|
|
242
252
|
onError: (err) => logger.error("staff.timesheets.time-entries response decoration failed", { err })
|
|
243
253
|
});
|
|
244
254
|
}
|
|
255
|
+
function resolveListOrganizationIds(ctx) {
|
|
256
|
+
if (Array.isArray(ctx.organizationIds)) return Array.from(new Set(ctx.organizationIds));
|
|
257
|
+
const fallback = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
|
|
258
|
+
return fallback ? [fallback] : [];
|
|
259
|
+
}
|
|
260
|
+
async function attachTimeEntryTotals(payload, ctx) {
|
|
261
|
+
if (parseBooleanToken(ctx.query?.includeTotals ?? null) !== true) return;
|
|
262
|
+
if (!payload || typeof payload !== "object" || !Array.isArray(payload.items)) return;
|
|
263
|
+
delete payload.totals;
|
|
264
|
+
const tenantId = ctx.auth?.tenantId ?? null;
|
|
265
|
+
if (!tenantId) return;
|
|
266
|
+
try {
|
|
267
|
+
const { organizationId } = resolveCtxScope(ctx);
|
|
268
|
+
const query = ctx.query;
|
|
269
|
+
const scopedFilters = await buildScopedTimeEntryListFilters(ctx.query, ctx);
|
|
270
|
+
const filters = mergeIdFilter(scopedFilters, parseIdsParam(query.ids), {
|
|
271
|
+
idsParamProvided: isIdsParamProvided(query.ids)
|
|
272
|
+
});
|
|
273
|
+
const rates = await resolveFeatureAccess(ctx.container, ctx.auth?.sub ?? null, [RATES_FEATURE], {
|
|
274
|
+
tenantId,
|
|
275
|
+
organizationId
|
|
276
|
+
});
|
|
277
|
+
const em = ctx.container.resolve("em");
|
|
278
|
+
payload.totals = await computeTimeEntryTotals(em.getKysely(), filters, {
|
|
279
|
+
tenantId,
|
|
280
|
+
organizationIds: resolveListOrganizationIds(ctx),
|
|
281
|
+
canSeeRates: rates.allowed,
|
|
282
|
+
includeDeleted: parseBooleanToken(typeof query.withDeleted === "string" ? query.withDeleted : null) === true
|
|
283
|
+
});
|
|
284
|
+
} catch (err) {
|
|
285
|
+
logger.error("staff.timesheets.time-entries totals failed", { err });
|
|
286
|
+
getTelemetryRuntime()?.reportError(err, { module: "staff", code: "staff.time_entry_totals_failed" });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
245
289
|
const crud = makeCrudRoute({
|
|
246
290
|
metadata: routeMetadata,
|
|
247
291
|
orm: {
|
|
@@ -267,6 +311,9 @@ const crud = makeCrudRoute({
|
|
|
267
311
|
},
|
|
268
312
|
buildFilters: buildScopedTimeEntryListFilters
|
|
269
313
|
},
|
|
314
|
+
hooks: {
|
|
315
|
+
afterList: attachTimeEntryTotals
|
|
316
|
+
},
|
|
270
317
|
actions: {
|
|
271
318
|
create: {
|
|
272
319
|
commandId: staffTimeEntryCommandIds.create,
|
|
@@ -347,11 +394,23 @@ const timeEntryListItemSchema = z.object({
|
|
|
347
394
|
/** Present only for a caller holding `staff.timesheets.rates.view`. */
|
|
348
395
|
currencyCode: z.string().nullable().optional()
|
|
349
396
|
});
|
|
397
|
+
const timeEntryTotalsSchema = z.object({
|
|
398
|
+
entryCount: z.number(),
|
|
399
|
+
/** Sum of `duration_minutes` across the whole filtered set. */
|
|
400
|
+
durationMinutes: z.number(),
|
|
401
|
+
/** Sum of `rounded_minutes` across the whole filtered set. */
|
|
402
|
+
roundedMinutes: z.number(),
|
|
403
|
+
/** Present only for a caller holding `staff.timesheets.rates.view`; one entry per currency, never added together. */
|
|
404
|
+
money: z.array(z.object({ currencyCode: z.string().nullable(), amount: z.number() })).optional()
|
|
405
|
+
});
|
|
350
406
|
const openApi = createStaffCrudOpenApi({
|
|
351
407
|
resourceName: "TimeEntry",
|
|
352
408
|
pluralName: "TimeEntries",
|
|
353
409
|
querySchema: listSchema,
|
|
354
|
-
listResponseSchema: createPagedListResponseSchema(timeEntryListItemSchema)
|
|
410
|
+
listResponseSchema: createPagedListResponseSchema(timeEntryListItemSchema).extend({
|
|
411
|
+
/** Present only when the request sends `includeTotals=true`; covers every row matching the filters, not just the page. */
|
|
412
|
+
totals: timeEntryTotalsSchema.optional()
|
|
413
|
+
}),
|
|
355
414
|
create: {
|
|
356
415
|
schema: staffTimeEntryCreateSchema,
|
|
357
416
|
description: "Creates a time entry for a staff member. The note is accepted under either `notes` or `description` (`description` wins when both are sent). `roundedMinutes` is derived from the tenant rounding rule at write time and stored; `isBillable` falls back to the project default then the tenant default; `rateCurrencyCode` snapshots the project currency. `tagIds` is applied through the tag assignment command."
|
|
@@ -373,6 +432,7 @@ export {
|
|
|
373
432
|
POST,
|
|
374
433
|
PUT,
|
|
375
434
|
RATES_FEATURE,
|
|
435
|
+
attachTimeEntryTotals,
|
|
376
436
|
buildScopedTimeEntryListFilters,
|
|
377
437
|
decorateTimeEntryList,
|
|
378
438
|
metadata,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/staff/api/timesheets/time-entries/route.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Time entries \u2014 the timesheet grid of screen 10 and the full entry form of\n * screen 8, served from one collection.\n *\n * Three things this route owns beyond plain CRUD:\n *\n * 1. **Project access is intersected into every query.** A caller without\n * `staff.timesheets.projects.manage` sees entries on the projects they are an\n * active member of, plus their own project-less entries \u2014 nobody else's. The\n * resolution is memoised per request and fails closed, mirroring the projects\n * and tasks routes.\n * 2. **`description` is returned beside `notes`.** The column is `notes`; every\n * consulting-suite screen calls it `description`. UPGRADE_NOTES.md promises\n * both keys on read for at least one minor release, so both are emitted here\n * and both are accepted on write (the command folds them, `description` wins).\n * 3. **Money is added, not hidden.** `cost`, `currencyCode` and the stored rate\n * override only exist in the payload for a caller holding\n * `staff.timesheets.rates.view`; everyone else gets a response with no money\n * keys at all, the same shape the project portfolio enricher uses. The base\n * projection selects no money column \u2014 see `timeEntryListFields` for why that\n * is the gate rather than an optimisation.\n *\n * `cost` is computed from `rounded_minutes` \u2014 never from `duration_minutes` \u2014 via\n * `entryAmount`, so a non-billable entry reads `null` rather than `0`: zero is a\n * price, and out-of-scope work does not have one (D-7).\n */\n\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { sanitizeSearchTerm } from '../../helpers'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { StaffTimeEntry, StaffTimeEntryTag, StaffTimeProject } from '../../../data/entities'\nimport { staffTimeEntryCreateSchema, staffTimeEntryUpdateSchema } from '../../../data/validators'\nimport { buildTimeEntryListFilters, isParseableDateFilter } from '../../../lib/timesheets/timeEntryListFilters'\nimport { staffTimeEntryCommandIds, staffTimeEntryCrudEvents } from '../../../lib/crud'\nimport { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'\nimport { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from '../../../lib/time-tracking/access'\nimport { readTimeTrackingSettings } from '../../../lib/time-tracking/settings'\nimport { decorateTimeEntryRows } from '../../../lib/timesheets/timeEntryDecoration'\nimport { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'\n\nconst logger = createLogger('staff').child({ component: 'api/timesheets/time-entries' })\n\nconst F = {\n id: 'id',\n tenant_id: 'tenant_id',\n organization_id: 'organization_id',\n staff_member_id: 'staff_member_id',\n date: 'date',\n duration_minutes: 'duration_minutes',\n rounded_minutes: 'rounded_minutes',\n started_at: 'started_at',\n ended_at: 'ended_at',\n notes: 'notes',\n time_project_id: 'time_project_id',\n task_id: 'task_id',\n customer_id: 'customer_id',\n deal_id: 'deal_id',\n order_id: 'order_id',\n is_billable: 'is_billable',\n rate_override_amount: 'rate_override_amount',\n rate_currency_code: 'rate_currency_code',\n locked_report_id: 'locked_report_id',\n locked_at: 'locked_at',\n source: 'source',\n created_at: 'created_at',\n updated_at: 'updated_at',\n deleted_at: 'deleted_at',\n} as const\n\nexport const RATES_FEATURE = 'staff.timesheets.rates.view'\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['staff.timesheets.view'] },\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n PUT: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n DELETE: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst dateFilterSchema = z\n .string()\n .refine(isParseableDateFilter, { message: 'Invalid date' })\n .optional()\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n staffMemberId: z.string().uuid().optional(),\n from: dateFilterSchema,\n to: dateFilterSchema,\n projectId: z.string().uuid().optional(),\n /**\n * One task id, or a comma-separated list of them. The list form is what the\n * task drawer needs: a parent and its subtasks are one question (\"what has\n * been logged against this task?\"), so answering it must not cost one request\n * per child.\n */\n taskId: z.string().optional(),\n ids: z.string().optional(),\n running: z.string().optional(),\n /** `true` / `false` \u2014 omitted means \"either\". */\n billable: z.string().optional(),\n /** `true` = frozen into a closed report, `false` = still editable. */\n locked: z.string().optional(),\n /** Free text over the entry note. */\n q: z.string().optional(),\n /** Customer id \u2014 narrows to that customer's projects. */\n customerId: z.string().uuid().optional(),\n /** One tag id, or a comma-separated list; an entry matching any of them. */\n tagIds: z.string().optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\ntype EntryListQuery = z.infer<typeof listSchema>\n\n/**\n * Three-state flag: `true`, `false`, or \"the caller did not ask\". A missing\n * filter must widen to everything rather than collapse to `false`, which is what\n * a plain boolean parse would do.\n */\nfunction parseBooleanFlagOrNull(value: unknown): boolean | null {\n if (typeof value !== 'string') return null\n const normalized = value.trim().toLowerCase()\n if (normalized.length === 0) return null\n if (['1', 'true', 'yes', 'on'].includes(normalized)) return true\n if (['0', 'false', 'no', 'off'].includes(normalized)) return false\n return null\n}\n\n/** Non-UUID sentinel used as the \"match nothing\" filter, mirroring the tasks route. */\nconst IMPOSSIBLE_ID = '00000000-0000-0000-0000-000000000000'\n\nconst DENIED_ACCESS: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\nconst UUID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/\n\n/**\n * Splits `?taskId=a,b,c` into ids the database can be asked about.\n *\n * A token that is not a UUID is dropped rather than passed through: the column is\n * `uuid`, so a malformed id would fail the whole query instead of matching\n * nothing. `null` means the caller asked no task question at all; an empty array\n * means they asked one no row can answer.\n */\nfunction splitTaskIdList(value: unknown): string[] | null {\n if (typeof value !== 'string' || value.trim().length === 0) return null\n return value\n .split(',')\n .map((entry) => entry.trim())\n .filter((entry) => UUID_PATTERN.test(entry))\n}\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (\n userId: string,\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<string[]>\n userHasAllFeatures?: (\n userId: string,\n required: string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<boolean>\n}\n\ntype ContainerLike = { resolve: (name: string) => unknown }\n\n/** One access decision per request, shared by every filter that needs it. */\nconst accessByRequest = new WeakMap<Request, Promise<ProjectAccess>>()\n\nasync function resolveAssignmentGraceDays(container: ContainerLike, tenantId: string): Promise<number | null> {\n try {\n const configService = container.resolve('moduleConfigService') as ModuleConfigService\n const settings = await readTimeTrackingSettings(configService, { tenantId })\n return settings.access.assignmentGraceDays\n } catch {\n // Fail safe to the documented default rather than widening the window.\n return null\n }\n}\n\nasync function loadProjectAccess(\n container: ContainerLike,\n userId: string | null,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<ProjectAccess> {\n if (!tenantId || !organizationId) return { ...DENIED_ACCESS }\n const scope = { tenantId, organizationId }\n try {\n // One lookup, one authority, and a failure that says so. The previous\n // `catch \u2192 []` could not tell \"denied\" from \"could not ask\", so an RBAC\n // hiccup silently demoted a manager to their own memberships.\n const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope)\n const em = container.resolve('em') as EntityManager\n return await resolveProjectAccess({\n em: em.fork(),\n userId,\n tenantId,\n organizationId,\n canManageAll: access.allowed,\n userFeatures: access.grantedFeatures,\n assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId),\n })\n } catch (err) {\n logger.error('staff.timesheets.time-entries access resolution failed', { err })\n return { ...DENIED_ACCESS }\n }\n}\n\nfunction resolveCtxScope(ctx: CrudCtx): { tenantId: string | null; organizationId: string | null } {\n return {\n tenantId: ctx.auth?.tenantId ?? null,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n }\n}\n\nexport async function resolveListProjectAccess(ctx: CrudCtx): Promise<ProjectAccess> {\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n const load = () => loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId)\n const request = ctx.request\n if (!request) return load()\n const cached = accessByRequest.get(request)\n if (cached) return cached\n const pending = load()\n accessByRequest.set(request, pending)\n return pending\n}\n\n/**\n * Every entry query narrows to what the caller may actually see: entries on the\n * projects they belong to, plus their OWN entries that name no project. A\n * project-less entry has no membership to check, so the only person it can belong\n * to is the member who logged it.\n *\n * Exported so the intersection is testable without standing up the CRUD factory.\n */\nexport async function buildScopedTimeEntryListFilters(\n query: EntryListQuery,\n ctx: CrudCtx,\n): Promise<Record<string, unknown>> {\n const filters = buildTimeEntryListFilters(query)\n\n // The task narrowing is applied before the access branches and never instead of\n // them: asking for a task the caller may not see must return nothing, not the\n // task's entries.\n const taskIds = splitTaskIdList(query.taskId)\n if (taskIds !== null) {\n filters[F.task_id] = { $in: taskIds.length > 0 ? taskIds : [IMPOSSIBLE_ID] }\n }\n\n // --- Optional narrowing the grid's filter overlay drives ---------------\n\n const billable = parseBooleanFlagOrNull(query.billable)\n if (billable !== null) filters[F.is_billable] = { $eq: billable }\n\n const locked = parseBooleanFlagOrNull(query.locked)\n if (locked !== null) {\n // \"Locked\" is the presence of the report that froze the entry, so it is asked\n // as a nullness question rather than a boolean column that does not exist.\n filters[F.locked_report_id] = locked ? { $ne: null } : { $eq: null }\n }\n\n const term = sanitizeSearchTerm(query.q)\n if (term) filters[F.notes] = { $ilike: `%${escapeLikePattern(term)}%` }\n\n // A customer is a property of the project, not of the entry \u2014 `customer_id` on\n // the entry is a denormalised convenience that older rows may not carry \u2014 so\n // the question is answered by resolving the customer's projects first.\n if (typeof query.customerId === 'string' && query.customerId.trim().length > 0) {\n const em = ctx.container.resolve('em') as EntityManager\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n const projects = await em.fork().find(\n StaffTimeProject,\n { customerId: query.customerId.trim(), tenantId, organizationId, deletedAt: null },\n { fields: ['id'] },\n )\n const ids = projects.map((project) => project.id)\n filters[F.time_project_id] = { $in: ids.length > 0 ? ids : [IMPOSSIBLE_ID] }\n }\n\n const tagIds = splitTaskIdList(query.tagIds)\n if (tagIds !== null) {\n const em = ctx.container.resolve('em') as EntityManager\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n const assignments = tagIds.length === 0\n ? []\n : await em.fork().find(\n StaffTimeEntryTag,\n { tagId: { $in: tagIds }, tenantId, organizationId },\n { fields: ['timeEntryId'] },\n )\n const entryIds = Array.from(new Set(assignments.map((row) => row.timeEntryId)))\n filters[F.id] = { $in: entryIds.length > 0 ? entryIds : [IMPOSSIBLE_ID] }\n }\n\n const access = await resolveListProjectAccess(ctx)\n if (access.canManageAll) return filters\n\n const branches: Record<string, unknown>[] = []\n if (access.projectIds.length > 0) {\n branches.push({ [F.time_project_id]: { $in: access.projectIds } })\n }\n if (access.staffMemberId) {\n branches.push({ [F.time_project_id]: { $eq: null }, [F.staff_member_id]: access.staffMemberId })\n }\n if (branches.length === 0) {\n // A caller with no membership and no staff record gets no row at all \u2014 not a\n // redacted one \u2014 so an entry's note and its project never leave the server.\n filters[F.id] = { $in: [IMPOSSIBLE_ID] }\n return filters\n }\n filters.$or = branches\n return filters\n}\n\n/**\n * The base projection, and the reason `rate_override_amount` / `rate_currency_code`\n * are NOT in it.\n *\n * The money gate is `staff.timesheets.rates.view`, and it is applied by the\n * `staff.timesheets-time-entries` response enricher, which is declared\n * `critical: false`. A non-critical enricher that throws or exceeds its timeout\n * leaves the items exactly as the route produced them (`enricher-runner.ts`), so\n * anything the projection selects survives an enricher failure. Selecting the two\n * money columns here and removing them there would therefore fail OPEN under DB\n * contention. They are read back inside the enricher instead, for entitled callers\n * only \u2014 see `lib/timesheets/timeEntryDecoration.ts`.\n */\nexport const timeEntryListFields = [\n F.id,\n F.organization_id,\n F.tenant_id,\n F.staff_member_id,\n F.date,\n F.duration_minutes,\n F.rounded_minutes,\n F.started_at,\n F.ended_at,\n F.notes,\n F.time_project_id,\n F.task_id,\n F.customer_id,\n F.deal_id,\n F.order_id,\n F.is_billable,\n F.locked_report_id,\n F.locked_at,\n F.source,\n F.created_at,\n F.updated_at,\n] as const\n\nexport type { TimeEntryTagSummary } from '../../../lib/timesheets/timeEntryDecoration'\n\nasync function callerHasRatesView(ctx: CrudCtx): Promise<boolean> {\n const userId = ctx.auth?.sub\n if (!userId) return false\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n try {\n const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.userHasAllFeatures) return false\n return await rbac.userHasAllFeatures(userId, [RATES_FEATURE], { tenantId, organizationId })\n } catch {\n // Fail closed: an unreadable grant set never opens the money fields.\n return false\n }\n}\n\n/**\n * @deprecated The decoration is now the declared `staff.timesheets-time-entries`\n * response enricher (`data/enrichers.ts`), so third-party enrichers for\n * `staff:staff_time_entry` compose with it instead of running blind behind a\n * route-private hook. Kept as a thin, in-place wrapper because it is an exported\n * symbol of this route module; new callers should register an enricher.\n */\nexport async function decorateTimeEntryList(payload: unknown, ctx: CrudCtx): Promise<void> {\n const items = (payload as { items?: unknown })?.items\n if (!Array.isArray(items) || items.length === 0) return\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n await decorateTimeEntryRows(items as Record<string, unknown>[], {\n em: ctx.container.resolve('em') as EntityManager,\n tenantId,\n organizationId,\n canSeeRates: await callerHasRatesView(ctx),\n onError: (err) => logger.error('staff.timesheets.time-entries response decoration failed', { err }),\n })\n}\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: StaffTimeEntry,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n events: staffTimeEntryCrudEvents,\n enrichers: { entityId: 'staff:staff_time_entry' },\n indexer: { entityType: 'staff:staff_time_entry' },\n list: {\n schema: listSchema,\n entityId: 'staff:staff_time_entry',\n fields: [...timeEntryListFields],\n sortFieldMap: {\n date: F.date,\n createdAt: F.created_at,\n updatedAt: F.updated_at,\n durationMinutes: F.duration_minutes,\n roundedMinutes: F.rounded_minutes,\n },\n buildFilters: buildScopedTimeEntryListFilters,\n },\n actions: {\n create: {\n commandId: staffTimeEntryCommandIds.create,\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeEntryCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.timeEntryId ?? null }),\n status: 201,\n },\n update: {\n commandId: staffTimeEntryCommandIds.update,\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeEntryUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: staffTimeEntryCommandIds.delete,\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id = resolveCrudRecordId(parsed, ctx, translate)\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nexport const GET = crud.GET\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nconst timeEntryTagSchema = z.object({\n id: z.string().uuid(),\n slug: z.string(),\n label: z.string(),\n color: z.string().nullable(),\n})\n\nconst timeEntryListItemSchema = z.object({\n id: z.string().uuid().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n staff_member_id: z.string().uuid().nullable().optional(),\n date: z.string().nullable().optional(),\n duration_minutes: z.number().nullable().optional(),\n rounded_minutes: z.number().nullable().optional(),\n started_at: z.string().nullable().optional(),\n ended_at: z.string().nullable().optional(),\n notes: z.string().nullable().optional(),\n time_project_id: z.string().uuid().nullable().optional(),\n task_id: z.string().uuid().nullable().optional(),\n customer_id: z.string().uuid().nullable().optional(),\n deal_id: z.string().uuid().nullable().optional(),\n order_id: z.string().uuid().nullable().optional(),\n is_billable: z.boolean().nullable().optional(),\n /** Present only for a caller holding `staff.timesheets.rates.view`. */\n rate_override_amount: z.union([z.string(), z.number()]).nullable().optional(),\n /** Present only for a caller holding `staff.timesheets.rates.view`. */\n rate_currency_code: z.string().nullable().optional(),\n locked_report_id: z.string().uuid().nullable().optional(),\n locked_at: z.string().nullable().optional(),\n source: z.string().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n /** Alias of `notes`; both keys are returned (see UPGRADE_NOTES.md). */\n description: z.string().nullable().optional(),\n /** Raw minutes rounded by the tenant's rule at write time; the only input to cost (D-7). */\n roundedMinutes: z.number().nullable().optional(),\n isLocked: z.boolean().optional(),\n lockedReportId: z.string().uuid().nullable().optional(),\n tags: z.array(timeEntryTagSchema).optional(),\n /** Present only for a caller holding `staff.timesheets.rates.view`; `null` when non-billable. */\n cost: z.number().nullable().optional(),\n /** Present only for a caller holding `staff.timesheets.rates.view`. */\n currencyCode: z.string().nullable().optional(),\n})\n\nexport const openApi = createStaffCrudOpenApi({\n resourceName: 'TimeEntry',\n pluralName: 'TimeEntries',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(timeEntryListItemSchema),\n create: {\n schema: staffTimeEntryCreateSchema,\n description:\n 'Creates a time entry for a staff member. The note is accepted under either `notes` or `description` (`description` wins when both are sent). `roundedMinutes` is derived from the tenant rounding rule at write time and stored; `isBillable` falls back to the project default then the tenant default; `rateCurrencyCode` snapshots the project currency. `tagIds` is applied through the tag assignment command.',\n },\n update: {\n schema: staffTimeEntryUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description:\n 'Updates a time entry by id. `durationMinutes`, `startedAt` and `endedAt` are reconciled so the stored set never contradicts itself: sending both clocks recomputes the duration from them (a duration sent alongside is advisory), sending one clock with a duration derives the other clock, and sending only a duration shifts `endedAt` while `startedAt` anchors. An `endedAt` earlier than its `startedAt` is stored on the next calendar day (a midnight crossing) and `date` still names the day the work started. Any change to the effective duration recomputes the stored rounded minutes from the tenant rounding rule; changing the project re-snapshots the currency. `tagIds` replaces the entry tag set through the tag assignment commands, which refuse an entry locked in a closed report.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Deletes a time entry by id.',\n },\n})\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["/**\n * Time entries \u2014 the timesheet grid of screen 10 and the full entry form of\n * screen 8, served from one collection.\n *\n * Three things this route owns beyond plain CRUD:\n *\n * 1. **Project access is intersected into every query.** A caller without\n * `staff.timesheets.projects.manage` sees entries on the projects they are an\n * active member of, plus their own project-less entries \u2014 nobody else's. The\n * resolution is memoised per request and fails closed, mirroring the projects\n * and tasks routes.\n * 2. **`description` is returned beside `notes`.** The column is `notes`; every\n * consulting-suite screen calls it `description`. UPGRADE_NOTES.md promises\n * both keys on read for at least one minor release, so both are emitted here\n * and both are accepted on write (the command folds them, `description` wins).\n * 3. **Money is added, not hidden.** `cost`, `currencyCode` and the stored rate\n * override only exist in the payload for a caller holding\n * `staff.timesheets.rates.view`; everyone else gets a response with no money\n * keys at all, the same shape the project portfolio enricher uses. The base\n * projection selects no money column \u2014 see `timeEntryListFields` for why that\n * is the gate rather than an optimisation.\n *\n * `cost` is computed from `rounded_minutes` \u2014 never from `duration_minutes` \u2014 via\n * `entryAmount`, so a non-billable entry reads `null` rather than `0`: zero is a\n * price, and out-of-scope work does not have one (D-7).\n */\n\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { isIdsParamProvided, mergeIdFilter, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'\nimport { mergeAdvancedFiltersFromQuery } from '@open-mercato/shared/lib/crud/advanced-filter-integration'\nimport { sanitizeSearchTerm } from '../../helpers'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { StaffTimeEntry, StaffTimeEntryTag, StaffTimeProject } from '../../../data/entities'\nimport { staffTimeEntryCreateSchema, staffTimeEntryUpdateSchema } from '../../../data/validators'\nimport { buildTimeEntryListFilters, isParseableDateFilter } from '../../../lib/timesheets/timeEntryListFilters'\nimport { staffTimeEntryCommandIds, staffTimeEntryCrudEvents } from '../../../lib/crud'\nimport { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'\nimport { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from '../../../lib/time-tracking/access'\nimport { readTimeTrackingSettings } from '../../../lib/time-tracking/settings'\nimport { decorateTimeEntryRows } from '../../../lib/timesheets/timeEntryDecoration'\nimport { computeTimeEntryTotals } from '../../../lib/timesheets/timeEntryTotals'\nimport { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'\n\nconst logger = createLogger('staff').child({ component: 'api/timesheets/time-entries' })\n\nconst F = {\n id: 'id',\n tenant_id: 'tenant_id',\n organization_id: 'organization_id',\n staff_member_id: 'staff_member_id',\n date: 'date',\n duration_minutes: 'duration_minutes',\n rounded_minutes: 'rounded_minutes',\n started_at: 'started_at',\n ended_at: 'ended_at',\n notes: 'notes',\n time_project_id: 'time_project_id',\n task_id: 'task_id',\n customer_id: 'customer_id',\n deal_id: 'deal_id',\n order_id: 'order_id',\n is_billable: 'is_billable',\n rate_override_amount: 'rate_override_amount',\n rate_currency_code: 'rate_currency_code',\n locked_report_id: 'locked_report_id',\n locked_at: 'locked_at',\n source: 'source',\n created_at: 'created_at',\n updated_at: 'updated_at',\n deleted_at: 'deleted_at',\n} as const\n\nexport const RATES_FEATURE = 'staff.timesheets.rates.view'\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['staff.timesheets.view'] },\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n PUT: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n DELETE: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst dateFilterSchema = z\n .string()\n .refine(isParseableDateFilter, { message: 'Invalid date' })\n .optional()\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n staffMemberId: z.string().uuid().optional(),\n from: dateFilterSchema,\n to: dateFilterSchema,\n projectId: z.string().uuid().optional(),\n /**\n * One task id, or a comma-separated list of them. The list form is what the\n * task drawer needs: a parent and its subtasks are one question (\"what has\n * been logged against this task?\"), so answering it must not cost one request\n * per child.\n */\n taskId: z.string().optional(),\n ids: z.string().optional(),\n running: z.string().optional(),\n /** `true` / `false` \u2014 omitted means \"either\". */\n billable: z.string().optional(),\n /** `true` = frozen into a closed report, `false` = still editable. */\n locked: z.string().optional(),\n /** Free text over the entry note. */\n q: z.string().optional(),\n /** Customer id \u2014 narrows to that customer's projects. */\n customerId: z.string().uuid().optional(),\n /** One tag id, or a comma-separated list; an entry matching any of them. */\n tagIds: z.string().optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n /**\n * `true` adds a `totals` object computed over the WHOLE filtered set (not the\n * page). Opt-in so callers that only need rows pay for no aggregate query.\n */\n includeTotals: z.string().optional(),\n })\n .passthrough()\n\ntype EntryListQuery = z.infer<typeof listSchema>\n\n/**\n * Three-state flag: `true`, `false`, or \"the caller did not ask\". A missing\n * filter must widen to everything rather than collapse to `false`, which is what\n * a plain boolean parse would do.\n */\nfunction parseBooleanFlagOrNull(value: unknown): boolean | null {\n if (typeof value !== 'string') return null\n const normalized = value.trim().toLowerCase()\n if (normalized.length === 0) return null\n if (['1', 'true', 'yes', 'on'].includes(normalized)) return true\n if (['0', 'false', 'no', 'off'].includes(normalized)) return false\n return null\n}\n\n/** Non-UUID sentinel used as the \"match nothing\" filter, mirroring the tasks route. */\nconst IMPOSSIBLE_ID = '00000000-0000-0000-0000-000000000000'\n\nconst DENIED_ACCESS: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\nconst UUID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/\n\n/**\n * Splits `?taskId=a,b,c` into ids the database can be asked about.\n *\n * A token that is not a UUID is dropped rather than passed through: the column is\n * `uuid`, so a malformed id would fail the whole query instead of matching\n * nothing. `null` means the caller asked no task question at all; an empty array\n * means they asked one no row can answer.\n */\nfunction splitTaskIdList(value: unknown): string[] | null {\n if (typeof value !== 'string' || value.trim().length === 0) return null\n return value\n .split(',')\n .map((entry) => entry.trim())\n .filter((entry) => UUID_PATTERN.test(entry))\n}\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (\n userId: string,\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<string[]>\n userHasAllFeatures?: (\n userId: string,\n required: string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<boolean>\n}\n\ntype ContainerLike = { resolve: (name: string) => unknown }\n\n/** One access decision per request, shared by every filter that needs it. */\nconst accessByRequest = new WeakMap<Request, Promise<ProjectAccess>>()\n\nasync function resolveAssignmentGraceDays(container: ContainerLike, tenantId: string): Promise<number | null> {\n try {\n const configService = container.resolve('moduleConfigService') as ModuleConfigService\n const settings = await readTimeTrackingSettings(configService, { tenantId })\n return settings.access.assignmentGraceDays\n } catch {\n // Fail safe to the documented default rather than widening the window.\n return null\n }\n}\n\nasync function loadProjectAccess(\n container: ContainerLike,\n userId: string | null,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<ProjectAccess> {\n if (!tenantId || !organizationId) return { ...DENIED_ACCESS }\n const scope = { tenantId, organizationId }\n try {\n // One lookup, one authority, and a failure that says so. The previous\n // `catch \u2192 []` could not tell \"denied\" from \"could not ask\", so an RBAC\n // hiccup silently demoted a manager to their own memberships.\n const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope)\n const em = container.resolve('em') as EntityManager\n return await resolveProjectAccess({\n em: em.fork(),\n userId,\n tenantId,\n organizationId,\n canManageAll: access.allowed,\n userFeatures: access.grantedFeatures,\n assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId),\n })\n } catch (err) {\n logger.error('staff.timesheets.time-entries access resolution failed', { err })\n return { ...DENIED_ACCESS }\n }\n}\n\nfunction resolveCtxScope(ctx: CrudCtx): { tenantId: string | null; organizationId: string | null } {\n return {\n tenantId: ctx.auth?.tenantId ?? null,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n }\n}\n\nexport async function resolveListProjectAccess(ctx: CrudCtx): Promise<ProjectAccess> {\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n const load = () => loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId)\n const request = ctx.request\n if (!request) return load()\n const cached = accessByRequest.get(request)\n if (cached) return cached\n const pending = load()\n accessByRequest.set(request, pending)\n return pending\n}\n\n/**\n * Every entry query narrows to what the caller may actually see: entries on the\n * projects they belong to, plus their OWN entries that name no project. A\n * project-less entry has no membership to check, so the only person it can belong\n * to is the member who logged it.\n *\n * Exported so the intersection is testable without standing up the CRUD factory.\n */\nexport async function buildScopedTimeEntryListFilters(\n query: EntryListQuery,\n ctx: CrudCtx,\n): Promise<Record<string, unknown>> {\n const filters = buildTimeEntryListFilters(query)\n\n // The task narrowing is applied before the access branches and never instead of\n // them: asking for a task the caller may not see must return nothing, not the\n // task's entries.\n const taskIds = splitTaskIdList(query.taskId)\n if (taskIds !== null) {\n filters[F.task_id] = { $in: taskIds.length > 0 ? taskIds : [IMPOSSIBLE_ID] }\n }\n\n // --- Optional narrowing the grid's filter overlay drives ---------------\n\n const billable = parseBooleanFlagOrNull(query.billable)\n if (billable !== null) filters[F.is_billable] = { $eq: billable }\n\n const locked = parseBooleanFlagOrNull(query.locked)\n if (locked !== null) {\n // \"Locked\" is the presence of the report that froze the entry, so it is asked\n // as a nullness question rather than a boolean column that does not exist.\n filters[F.locked_report_id] = locked ? { $ne: null } : { $eq: null }\n }\n\n const term = sanitizeSearchTerm(query.q)\n if (term) filters[F.notes] = { $ilike: `%${escapeLikePattern(term)}%` }\n\n // A customer is a property of the project, not of the entry \u2014 `customer_id` on\n // the entry is a denormalised convenience that older rows may not carry \u2014 so\n // the question is answered by resolving the customer's projects first.\n if (typeof query.customerId === 'string' && query.customerId.trim().length > 0) {\n const em = ctx.container.resolve('em') as EntityManager\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n const projects = await em.fork().find(\n StaffTimeProject,\n { customerId: query.customerId.trim(), tenantId, organizationId, deletedAt: null },\n { fields: ['id'] },\n )\n const ids = projects.map((project) => project.id)\n filters[F.time_project_id] = { $in: ids.length > 0 ? ids : [IMPOSSIBLE_ID] }\n }\n\n const tagIds = splitTaskIdList(query.tagIds)\n if (tagIds !== null) {\n const em = ctx.container.resolve('em') as EntityManager\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n const assignments = tagIds.length === 0\n ? []\n : await em.fork().find(\n StaffTimeEntryTag,\n { tagId: { $in: tagIds }, tenantId, organizationId },\n { fields: ['timeEntryId'] },\n )\n const entryIds = Array.from(new Set(assignments.map((row) => row.timeEntryId)))\n filters[F.id] = { $in: entryIds.length > 0 ? entryIds : [IMPOSSIBLE_ID] }\n }\n\n const access = await resolveListProjectAccess(ctx)\n if (access.canManageAll) return mergeAdvancedFiltersFromQuery(filters, query as Record<string, unknown>)\n\n const branches: Record<string, unknown>[] = []\n if (access.projectIds.length > 0) {\n branches.push({ [F.time_project_id]: { $in: access.projectIds } })\n }\n if (access.staffMemberId) {\n branches.push({ [F.time_project_id]: { $eq: null }, [F.staff_member_id]: access.staffMemberId })\n }\n if (branches.length === 0) {\n // A caller with no membership and no staff record gets no row at all \u2014 not a\n // redacted one \u2014 so an entry's note and its project never leave the server.\n filters[F.id] = { $in: [IMPOSSIBLE_ID] }\n return mergeAdvancedFiltersFromQuery(filters, query as Record<string, unknown>)\n }\n filters.$or = branches\n return mergeAdvancedFiltersFromQuery(filters, query as Record<string, unknown>)\n}\n\n/**\n * The base projection, and the reason `rate_override_amount` / `rate_currency_code`\n * are NOT in it.\n *\n * The money gate is `staff.timesheets.rates.view`, and it is applied by the\n * `staff.timesheets-time-entries` response enricher, which is declared\n * `critical: false`. A non-critical enricher that throws or exceeds its timeout\n * leaves the items exactly as the route produced them (`enricher-runner.ts`), so\n * anything the projection selects survives an enricher failure. Selecting the two\n * money columns here and removing them there would therefore fail OPEN under DB\n * contention. They are read back inside the enricher instead, for entitled callers\n * only \u2014 see `lib/timesheets/timeEntryDecoration.ts`.\n */\nexport const timeEntryListFields = [\n F.id,\n F.organization_id,\n F.tenant_id,\n F.staff_member_id,\n F.date,\n F.duration_minutes,\n F.rounded_minutes,\n F.started_at,\n F.ended_at,\n F.notes,\n F.time_project_id,\n F.task_id,\n F.customer_id,\n F.deal_id,\n F.order_id,\n F.is_billable,\n F.locked_report_id,\n F.locked_at,\n F.source,\n F.created_at,\n F.updated_at,\n] as const\n\nexport type { TimeEntryTagSummary } from '../../../lib/timesheets/timeEntryDecoration'\n\nasync function callerHasRatesView(ctx: CrudCtx): Promise<boolean> {\n const userId = ctx.auth?.sub\n if (!userId) return false\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n try {\n const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.userHasAllFeatures) return false\n return await rbac.userHasAllFeatures(userId, [RATES_FEATURE], { tenantId, organizationId })\n } catch {\n // Fail closed: an unreadable grant set never opens the money fields.\n return false\n }\n}\n\n/**\n * @deprecated The decoration is now the declared `staff.timesheets-time-entries`\n * response enricher (`data/enrichers.ts`), so third-party enrichers for\n * `staff:staff_time_entry` compose with it instead of running blind behind a\n * route-private hook. Kept as a thin, in-place wrapper because it is an exported\n * symbol of this route module; new callers should register an enricher.\n */\nexport async function decorateTimeEntryList(payload: unknown, ctx: CrudCtx): Promise<void> {\n const items = (payload as { items?: unknown })?.items\n if (!Array.isArray(items) || items.length === 0) return\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n await decorateTimeEntryRows(items as Record<string, unknown>[], {\n em: ctx.container.resolve('em') as EntityManager,\n tenantId,\n organizationId,\n canSeeRates: await callerHasRatesView(ctx),\n onError: (err) => logger.error('staff.timesheets.time-entries response decoration failed', { err }),\n })\n}\n\nfunction resolveListOrganizationIds(ctx: CrudCtx): string[] {\n if (Array.isArray(ctx.organizationIds)) return Array.from(new Set(ctx.organizationIds))\n const fallback = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n return fallback ? [fallback] : []\n}\n\n/**\n * Adds `totals` for the whole filtered set when the caller asked for them with\n * `?includeTotals=true`. Reuses the list's scoped filters and merges them the\n * way the CRUD factory does (advanced filters, then `?ids=`, then `withDeleted`),\n * so the totals never count a row the list would not return. A failure leaves\n * the list intact and omits `totals`. The aggregate runs on every request that\n * asks for it, list-cache hits included, because totals are not cached.\n */\nexport async function attachTimeEntryTotals(\n payload: unknown,\n ctx: CrudCtx & { query: EntryListQuery },\n): Promise<void> {\n if (parseBooleanToken(ctx.query?.includeTotals ?? null) !== true) return\n if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { items?: unknown }).items)) return\n delete (payload as Record<string, unknown>).totals\n const tenantId = ctx.auth?.tenantId ?? null\n if (!tenantId) return\n try {\n const { organizationId } = resolveCtxScope(ctx)\n const query = ctx.query as Record<string, unknown>\n const scopedFilters = await buildScopedTimeEntryListFilters(ctx.query, ctx)\n const filters = mergeIdFilter(scopedFilters, parseIdsParam(query.ids), {\n idsParamProvided: isIdsParamProvided(query.ids),\n }) as Record<string, unknown>\n const rates = await resolveFeatureAccess(ctx.container, ctx.auth?.sub ?? null, [RATES_FEATURE], {\n tenantId,\n organizationId,\n })\n const em = ctx.container.resolve('em') as EntityManager\n ;(payload as Record<string, unknown>).totals = await computeTimeEntryTotals(em.getKysely(), filters, {\n tenantId,\n organizationIds: resolveListOrganizationIds(ctx),\n canSeeRates: rates.allowed,\n includeDeleted: parseBooleanToken(typeof query.withDeleted === 'string' ? query.withDeleted : null) === true,\n })\n } catch (err) {\n logger.error('staff.timesheets.time-entries totals failed', { err })\n getTelemetryRuntime()?.reportError(err, { module: 'staff', code: 'staff.time_entry_totals_failed' })\n }\n}\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: StaffTimeEntry,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n events: staffTimeEntryCrudEvents,\n enrichers: { entityId: 'staff:staff_time_entry' },\n indexer: { entityType: 'staff:staff_time_entry' },\n list: {\n schema: listSchema,\n entityId: 'staff:staff_time_entry',\n fields: [...timeEntryListFields],\n sortFieldMap: {\n date: F.date,\n createdAt: F.created_at,\n updatedAt: F.updated_at,\n durationMinutes: F.duration_minutes,\n roundedMinutes: F.rounded_minutes,\n },\n buildFilters: buildScopedTimeEntryListFilters,\n },\n hooks: {\n afterList: attachTimeEntryTotals,\n },\n actions: {\n create: {\n commandId: staffTimeEntryCommandIds.create,\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeEntryCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.timeEntryId ?? null }),\n status: 201,\n },\n update: {\n commandId: staffTimeEntryCommandIds.update,\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeEntryUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: staffTimeEntryCommandIds.delete,\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id = resolveCrudRecordId(parsed, ctx, translate)\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nexport const GET = crud.GET\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nconst timeEntryTagSchema = z.object({\n id: z.string().uuid(),\n slug: z.string(),\n label: z.string(),\n color: z.string().nullable(),\n})\n\nconst timeEntryListItemSchema = z.object({\n id: z.string().uuid().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n staff_member_id: z.string().uuid().nullable().optional(),\n date: z.string().nullable().optional(),\n duration_minutes: z.number().nullable().optional(),\n rounded_minutes: z.number().nullable().optional(),\n started_at: z.string().nullable().optional(),\n ended_at: z.string().nullable().optional(),\n notes: z.string().nullable().optional(),\n time_project_id: z.string().uuid().nullable().optional(),\n task_id: z.string().uuid().nullable().optional(),\n customer_id: z.string().uuid().nullable().optional(),\n deal_id: z.string().uuid().nullable().optional(),\n order_id: z.string().uuid().nullable().optional(),\n is_billable: z.boolean().nullable().optional(),\n /** Present only for a caller holding `staff.timesheets.rates.view`. */\n rate_override_amount: z.union([z.string(), z.number()]).nullable().optional(),\n /** Present only for a caller holding `staff.timesheets.rates.view`. */\n rate_currency_code: z.string().nullable().optional(),\n locked_report_id: z.string().uuid().nullable().optional(),\n locked_at: z.string().nullable().optional(),\n source: z.string().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n /** Alias of `notes`; both keys are returned (see UPGRADE_NOTES.md). */\n description: z.string().nullable().optional(),\n /** Raw minutes rounded by the tenant's rule at write time; the only input to cost (D-7). */\n roundedMinutes: z.number().nullable().optional(),\n isLocked: z.boolean().optional(),\n lockedReportId: z.string().uuid().nullable().optional(),\n tags: z.array(timeEntryTagSchema).optional(),\n /** Present only for a caller holding `staff.timesheets.rates.view`; `null` when non-billable. */\n cost: z.number().nullable().optional(),\n /** Present only for a caller holding `staff.timesheets.rates.view`. */\n currencyCode: z.string().nullable().optional(),\n})\n\nconst timeEntryTotalsSchema = z.object({\n entryCount: z.number(),\n /** Sum of `duration_minutes` across the whole filtered set. */\n durationMinutes: z.number(),\n /** Sum of `rounded_minutes` across the whole filtered set. */\n roundedMinutes: z.number(),\n /** Present only for a caller holding `staff.timesheets.rates.view`; one entry per currency, never added together. */\n money: z.array(z.object({ currencyCode: z.string().nullable(), amount: z.number() })).optional(),\n})\n\nexport const openApi = createStaffCrudOpenApi({\n resourceName: 'TimeEntry',\n pluralName: 'TimeEntries',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(timeEntryListItemSchema).extend({\n /** Present only when the request sends `includeTotals=true`; covers every row matching the filters, not just the page. */\n totals: timeEntryTotalsSchema.optional(),\n }),\n create: {\n schema: staffTimeEntryCreateSchema,\n description:\n 'Creates a time entry for a staff member. The note is accepted under either `notes` or `description` (`description` wins when both are sent). `roundedMinutes` is derived from the tenant rounding rule at write time and stored; `isBillable` falls back to the project default then the tenant default; `rateCurrencyCode` snapshots the project currency. `tagIds` is applied through the tag assignment command.',\n },\n update: {\n schema: staffTimeEntryUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description:\n 'Updates a time entry by id. `durationMinutes`, `startedAt` and `endedAt` are reconciled so the stored set never contradicts itself: sending both clocks recomputes the duration from them (a duration sent alongside is advisory), sending one clock with a duration derives the other clock, and sending only a duration shifts `endedAt` while `startedAt` anchors. An `endedAt` earlier than its `startedAt` is stored on the next calendar day (a midnight crossing) and `date` still names the day the work started. Any change to the effective duration recomputes the stored rounded minutes from the tenant rounding rule; changing the project re-snapshots the currency. `tagIds` replaces the entry tag set through the tag assignment commands, which refuse an entry locked in a closed report.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Deletes a time entry by id.',\n },\n})\n"],
|
|
5
|
+
"mappings": "AA2BA,SAAS,SAAS;AAElB,SAAS,qBAAmC;AAC5C,SAAS,2BAA2B;AACpC,SAAS,qBAAqB,+BAA+B;AAC7D,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B;AACpC,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAClC,SAAS,oBAAoB,eAAe,qBAAqB;AACjE,SAAS,qCAAqC;AAC9C,SAAS,0BAA0B;AAEnC,SAAS,gBAAgB,mBAAmB,wBAAwB;AACpE,SAAS,4BAA4B,kCAAkC;AACvE,SAAS,2BAA2B,6BAA6B;AACjE,SAAS,0BAA0B,gCAAgC;AACnE,SAAS,4BAA4B;AACrC,SAAS,yBAAyB,4BAAgD;AAClF,SAAS,gCAAgC;AACzC,SAAS,6BAA6B;AACtC,SAAS,8BAA8B;AACvC,SAAS,wBAAwB,+BAA+B,+BAA+B;AAE/F,MAAM,SAAS,aAAa,OAAO,EAAE,MAAM,EAAE,WAAW,8BAA8B,CAAC;AAEvF,MAAM,IAAI;AAAA,EACR,IAAI;AAAA,EACJ,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,iBAAiB;AAAA,EACjB,MAAM;AAAA,EACN,kBAAkB;AAAA,EAClB,iBAAiB;AAAA,EACjB,YAAY;AAAA,EACZ,UAAU;AAAA,EACV,OAAO;AAAA,EACP,iBAAiB;AAAA,EACjB,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,EACT,UAAU;AAAA,EACV,aAAa;AAAA,EACb,sBAAsB;AAAA,EACtB,oBAAoB;AAAA,EACpB,kBAAkB;AAAA,EAClB,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,YAAY;AAAA,EACZ,YAAY;AACd;AAEO,MAAM,gBAAgB;AAE7B,MAAM,gBAAgB;AAAA,EACpB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,uBAAuB,EAAE;AAAA,EACrE,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,6BAA6B,EAAE;AAAA,EAC5E,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,6BAA6B,EAAE;AAAA,EAC3E,QAAQ,EAAE,aAAa,MAAM,iBAAiB,CAAC,6BAA6B,EAAE;AAChF;AAEO,MAAM,WAAW;AAExB,MAAM,gBAAgB,EAAE,OAAO,CAAC,CAAC,EAAE,YAAY;AAE/C,MAAM,mBAAmB,EACtB,OAAO,EACP,OAAO,uBAAuB,EAAE,SAAS,eAAe,CAAC,EACzD,SAAS;AAEZ,MAAM,aAAa,EAChB,OAAO;AAAA,EACN,MAAM,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC;AAAA,EACxC,UAAU,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EACtD,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC1C,MAAM;AAAA,EACN,IAAI;AAAA,EACJ,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOtC,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,KAAK,EAAE,OAAO,EAAE,SAAS;AAAA,EACzB,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA,EAE7B,UAAU,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA,EAE9B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA,EAE5B,GAAG,EAAE,OAAO,EAAE,SAAS;AAAA;AAAA,EAEvB,YAAY,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA;AAAA,EAEvC,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,WAAW,EAAE,OAAO,EAAE,SAAS;AAAA,EAC/B,SAAS,EAAE,KAAK,CAAC,OAAO,MAAM,CAAC,EAAE,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,EAK1C,eAAe,EAAE,OAAO,EAAE,SAAS;AACrC,CAAC,EACA,YAAY;AASf,SAAS,uBAAuB,OAAgC;AAC9D,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,aAAa,MAAM,KAAK,EAAE,YAAY;AAC5C,MAAI,WAAW,WAAW,EAAG,QAAO;AACpC,MAAI,CAAC,KAAK,QAAQ,OAAO,IAAI,EAAE,SAAS,UAAU,EAAG,QAAO;AAC5D,MAAI,CAAC,KAAK,SAAS,MAAM,KAAK,EAAE,SAAS,UAAU,EAAG,QAAO;AAC7D,SAAO;AACT;AAGA,MAAM,gBAAgB;AAEtB,MAAM,gBAA+B,EAAE,cAAc,OAAO,YAAY,CAAC,GAAG,eAAe,KAAK;AAEhG,MAAM,eAAe;AAUrB,SAAS,gBAAgB,OAAiC;AACxD,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,WAAW,EAAG,QAAO;AACnE,SAAO,MACJ,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,MAAM,KAAK,CAAC,EAC3B,OAAO,CAAC,UAAU,aAAa,KAAK,KAAK,CAAC;AAC/C;AAiBA,MAAM,kBAAkB,oBAAI,QAAyC;AAErE,eAAe,2BAA2B,WAA0B,UAA0C;AAC5G,MAAI;AACF,UAAM,gBAAgB,UAAU,QAAQ,qBAAqB;AAC7D,UAAM,WAAW,MAAM,yBAAyB,eAAe,EAAE,SAAS,CAAC;AAC3E,WAAO,SAAS,OAAO;AAAA,EACzB,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,kBACb,WACA,QACA,UACA,gBACwB;AACxB,MAAI,CAAC,YAAY,CAAC,eAAgB,QAAO,EAAE,GAAG,cAAc;AAC5D,QAAM,QAAQ,EAAE,UAAU,eAAe;AACzC,MAAI;AAIF,UAAM,SAAS,MAAM,qBAAqB,WAAW,QAAQ,CAAC,uBAAuB,GAAG,KAAK;AAC7F,UAAM,KAAK,UAAU,QAAQ,IAAI;AACjC,WAAO,MAAM,qBAAqB;AAAA,MAChC,IAAI,GAAG,KAAK;AAAA,MACZ;AAAA,MACA;AAAA,MACA;AAAA,MACA,cAAc,OAAO;AAAA,MACrB,cAAc,OAAO;AAAA,MACrB,qBAAqB,MAAM,2BAA2B,WAAW,QAAQ;AAAA,IAC3E,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,MAAM,0DAA0D,EAAE,IAAI,CAAC;AAC9E,WAAO,EAAE,GAAG,cAAc;AAAA,EAC5B;AACF;AAEA,SAAS,gBAAgB,KAA0E;AACjG,SAAO;AAAA,IACL,UAAU,IAAI,MAAM,YAAY;AAAA,IAChC,gBAAgB,IAAI,0BAA0B,IAAI,MAAM,SAAS;AAAA,EACnE;AACF;AAEA,eAAsB,yBAAyB,KAAsC;AACnF,QAAM,EAAE,UAAU,eAAe,IAAI,gBAAgB,GAAG;AACxD,QAAM,OAAO,MAAM,kBAAkB,IAAI,WAAW,IAAI,MAAM,OAAO,MAAM,UAAU,cAAc;AACnG,QAAM,UAAU,IAAI;AACpB,MAAI,CAAC,QAAS,QAAO,KAAK;AAC1B,QAAM,SAAS,gBAAgB,IAAI,OAAO;AAC1C,MAAI,OAAQ,QAAO;AACnB,QAAM,UAAU,KAAK;AACrB,kBAAgB,IAAI,SAAS,OAAO;AACpC,SAAO;AACT;AAUA,eAAsB,gCACpB,OACA,KACkC;AAClC,QAAM,UAAU,0BAA0B,KAAK;AAK/C,QAAM,UAAU,gBAAgB,MAAM,MAAM;AAC5C,MAAI,YAAY,MAAM;AACpB,YAAQ,EAAE,OAAO,IAAI,EAAE,KAAK,QAAQ,SAAS,IAAI,UAAU,CAAC,aAAa,EAAE;AAAA,EAC7E;AAIA,QAAM,WAAW,uBAAuB,MAAM,QAAQ;AACtD,MAAI,aAAa,KAAM,SAAQ,EAAE,WAAW,IAAI,EAAE,KAAK,SAAS;AAEhE,QAAM,SAAS,uBAAuB,MAAM,MAAM;AAClD,MAAI,WAAW,MAAM;AAGnB,YAAQ,EAAE,gBAAgB,IAAI,SAAS,EAAE,KAAK,KAAK,IAAI,EAAE,KAAK,KAAK;AAAA,EACrE;AAEA,QAAM,OAAO,mBAAmB,MAAM,CAAC;AACvC,MAAI,KAAM,SAAQ,EAAE,KAAK,IAAI,EAAE,QAAQ,IAAI,kBAAkB,IAAI,CAAC,IAAI;AAKtE,MAAI,OAAO,MAAM,eAAe,YAAY,MAAM,WAAW,KAAK,EAAE,SAAS,GAAG;AAC9E,UAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AACrC,UAAM,EAAE,UAAU,eAAe,IAAI,gBAAgB,GAAG;AACxD,UAAM,WAAW,MAAM,GAAG,KAAK,EAAE;AAAA,MAC/B;AAAA,MACA,EAAE,YAAY,MAAM,WAAW,KAAK,GAAG,UAAU,gBAAgB,WAAW,KAAK;AAAA,MACjF,EAAE,QAAQ,CAAC,IAAI,EAAE;AAAA,IACnB;AACA,UAAM,MAAM,SAAS,IAAI,CAAC,YAAY,QAAQ,EAAE;AAChD,YAAQ,EAAE,eAAe,IAAI,EAAE,KAAK,IAAI,SAAS,IAAI,MAAM,CAAC,aAAa,EAAE;AAAA,EAC7E;AAEA,QAAM,SAAS,gBAAgB,MAAM,MAAM;AAC3C,MAAI,WAAW,MAAM;AACnB,UAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AACrC,UAAM,EAAE,UAAU,eAAe,IAAI,gBAAgB,GAAG;AACxD,UAAM,cAAc,OAAO,WAAW,IAClC,CAAC,IACD,MAAM,GAAG,KAAK,EAAE;AAAA,MACd;AAAA,MACA,EAAE,OAAO,EAAE,KAAK,OAAO,GAAG,UAAU,eAAe;AAAA,MACnD,EAAE,QAAQ,CAAC,aAAa,EAAE;AAAA,IAC5B;AACJ,UAAM,WAAW,MAAM,KAAK,IAAI,IAAI,YAAY,IAAI,CAAC,QAAQ,IAAI,WAAW,CAAC,CAAC;AAC9E,YAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,SAAS,SAAS,IAAI,WAAW,CAAC,aAAa,EAAE;AAAA,EAC1E;AAEA,QAAM,SAAS,MAAM,yBAAyB,GAAG;AACjD,MAAI,OAAO,aAAc,QAAO,8BAA8B,SAAS,KAAgC;AAEvG,QAAM,WAAsC,CAAC;AAC7C,MAAI,OAAO,WAAW,SAAS,GAAG;AAChC,aAAS,KAAK,EAAE,CAAC,EAAE,eAAe,GAAG,EAAE,KAAK,OAAO,WAAW,EAAE,CAAC;AAAA,EACnE;AACA,MAAI,OAAO,eAAe;AACxB,aAAS,KAAK,EAAE,CAAC,EAAE,eAAe,GAAG,EAAE,KAAK,KAAK,GAAG,CAAC,EAAE,eAAe,GAAG,OAAO,cAAc,CAAC;AAAA,EACjG;AACA,MAAI,SAAS,WAAW,GAAG;AAGzB,YAAQ,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,aAAa,EAAE;AACvC,WAAO,8BAA8B,SAAS,KAAgC;AAAA,EAChF;AACA,UAAQ,MAAM;AACd,SAAO,8BAA8B,SAAS,KAAgC;AAChF;AAeO,MAAM,sBAAsB;AAAA,EACjC,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AAAA,EACF,EAAE;AACJ;AAIA,eAAe,mBAAmB,KAAgC;AAChE,QAAM,SAAS,IAAI,MAAM;AACzB,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,EAAE,UAAU,eAAe,IAAI,gBAAgB,GAAG;AACxD,MAAI;AACF,UAAM,OAAO,IAAI,UAAU,QAAQ,aAAa;AAChD,QAAI,CAAC,MAAM,mBAAoB,QAAO;AACtC,WAAO,MAAM,KAAK,mBAAmB,QAAQ,CAAC,aAAa,GAAG,EAAE,UAAU,eAAe,CAAC;AAAA,EAC5F,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AASA,eAAsB,sBAAsB,SAAkB,KAA6B;AACzF,QAAM,QAAS,SAAiC;AAChD,MAAI,CAAC,MAAM,QAAQ,KAAK,KAAK,MAAM,WAAW,EAAG;AACjD,QAAM,EAAE,UAAU,eAAe,IAAI,gBAAgB,GAAG;AACxD,QAAM,sBAAsB,OAAoC;AAAA,IAC9D,IAAI,IAAI,UAAU,QAAQ,IAAI;AAAA,IAC9B;AAAA,IACA;AAAA,IACA,aAAa,MAAM,mBAAmB,GAAG;AAAA,IACzC,SAAS,CAAC,QAAQ,OAAO,MAAM,4DAA4D,EAAE,IAAI,CAAC;AAAA,EACpG,CAAC;AACH;AAEA,SAAS,2BAA2B,KAAwB;AAC1D,MAAI,MAAM,QAAQ,IAAI,eAAe,EAAG,QAAO,MAAM,KAAK,IAAI,IAAI,IAAI,eAAe,CAAC;AACtF,QAAM,WAAW,IAAI,0BAA0B,IAAI,MAAM,SAAS;AAClE,SAAO,WAAW,CAAC,QAAQ,IAAI,CAAC;AAClC;AAUA,eAAsB,sBACpB,SACA,KACe;AACf,MAAI,kBAAkB,IAAI,OAAO,iBAAiB,IAAI,MAAM,KAAM;AAClE,MAAI,CAAC,WAAW,OAAO,YAAY,YAAY,CAAC,MAAM,QAAS,QAAgC,KAAK,EAAG;AACvG,SAAQ,QAAoC;AAC5C,QAAM,WAAW,IAAI,MAAM,YAAY;AACvC,MAAI,CAAC,SAAU;AACf,MAAI;AACF,UAAM,EAAE,eAAe,IAAI,gBAAgB,GAAG;AAC9C,UAAM,QAAQ,IAAI;AAClB,UAAM,gBAAgB,MAAM,gCAAgC,IAAI,OAAO,GAAG;AAC1E,UAAM,UAAU,cAAc,eAAe,cAAc,MAAM,GAAG,GAAG;AAAA,MACrE,kBAAkB,mBAAmB,MAAM,GAAG;AAAA,IAChD,CAAC;AACD,UAAM,QAAQ,MAAM,qBAAqB,IAAI,WAAW,IAAI,MAAM,OAAO,MAAM,CAAC,aAAa,GAAG;AAAA,MAC9F;AAAA,MACA;AAAA,IACF,CAAC;AACD,UAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AACpC,IAAC,QAAoC,SAAS,MAAM,uBAAuB,GAAG,UAAU,GAAG,SAAS;AAAA,MACnG;AAAA,MACA,iBAAiB,2BAA2B,GAAG;AAAA,MAC/C,aAAa,MAAM;AAAA,MACnB,gBAAgB,kBAAkB,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,IAAI,MAAM;AAAA,IAC1G,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,MAAM,+CAA+C,EAAE,IAAI,CAAC;AACnE,wBAAoB,GAAG,YAAY,KAAK,EAAE,QAAQ,SAAS,MAAM,iCAAiC,CAAC;AAAA,EACrG;AACF;AAEA,MAAM,OAAO,cAAc;AAAA,EACzB,UAAU;AAAA,EACV,KAAK;AAAA,IACH,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,UAAU;AAAA,IACV,aAAa;AAAA,IACb,iBAAiB;AAAA,EACnB;AAAA,EACA,QAAQ;AAAA,EACR,WAAW,EAAE,UAAU,yBAAyB;AAAA,EAChD,SAAS,EAAE,YAAY,yBAAyB;AAAA,EAChD,MAAM;AAAA,IACJ,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,QAAQ,CAAC,GAAG,mBAAmB;AAAA,IAC/B,cAAc;AAAA,MACZ,MAAM,EAAE;AAAA,MACR,WAAW,EAAE;AAAA,MACb,WAAW,EAAE;AAAA,MACb,iBAAiB,EAAE;AAAA,MACnB,gBAAgB,EAAE;AAAA,IACpB;AAAA,IACA,cAAc;AAAA,EAChB;AAAA,EACA,OAAO;AAAA,IACL,WAAW;AAAA,EACb;AAAA,EACA,SAAS;AAAA,IACP,QAAQ;AAAA,MACN,WAAW,yBAAyB;AAAA,MACpC,QAAQ;AAAA,MACR,UAAU,OAAO,EAAE,KAAK,IAAI,MAAM;AAChC,cAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,eAAO,wBAAwB,4BAA4B,OAAO,CAAC,GAAG,KAAK,SAAS;AAAA,MACtF;AAAA,MACA,UAAU,CAAC,EAAE,OAAO,OAAO,EAAE,IAAI,QAAQ,eAAe,KAAK;AAAA,MAC7D,QAAQ;AAAA,IACV;AAAA,IACA,QAAQ;AAAA,MACN,WAAW,yBAAyB;AAAA,MACpC,QAAQ;AAAA,MACR,UAAU,OAAO,EAAE,KAAK,IAAI,MAAM;AAChC,cAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,eAAO,wBAAwB,4BAA4B,OAAO,CAAC,GAAG,KAAK,SAAS;AAAA,MACtF;AAAA,MACA,UAAU,OAAO,EAAE,IAAI,KAAK;AAAA,IAC9B;AAAA,IACA,QAAQ;AAAA,MACN,WAAW,yBAAyB;AAAA,MACpC,QAAQ;AAAA,MACR,UAAU,OAAO,EAAE,QAAQ,IAAI,MAAM;AACnC,cAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,cAAM,KAAK,oBAAoB,QAAQ,KAAK,SAAS;AACrD,eAAO,EAAE,GAAG;AAAA,MACd;AAAA,MACA,UAAU,OAAO,EAAE,IAAI,KAAK;AAAA,IAC9B;AAAA,EACF;AACF,CAAC;AAEM,MAAM,MAAM,KAAK;AACjB,MAAM,OAAO,KAAK;AAClB,MAAM,MAAM,KAAK;AACjB,MAAM,SAAS,KAAK;AAE3B,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,EACpB,MAAM,EAAE,OAAO;AAAA,EACf,OAAO,EAAE,OAAO;AAAA,EAChB,OAAO,EAAE,OAAO,EAAE,SAAS;AAC7B,CAAC;AAED,MAAM,0BAA0B,EAAE,OAAO;AAAA,EACvC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACvD,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACjD,iBAAiB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACvD,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACrC,kBAAkB,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACjD,iBAAiB,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAChD,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC3C,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACzC,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACtC,iBAAiB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACvD,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EAC/C,aAAa,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACnD,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EAC/C,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EAChD,aAAa,EAAE,QAAQ,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA,EAE7C,sBAAsB,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA,EAE5E,oBAAoB,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACnD,kBAAkB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACxD,WAAW,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC1C,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACvC,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC3C,YAAY,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA,EAE3C,aAAa,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA,EAE5C,gBAAgB,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EAC/C,UAAU,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC/B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS;AAAA,EACtD,MAAM,EAAE,MAAM,kBAAkB,EAAE,SAAS;AAAA;AAAA,EAE3C,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA;AAAA,EAErC,cAAc,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAC/C,CAAC;AAED,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,YAAY,EAAE,OAAO;AAAA;AAAA,EAErB,iBAAiB,EAAE,OAAO;AAAA;AAAA,EAE1B,gBAAgB,EAAE,OAAO;AAAA;AAAA,EAEzB,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,SAAS,GAAG,QAAQ,EAAE,OAAO,EAAE,CAAC,CAAC,EAAE,SAAS;AACjG,CAAC;AAEM,MAAM,UAAU,uBAAuB;AAAA,EAC5C,cAAc;AAAA,EACd,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,oBAAoB,8BAA8B,uBAAuB,EAAE,OAAO;AAAA;AAAA,IAEhF,QAAQ,sBAAsB,SAAS;AAAA,EACzC,CAAC;AAAA,EACD,QAAQ;AAAA,IACN,QAAQ;AAAA,IACR,aACE;AAAA,EACJ;AAAA,EACA,QAAQ;AAAA,IACN,QAAQ;AAAA,IACR,gBAAgB;AAAA,IAChB,aACE;AAAA,EACJ;AAAA,EACA,KAAK;AAAA,IACH,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AAAA,IAC1C,gBAAgB;AAAA,IAChB,aAAa;AAAA,EACf;AACF,CAAC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -38,7 +38,6 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
|
38
38
|
import { TimeEntryDialog } from "../../../../lib/time-tracking-ui/TimeEntryDialog.js";
|
|
39
39
|
import { REPORTS_PATH, reportDetailHref } from "../../../../lib/time-tracking-ui/reportLinks.js";
|
|
40
40
|
import { TimeEntryDurationCell } from "../../../../lib/time-tracking-ui/TimeEntryDurationCell.js";
|
|
41
|
-
import { TimeEntriesSummaryFooter } from "../../../../lib/time-tracking-ui/TimeEntriesSummaryFooter.js";
|
|
42
41
|
import {
|
|
43
42
|
collectDirectoryIds,
|
|
44
43
|
currentWeekRange,
|
|
@@ -46,6 +45,7 @@ import {
|
|
|
46
45
|
formatEntryDay,
|
|
47
46
|
isEntryLockedError,
|
|
48
47
|
readCopyDayTargetConflict,
|
|
48
|
+
readTimeEntryTotals,
|
|
49
49
|
summarizeTimeEntries,
|
|
50
50
|
toTimeEntryListRow
|
|
51
51
|
} from "../../../../lib/time-tracking-ui/timeEntryListData.js";
|
|
@@ -142,6 +142,7 @@ function TimeTrackingEntriesPage() {
|
|
|
142
142
|
const [rows, setRows] = React.useState([]);
|
|
143
143
|
const [total, setTotal] = React.useState(0);
|
|
144
144
|
const [totalIsCapped, setTotalIsCapped] = React.useState(false);
|
|
145
|
+
const [filteredTotals, setFilteredTotals] = React.useState(null);
|
|
145
146
|
const [totalPages, setTotalPages] = React.useState(1);
|
|
146
147
|
const [isLoading, setIsLoading] = React.useState(true);
|
|
147
148
|
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
|
@@ -376,7 +377,11 @@ function TimeTrackingEntriesPage() {
|
|
|
376
377
|
if (hasLoadedOnceRef.current) setIsRefreshing(true);
|
|
377
378
|
else setIsLoading(true);
|
|
378
379
|
try {
|
|
379
|
-
const params = new URLSearchParams({
|
|
380
|
+
const params = new URLSearchParams({
|
|
381
|
+
page: String(page),
|
|
382
|
+
pageSize: String(PAGE_SIZE),
|
|
383
|
+
includeTotals: "true"
|
|
384
|
+
});
|
|
380
385
|
const sort = sorting[0];
|
|
381
386
|
params.set("sortField", sort?.id ?? "date");
|
|
382
387
|
params.set("sortDir", sort?.desc === false ? "asc" : "desc");
|
|
@@ -419,6 +424,7 @@ function TimeTrackingEntriesPage() {
|
|
|
419
424
|
);
|
|
420
425
|
setTotal(typeof listPayload.total === "number" ? listPayload.total : items.length);
|
|
421
426
|
setTotalIsCapped(listPayload.totalIsCapped === true);
|
|
427
|
+
setFilteredTotals(readTimeEntryTotals(listPayload.totals));
|
|
422
428
|
setTotalPages(
|
|
423
429
|
typeof listPayload.totalPages === "number" ? listPayload.totalPages : Math.max(1, Math.ceil(items.length / PAGE_SIZE))
|
|
424
430
|
);
|
|
@@ -513,13 +519,14 @@ function TimeTrackingEntriesPage() {
|
|
|
513
519
|
}),
|
|
514
520
|
"success"
|
|
515
521
|
);
|
|
522
|
+
handleRefresh();
|
|
516
523
|
} catch (error) {
|
|
517
524
|
setRows(previousRows);
|
|
518
525
|
setTotal((current) => current + 1);
|
|
519
526
|
reportFailure(error, labels.errors.delete, "staff.time_tracking.entries delete failed");
|
|
520
527
|
}
|
|
521
528
|
},
|
|
522
|
-
[labels.errors.delete, labels.noTask, mutationContext, reportFailure, rows, runMutation, t]
|
|
529
|
+
[handleRefresh, labels.errors.delete, labels.noTask, mutationContext, reportFailure, rows, runMutation, t]
|
|
523
530
|
);
|
|
524
531
|
const handleDuplicate = React.useCallback(
|
|
525
532
|
async (entry) => {
|
|
@@ -842,12 +849,22 @@ function TimeTrackingEntriesPage() {
|
|
|
842
849
|
toggleRowSelection
|
|
843
850
|
]);
|
|
844
851
|
const summary = React.useMemo(() => summarizeTimeEntries(rows), [rows]);
|
|
852
|
+
const tableId = extensionPoints.hosts.timeEntriesTable.tableId;
|
|
853
|
+
const tableInjectionContext = React.useMemo(
|
|
854
|
+
() => ({
|
|
855
|
+
tableId,
|
|
856
|
+
title: labels.title,
|
|
857
|
+
entriesSummary: { summary, totalCount: total, canSeeMoney, totals: filteredTotals }
|
|
858
|
+
}),
|
|
859
|
+
[tableId, labels.title, summary, total, canSeeMoney, filteredTotals]
|
|
860
|
+
);
|
|
845
861
|
return /* @__PURE__ */ jsxs(Page, { children: [
|
|
846
862
|
/* @__PURE__ */ jsxs(PageBody, { children: [
|
|
847
863
|
/* @__PURE__ */ jsx(
|
|
848
864
|
DataTable,
|
|
849
865
|
{
|
|
850
|
-
extensionTableId:
|
|
866
|
+
extensionTableId: tableId,
|
|
867
|
+
injectionContext: tableInjectionContext,
|
|
851
868
|
title: labels.title,
|
|
852
869
|
titleHeadingLevel: 1,
|
|
853
870
|
data: rows,
|
|
@@ -861,26 +878,34 @@ function TimeTrackingEntriesPage() {
|
|
|
861
878
|
dropDeepLinkParams(DEEP_LINK_PARAMS.filter((id) => !values[id]));
|
|
862
879
|
},
|
|
863
880
|
onFiltersClear: clearAllFilters,
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
881
|
+
showActiveFilterChips: false,
|
|
882
|
+
activeFilterChips: /* @__PURE__ */ jsxs(
|
|
883
|
+
"div",
|
|
884
|
+
{
|
|
885
|
+
className: "flex flex-col gap-2 border-b border-border px-4 py-3",
|
|
886
|
+
"data-staff-entries-filter-chips": "",
|
|
887
|
+
children: [
|
|
888
|
+
/* @__PURE__ */ jsx(
|
|
889
|
+
EntryFilterPresets,
|
|
890
|
+
{
|
|
891
|
+
presets,
|
|
892
|
+
activePresetId,
|
|
893
|
+
onApplyPreset: applyPreset
|
|
894
|
+
}
|
|
895
|
+
),
|
|
896
|
+
/* @__PURE__ */ jsx(
|
|
897
|
+
EntryFilterChips,
|
|
898
|
+
{
|
|
899
|
+
chips: appliedChips,
|
|
900
|
+
onRemove: removeChip,
|
|
901
|
+
onClearAll: clearAllFilters,
|
|
902
|
+
clearAllLabel: labels.filters.clearAll,
|
|
903
|
+
removeLabel: (name) => labels.filters.remove.replace("{name}", name)
|
|
904
|
+
}
|
|
905
|
+
)
|
|
906
|
+
]
|
|
907
|
+
}
|
|
908
|
+
),
|
|
884
909
|
filterAwareEmptyState: {
|
|
885
910
|
active: appliedChips.length > 0,
|
|
886
911
|
entityNamePlural: labels.title,
|
|
@@ -959,7 +984,6 @@ function TimeTrackingEntriesPage() {
|
|
|
959
984
|
}
|
|
960
985
|
}
|
|
961
986
|
),
|
|
962
|
-
/* @__PURE__ */ jsx(TimeEntriesSummaryFooter, { summary, totalCount: total, canSeeMoney }),
|
|
963
987
|
!canManage ? /* @__PURE__ */ jsx("p", { className: "mt-2 text-xs text-muted-foreground", children: t("staff.time_tracking.entries.readOnlyNotice", "You can view these entries but not change them.") }) : null
|
|
964
988
|
] }),
|
|
965
989
|
/* @__PURE__ */ jsx(
|