feat(reports): Time & Activity CSV export and weekly Spent column

Merged
EV
15d ago
+1945-45

Summary

Two report improvements:

  • Time & Activity: Export to CSV (#7527). An Export to CSV button on Reports → Time & Activity and on the employee Activity → Time & Activity tab downloads the time entries the grid shows, for the current filters and in its current grouping, as time-activity_<start>_<end>.csv. The file opens directly in Excel / LibreOffice. The exporter behind it works for any report, so the other reports can use it next (#7201).
  • Weekly report: the Spent column is now filled in (#4735). Each employee's week is priced at their bill rate, exact to the currency's minor unit. Employees without a rate still show the dash, as they do today.

Why

  • #7527: the Time & Activity report had no export at all: there was no CSV, Blob or download code in the report components.
  • #4735: the weekly report's Spent column always showed a dash. The API never selected the employee's bill rate, so it had nothing to compute the amount from.

What changed

@gauzy/ui-core/common

  • New utils/csv.ts: RFC 4180 encoding shared by every CSV the UI writes. It has neutralizeSpreadsheetText (the formula-injection guard from GHSA-7xp5-j564-4752, moved here unchanged), quoteCsvText, toCsvField, toCsvRecord, buildCsvText, and the BOM, record-separator and MIME constants. Numbers stay unquoted so a spreadsheet can sum them. Text is always quoted and neutralized.

@gauzy/ui-core/shared

  • invoice/generate-csv.ts now uses the shared encoder. Its output is unchanged: its existing spec passes as is, and it still exports neutralizeSpreadsheetText.
  • New report/export/ (report-agnostic):
    • IReportExportTable (columns + rows) and IReportExporter.
    • CsvReportExporter: header record, CRLF, UTF-8 with a BOM.
    • ReportExportService (providedIn: 'root'): encodes a table and downloads it with file-saver, which is already a dependency. CSV is built in. More formats (PDF, XLSX…) plug in through the REPORT_EXPORTERS multi-provider, without touching the reports.
    • buildReportExportFileName('time-activity', start, end) gives time-activity_2026-10-01_2026-10-31 and replaces characters that are unsafe in a file name.
  • New report/daily-grid/daily-report-export.ts:
    • flattenDailyReport reads all four groupings of GET /timesheet/time-log/report/daily (date / employee / project / client) and returns one row per time entry, in grid order.
    • buildDailyReportExportTable has these columns: date, employee, project, client, task, notes, time (HH:mm:ss, as the grid shows it), hours, and activity %. The grouping's column comes first.
    • Hours are exact decimal hours (duration / 3600, not rounded per row), so a spreadsheet sum of the column equals the report total. Rounding each row to 2 decimals drifted: 60 one-minute entries summed to 1.2 h. Format the column in the spreadsheet to show fewer decimals.
    • The IReportDayGroupBy* contracts do not match the real API answer (the employee grouping nests projectLogs, and the client grouping's logs is a list). So the shapes are declared locally and the contracts are left as they are.
  • DailyGridComponent owns the export of what it shows: canExport and exportReport(format = 'csv', reportName = 'time-activity'). Any page hosting the grid gets the export with a button that calls it. A format a plugin registers through REPORT_EXPORTERS is passed straight through.
  • ngx-gauzy-filters gains an optional trailing content slot, [gaFilterActions], for actions on the filtered results. It renders after the built-in filters. Existing projected filters still go to the default slot, unchanged.
  • ach.json: added the missing REPORT_PAGE.NOTES English fallback. The grid's Notes header, and now the export's, showed the raw key in that catalog.

apps/gauzy

  • Reports → Time & Activity: an Export to CSV button in the card header (reuses the existing BUTTONS.EXPORT_TO_CSV key). It is disabled while the grid loads or has no rows, and it calls the grid's exportReport through @ViewChild(DailyGridComponent).
  • Employee Activity → Time & Activity tab: the same button, at the end of the filter row (through gaFilterActions). It exports in the grouping selected on that tab.
  • Weekly report: the Spent cell renders spent with the currency + position pipes, in the employee's rate currency, falling back to the organization currency. It shows the dash when spent is null.

@gauzy/core (API)

  • time-log.utils.ts: new calculateBillableAmount(seconds, rate, currency?). It uses the platform's exact decimal money code (packages/core/src/lib/money), not floating point: total whole seconds × rate ÷ 3600, carried with guard digits, then rounded once, half up, through the active rounding strategy to the currency's minor unit (0 decimals for JPY, 3 for KWD, 2 when unknown). For example, 1,800 s at 20.15 is exactly 10.075, so it gives 10.08; a float formula gave 10.07. It accepts a rate that arrives as a numeric string (MikroORM / numeric columns), and returns null when the rate is missing, not a number, or not positive.
  • TimeLogService.getWeeklyReport:
    • The TypeORM select adds employee.billRateValue and employee.billRateCurrency. The MikroORM branch already populates the whole employee, rate included.
    • Each employee entry now carries spent and currency. Both ORMs share the same aggregation code, which prices the week's total seconds (not each entry).
    • A rate with no currency (the column is optional) is priced in the organization's currency, the one the page displays it in. The organization's currency is read once, only when some employee in the week needs it, scoped to the caller's tenant, under either ORM.
  • GraphQL: TimeLogWeeklyReport gains nullable spent: Decimal and currency: String, and the resolver passes them through. The schema snapshot was regenerated with nx run core:graphql-snapshot; the only diff is these two fields and their descriptions.

@gauzy/contracts

  • ReportDayData gains optional spent and currency.

Flags / settings and defaults

None. Both changes add to what is already there:

  • The export is a new button that only serialises rows the page already fetched under the caller's permissions. It adds no endpoint and no data access.
  • spent / currency are new response fields. The weekly route and the Amounts Owed route have the same guards (TIME_TRACKER / ALL_ORG_VIEW / ALL_ORG_EDIT + EmployeeTrackedDataGuard), and the Amounts Owed report already returns employee.billRateValue to the same callers. A caller without CHANGE_SELECTED_EMPLOYEE is still limited to their own employee.

Tests

Node 26, from the worktree root:

NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/core/jest.config.ts --rootDir packages/core time-log
  -> Test Suites: 17 passed; Tests: 271 passed
     (new: time-log.utils.spec.ts: exact half-cent ties (1800 s @ 20.15, 900 s @ 8.54 / 33.98 / 40.30),
      a full week at a large rate, JPY / KWD minor units, string rates, no-rate cases;
      weekly spent cases in time-log.service.spec.ts: the TypeORM select, pricing with and without a rate,
      the week's total priced once in the rate's currency, the organization currency fallback (read once,
      tenant-scoped, skipped when not needed) under TypeORM and MikroORM, and the MikroORM string rate;
      the resolver spec checks spent/currency pass-through and `spent: Decimal`)

npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core daily-grid daily-report-export report-export csv generate-csv gauzy-filters
  -> Test Suites: 9 passed; Tests: 86 passed
     (new: csv.spec.ts, csv-report-exporter.spec.ts, report-export.service.spec.ts (incl. a plugin-registered
      format), report-export-file-name.spec.ts, daily-report-export.spec.ts (all four groupings, column order,
      exact hours that sum to the total, formula neutralization), daily-grid.component.spec.ts (canExport,
      exportReport defaults, plugin format pass-through); invoice generate-csv.spec.ts and the gauzy-filters
      specs unchanged and green)

npx jest -c apps/gauzy/jest.config.ts --rootDir apps/gauzy time-reports.component time-activities.component weekly-time-reports
  -> Test Suites: 3 passed; Tests: 16 passed
     (both pages: button disabled without rows / while loading; click exports the loaded rows in the
      grouping with the dated file name; on the Activity tab the button is the last item of the filter row;
      Spent cell shows amount + currency, organization-currency fallback, zero, and the dash)

Type checks:

  • npx tsc -p packages/core/tsconfig.lib.json --noEmit: clean.
  • npx tsc -p packages/ui-core/tsconfig.lib.json --noEmit: no errors outside spec files. The lib tsconfig picks up specs without jest types, which also happens on develop.
  • npx tsc -p apps/gauzy/tsconfig.app.json --noEmit: no errors in the touched pages.
  • GraphQL snapshot: schema-snapshot.ts --check passes.

ESLint on the touched files reports no new findings. The errors it does show are on lines this PR does not change.

Manual verification still needed

I could not run the app here (no seeded database or browser session in this lane). Please check in yarn start:gauzy with demo data:

  1. Reports → Time & Activity: Export to CSV downloads time-activity_<start>_<end>.csv. It should open in Excel and LibreOffice with accented names intact, the hours column should sum to the report total, and it should work in each Group By option.
  2. Employees → (an employee) → Activity → Time & Activity: the same button sits at the end of the filter row, and its file follows the Group by selected there. Please also check that it lines up with the other filter controls (I could not see it rendered).
  3. Reports → Weekly: the Spent column should show each employee's week at their rate, in the rate's currency (or the organization's when the rate has none). Compare it with Reports → Amounts Owed for the same employee and range. Amounts Owed rounds each day to 1 decimal, so allow a cent or so of difference. Employees without a rate should still show the dash.

Risk and rollback

Low. All changes are additive, and the invoice CSV refactor keeps its output byte-for-byte (covered by its existing spec). To roll back, revert the PR. No migration and no settings.

Question for the owner

  • #7527 asks for PDF or Excel. This PR ships CSV, which opens directly in Excel and LibreOffice. A native .xlsx or .pdf would need a new dependency, which this change avoids, and it can be added later through REPORT_EXPORTERS without touching the reports. If you want one of those formats before #7527 counts as done, change Closes #7527 below to Refs #7527.

Notes / follow-ups (not in this PR)

  • The Amounts Owed report renders employee.billRateCurrency, but its TypeORM select only picks billRateValue. Under TypeORM the amount is therefore shown in the default currency (USD) rather than the employee's. This PR's organization-currency fallback does not fix it: the cause is different (a missing select field in getOwedAmountReport), so the fix is in #11585.
  • #7201 (PDF/XLS for all reports, scheduled delivery) can build on ReportExportService + REPORT_EXPORTERS.

Closes #7527
Closes #4735
Refs #7201

🤖 Generated with Claude Code