feat(reports): Time & Activity CSV export and weekly Spent column
Merged
EV
15d ago+1945+1945+0123456789012345678901234567890123456789-45-45-01234567890123456789
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 hasneutralizeSpreadsheetText(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.tsnow uses the shared encoder. Its output is unchanged: its existing spec passes as is, and it still exportsneutralizeSpreadsheetText.- New
report/export/(report-agnostic):IReportExportTable(columns + rows) andIReportExporter.CsvReportExporter: header record, CRLF, UTF-8 with a BOM.ReportExportService(providedIn: 'root'): encodes a table and downloads it withfile-saver, which is already a dependency. CSV is built in. More formats (PDF, XLSX…) plug in through theREPORT_EXPORTERSmulti-provider, without touching the reports.buildReportExportFileName('time-activity', start, end)givestime-activity_2026-10-01_2026-10-31and replaces characters that are unsafe in a file name.
- New
report/daily-grid/daily-report-export.ts:flattenDailyReportreads all four groupings ofGET /timesheet/time-log/report/daily(date / employee / project / client) and returns one row per time entry, in grid order.buildDailyReportExportTablehas 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 nestsprojectLogs, and the client grouping'slogsis a list). So the shapes are declared locally and the contracts are left as they are.
DailyGridComponentowns the export of what it shows:canExportandexportReport(format = 'csv', reportName = 'time-activity'). Any page hosting the grid gets the export with a button that calls it. A format a plugin registers throughREPORT_EXPORTERSis passed straight through.ngx-gauzy-filtersgains 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 missingREPORT_PAGE.NOTESEnglish 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_CSVkey). It is disabled while the grid loads or has no rows, and it calls the grid'sexportReportthrough@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
spentwith thecurrency+positionpipes, in the employee's rate currency, falling back to the organization currency. It shows the dash whenspentis null.
@gauzy/core (API)
time-log.utils.ts: newcalculateBillableAmount(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 /numericcolumns), and returnsnullwhen the rate is missing, not a number, or not positive.TimeLogService.getWeeklyReport:- The TypeORM select adds
employee.billRateValueandemployee.billRateCurrency. The MikroORM branch already populates the whole employee, rate included. - Each employee entry now carries
spentandcurrency. 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.
- The TypeORM select adds
- GraphQL:
TimeLogWeeklyReportgains nullablespent: Decimalandcurrency: String, and the resolver passes them through. The schema snapshot was regenerated withnx run core:graphql-snapshot; the only diff is these two fields and their descriptions.
@gauzy/contracts
ReportDayDatagains optionalspentandcurrency.
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/currencyare 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 returnsemployee.billRateValueto the same callers. A caller withoutCHANGE_SELECTED_EMPLOYEEis 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 ondevelop.npx tsc -p apps/gauzy/tsconfig.app.json --noEmit: no errors in the touched pages.- GraphQL snapshot:
schema-snapshot.ts --checkpasses.
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:
- 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. - 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).
- 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
.xlsxor.pdfwould need a new dependency, which this change avoids, and it can be added later throughREPORT_EXPORTERSwithout touching the reports. If you want one of those formats before #7527 counts as done, changeCloses #7527below toRefs #7527.
Notes / follow-ups (not in this PR)
- The Amounts Owed report renders
employee.billRateCurrency, but its TypeORM select only picksbillRateValue. 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 ingetOwedAmountReport), 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