@open-mercato/core 0.10.1-develop.7923.1.35033d071b → 0.10.1-develop.7926.1.673c22ad09

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/staff/api/timesheets/time-entries/route.js +65 -5
  3. package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
  4. package/dist/modules/staff/backend/staff/time-tracking/entries/page.js +49 -25
  5. package/dist/modules/staff/backend/staff/time-tracking/entries/page.js.map +2 -2
  6. package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js +13 -6
  7. package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js.map +2 -2
  8. package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js +23 -0
  9. package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js.map +2 -2
  10. package/dist/modules/staff/lib/timesheets/timeEntryTotals.js +149 -0
  11. package/dist/modules/staff/lib/timesheets/timeEntryTotals.js.map +7 -0
  12. package/dist/modules/staff/widgets/injection/time-entries-summary-footer/widget.js +24 -0
  13. package/dist/modules/staff/widgets/injection/time-entries-summary-footer/widget.js.map +7 -0
  14. package/dist/modules/staff/widgets/injection-table.js +4 -0
  15. package/dist/modules/staff/widgets/injection-table.js.map +2 -2
  16. package/package.json +7 -7
  17. package/src/modules/staff/AGENTS.md +1 -0
  18. package/src/modules/staff/api/timesheets/time-entries/route.ts +77 -4
  19. package/src/modules/staff/backend/staff/time-tracking/entries/page.tsx +29 -7
  20. package/src/modules/staff/i18n/de.json +1 -0
  21. package/src/modules/staff/i18n/en.json +1 -0
  22. package/src/modules/staff/i18n/es.json +1 -0
  23. package/src/modules/staff/i18n/ko.json +1 -0
  24. package/src/modules/staff/i18n/pl.json +1 -0
  25. package/src/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.tsx +25 -8
  26. package/src/modules/staff/lib/time-tracking-ui/timeEntryListData.ts +30 -0
  27. package/src/modules/staff/lib/timesheets/timeEntryTotals.ts +271 -0
  28. package/src/modules/staff/widgets/injection/time-entries-summary-footer/widget.tsx +32 -0
  29. package/src/modules/staff/widgets/injection-table.ts +4 -0
@@ -1,4 +1,4 @@
1
- [build:core] found 6266 entry points
1
+ [build:core] found 6270 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 271 entry points
4
4
  [build:core:generated] built successfully
@@ -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": "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",
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({ page: String(page), pageSize: String(PAGE_SIZE) });
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: extensionPoints.hosts.timeEntriesTable.tableId,
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
- activeFilterChips: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", children: [
865
- /* @__PURE__ */ jsx(
866
- EntryFilterPresets,
867
- {
868
- presets,
869
- activePresetId,
870
- onApplyPreset: applyPreset
871
- }
872
- ),
873
- /* @__PURE__ */ jsx(
874
- EntryFilterChips,
875
- {
876
- chips: appliedChips,
877
- onRemove: removeChip,
878
- onClearAll: clearAllFilters,
879
- clearAllLabel: labels.filters.clearAll,
880
- removeLabel: (name) => labels.filters.remove.replace("{name}", name)
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(