14750 Commits
Author SHA1 Message Date
Aditya Mitra 6fb5a40886 store page size between page loads for employees (#10319)
* store page size in localstorage

* fix the reviews
2026-09-30 17:57:39 +02:00
joel kalema a73de1d121 Feat/add employee dialog polish (#10336)
* feat(i18n): add strings for the add-employee dialog and form sections

* feat(user-forms): upload the profile photo through the shared avatar uploader

* feat(user-forms): group basic info fields into profile, account and employment sections

* feat(employees): track queued employees and the current entry in the add dialog

* feat(employees): redesign the add-employee dialog with labelled steps, review list and fixed footer

* style(employees): style the add-employee dialog header, steps, list and footer

* feat(tags): draw the picker checkbox and show selected tags as tinted labels

* style(tags): compact rows for the tag picker dropdown panel

* fix(employees): vertically centre the Show Deleted toggle in the header

* fix(employees): give Status and Screen Capture columns room from Name and Email

* fix(employees): address review feedback on the add-employee dialog

- mark template-only members protected
- translate the step bar's aria-label
- clear loading in a finally block so a failed create can be retried
- fit the step bar on narrow dialogs by naming only the current step

* fix(employees): resolve SonarCloud reliability issues in touched files

- mark fire-and-forget promises with void (typescript:S9383)
- drop async from delete(), which awaits nothing (typescript:S7503)

* fix(user-forms): hide SUPER_ADMIN from the role picker once the permission check resolves

excludeRoles() resolves after the role field may already have rendered its
options, and it mutated the excludes array in place, so the field kept
offering SUPER_ADMIN to users without that role.

- basic info form: assign a new excludes array instead of pushing
- role field: cache the fetched roles and re-filter them whenever excludes
  changes, clearing a selection that is no longer allowed

* fix(user-forms): keep a preselected role until the role list has loaded

applyExcludes() also runs from the excludes setter, which can fire before
getAll() returns. With no roles loaded every preselected role looked
disallowed and was cleared. Only check the selection once the roles have
loaded; renderRoles() re-runs the check at that point.
2026-09-29 20:06:44 +02:00
joel kalema 4c0d30c4e1 Feat/task details polish (#10329)
* feat(record-view): add opt-in wide input to the record drawer

* feat(record-view): toggle the wide drawer class from the input

* style(record-view): size and pad the wide drawer for long-form content

* feat(tasks): render task descriptions from GitHub-flavoured markdown and safe HTML

* feat(tasks): add task view component deriving pills, people and description

* feat(tasks): lay out task view like an issue tracker with a collapsible details panel

* style(tasks): style task view details, avatars and markdown content

* feat(tasks): declare the task view component

* feat(tasks): show the task view in the wide drawer

* refactor(tasks): drop the record-view descriptor now served by the task view

* feat(record-view): add markdown and status field types, section variants and row icons

* feat(record-view): render panels, person chips, status and markdown in the shared view

* feat(tasks): describe the task view through the shared record view

* refactor(tasks): remove the separate task view component

* refactor(record-view): split markdown block rendering into per-block renderers

* refactor(record-view): derive avatar hue without bitwise truncation

* fix(record-view): keep fenced code intact when stripping comments and split list parsing

* fix(record-view): guard meta rows by permission and use inject() with class bindings

* fix(tasks): honour done statuses when flagging an overdue task

* fix(record-view): close code fences only on a matching fence and resolve Sonar findings in the markdown renderer

* style(record-view): merge the duplicate wide drawer selector

* fix(record-view): detect tilde fences, keep terminal # in headings and make placeholder restoration terminate

* fix(record-view): strip placeholder marks from reference URLs and restore placeholders in one pass
2026-09-29 13:34:28 +02:00
Ruslan KonviserandClaude Opus 5.5 c12fc11a50 fix(auth): make email verification work end to end and stop register dead-ends (#10332)
* fix(auth): make email verification work end to end and stop register dead-ends

Verification link and notice
- /auth/confirm-email no longer sits behind NoAuthGuard. Registering signs the
  user in, so the emailed link was opened by a signed-in user, redirected to the
  dashboard before the resolver ran, and never verified anything. The token is
  still required and still single use (the API clears it on success).
- A refused or expired link now shows its message instead of an endless spinner,
  and a successful one marks the signed-in user verified in the store.
- New "verify your email" notice with Resend (POST /auth/email/verify/resend-link,
  already throttled 3/min) in the main layout and on tenant onboarding. It shows
  only when GET /auth/email/verify/status (new, same feature flag, so 404 where
  verification is off) confirms the user is unverified.
- Settings > Billing: an unverified admin with no linked subscription is told a
  paid plan connects once the address is verified.

Email sending
- Templates fall back to English when the recipient's locale has none, instead
  of rendering an empty email (verification exists only in en/bg/he/ru).
- Send failures are logged with the provider code, SMTP reply code and command,
  every address masked; verification sends are now recorded in email_sent with
  status SENT/FAILED (subject only, never the link). A transport that fails
  verification throws instead of returning undefined.
- resend-link answers 503 when the provider refused the message, not "OK".
- The verification link encodes the address (plus-addressing survived as a space).
- A caller-supplied appEmailConfirmationUrl is honoured only on an origin this
  deployment serves (CLIENT_BASE_URL, the configured links, EMAIL_LINK_ALLOWED_ORIGINS;
  "*" disables the check).
- Auth emails carry X-PM-TrackLinks: None so Postmark stops storing tokens as clicks.
- {{appLink}} falls back to CLIENT_BASE_URL when APP_LINK is empty: every
  hosted deployment has APP_LINK empty, so welcome emails linked to localhost:4200.

Register
- A refused sign-up shows the API's 4xx message (e.g. "A subscription is
  required...") instead of "Something went wrong", and the 403 checkoutUrl is
  offered as a "Continue to checkout" button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(auth): confirm-email trusts the API, not the link, for who was verified

Review follow-ups on the confirm-email page:
- After a successful confirmation, re-read GET /auth/email/verify/status
  instead of comparing the link's email parameter with the signed-in user.
  The token decides which account was confirmed; the email parameter is not
  bound to it.
- A request that got no HTTP answer (status 0) is shown as a connection
  problem, not as an invalid link.
- ActivatedRoute, Store and AuthService come from inject().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(auth): apply a verification status only to the user it was asked for

If a different user signs in while the status or resend request is in flight,
the answer no longer updates the new user's verification state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui-core): drop a resend answer once another user has signed in

The verification notice now tracks the user it speaks for. A different user
signing in cancels the pending resend and resets its state, and a late answer
for the previous user changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui-core): a dismissed verification notice stays dismissed only for that user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:32:46 +02:00
Ruslan KonviserandClaude Opus 5.5 ba62a02001 fix(billing): scope Stripe linking, paywall and billing pages to this deployment's product (#10331)
Product-scoped (hosted plan only) Stripe webhook linking, signup paywall, lazy tenant link and /billing routes; checkout-session proof at register/onboarding; BILLING_PRODUCT, BILLING_SIGNUP_PAYWALL and BILLING_WEBHOOK_LINKING (default off); 402 payment_method_required on a paid upgrade without a card. See the PR description for details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:34:26 +02:00
Lord VB | Evernix 367a01a66d fix(desktop): use a single ErrorHandler that forwards to Sentry and ErrorHandlerService (#10326)
* fix(desktop): use a single ErrorHandler that forwards to Sentry and ErrorHandlerService

The desktop, agent and desktop-timer apps registered two ErrorHandler
providers. Angular only keeps the last one, so either Sentry never saw
uncaught errors (desktop) or the app toast/logging handler was dead
(agent, desktop-timer).

Replace both with one factory provider that delegates to the Sentry
handler and then to ErrorHandlerService. Sentry console logging is
disabled since ErrorHandlerService already logs the error.

Closes #9106

* refactor(desktop-ui-lib): extract provideGlobalErrorHandler to remove duplicated provider

Move the combined ErrorHandler factory into desktop-ui-lib so the three
desktop apps share one implementation. The reporting handler (Sentry) is
passed in, so desktop-ui-lib does not need a Sentry dependency.

* fix(desktop-ui-lib): always run ErrorHandlerService and guard nullish errors in global handler

Run ErrorHandlerService in a finally block so a throwing reporting handler
cannot skip it, and wrap nullish values in an Error before passing them to
ErrorHandlerService. The value sent to the reporting handler is unchanged.
2026-09-28 19:54:07 +02:00
joel kalema a15f8536cd Feat/accounting dashboard polish (#10327)
* feat(i18n): add English strings for the accounting dashboard view

* feat(dashboard): redesign the accounting dashboard with KPIs, cash-flow chart and sortable breakdown

* fix(dashboard): keep employee chart legend items at the list font size

* feat(i18n): add Acholi strings for the accounting dashboard view

* feat(i18n): add Arabic and Hebrew strings for the accounting dashboard view

* feat(i18n): add Bulgarian strings for the accounting dashboard view

* feat(i18n): add German strings for the accounting dashboard view

* feat(i18n): add Spanish strings for the accounting dashboard view

* feat(i18n): add French strings for the accounting dashboard view

* feat(i18n): add Italian strings for the accounting dashboard view

* feat(i18n): add Dutch strings for the accounting dashboard view

* feat(i18n): add Polish strings for the accounting dashboard view

* feat(i18n): add Portuguese strings for the accounting dashboard view

* feat(i18n): add Russian strings for the accounting dashboard view

* feat(i18n): add Chinese strings for the accounting dashboard view

* fix(dashboard): address accounting dashboard review findings

- keep the signed income share for the label, clamp only the bar
- reject non-positive total income as a percentage denominator
- add a screen-reader table of the chart's daily values
- let long KPI amounts wrap instead of truncating
- format chart dates in the active UI language
- translate the accounting view strings in Hebrew and Acholi

* feat(dashboard): restore the cash-flow chart's day grid, point dots and full axis labels

- draw a vertical grid line per date, in the value grid's colour
- show a dot on every day for each series
- label the y-axis with full figures and the x-axis with full dates

* feat(dashboard): add a sort control to the earnings breakdown

- add a sort-by select and a direction toggle to the breakdown header
- sort by total expenses, highest first, by default
- start-align the money columns and trail their sort arrows
- narrow the employee column so the money columns get more room
- add the sort label and direction strings in every locale

* refactor(dashboard): inject the accounting view's theme service and currency pipe with inject()

* fix(dashboard): address accounting chart and header review findings

- keep a vertical grid line for every date; thin only the colliding labels
- format y-axis figures in the active UI language
- let the breakdown panel header and sort controls wrap on narrow screens

* fix(dashboard): read the x-axis tick font through CartesianScaleOptions
2026-09-28 19:53:11 +02:00
Ruslan KonviserandClaude Opus 5.5 5c8d4e1c8c chore(cspell): declare or reword the words #10324 added
The develop push of #10324 (9a6f8787) failed "Check Spelling and Typos
with cspell" (run 36399734369): 13 issues in 5 files. PRs into develop
run no cspell, so this surfaced only after the merge.

- "Nx's" (4x) and "callables": reworded in the comments.
- "internmap" (a d3 dependency, npm package name): added to .cspell.json.
- "avascript" / "msdt" in the safe-url spec: file-level cspell:ignore,
  as the repo does elsewhere; they are the tail of the entity-encoded
  "javascript:" payloads and the ms-msdt: scheme the tests feed in.

Comment, dictionary and directive changes only; no code or config
behaviour changes. Local cspell 6.31.3 on the five files: 0 issues
(the pre-fix safe-url spec, as a control: 6 issues).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:04:48 +02:00
Ruslan KonviserandClaude Opus 5.5 e39e9323c6 test: finish the Angular stub specs; interop for callable CJS, d3 transforms
Local runs after this commit: ui-core 45/45 suites, ui-auth 4/4, gauzy
29/29, desktop-ui-lib 41/41, gauzy-server 2/2, and every plugin UI project
that was red (integration-ai/github/hubstaff/upwork-ui, job-employee/
matching/proposal/search-ui, jobs-ui) green.

- jest.interop.js now patches every callable CommonJS package the code
  imports both ways (moment, randomcolor) with `default` + `__esModule`, so
  both import styles resolve to the function under either esModuleInterop.
  apps/gauzy's spec tsconfig turns esModuleInterop on (as ui-core, docs-ui
  and videos-ui do): ui-core's line chart default-imports
  chartjs-plugin-annotation and Chart.register received undefined.
- jest.preset.js transforms d3-* / internmap (ESM-only, reached through
  @swimlane/ngx-charts).
- desktop-ui-lib specs: section forms get the FormGroup their parent passes,
  cell renderers their rowData, dialogs their data; Settings/Setup/
  ScreenCapture get the library's GAUZY_ENV and the time tracker the
  AuthStrategy/AuthService the desktop apps bootstrap; the task table a
  signed-in session. Two test-side overrides are documented in place:
  LanguageSelectorComponent binds [(ngModel)] without FormsModule (a dead
  binding in the app too), and the plugin source forms use <nb-hint> as a
  plain styled element (Nebular has no such component).
- apps/gauzy: page specs get the date-picker config their route sets,
  the timesheet layout / job pages their route data, the task dialog a
  selected organization (its unguarded async read crashed the Jest worker)
  and a stand-in for docs-ui's links panel (docs-ui's entry point loads a
  PDF viewer that uses import.meta, which CommonJS Jest cannot parse).
- integration-upwork-ui: two hand-written specs used jasmine spies (jest
  has none) and partial Router/TranslateService mocks the template could
  not render with; they use jest.fn() and the real services now.
- gauzy-server's AppComponent spec was the 2021 CLI scaffold (title
  'desktop-web-ui', a "Welcome" h1) and declared a standalone component; it
  now imports it and asserts what the shell does (router outlet, language
  bridge started on init), with the library entry mocked to its one token.
- jobs-ui: the layout spec has asserted a main landmark since it was
  written; the layout now renders <main role="main"> around its outlet (the
  app shell defines no other main landmark).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:25:57 +02:00
Ruslan KonviserandClaude Opus 5.5 d3d8687ac1 test: app-wide TestBed defaults for the Angular projects; fix their stub specs (WIP)
Once the Angular suites loaded, most failures were CLI-generated
`should create` stubs that had never run, failing on the first injection
(TranslateService, NbToastrService, NbDialogRef, ActivatedRoute, the
translate pipe, the default icon pack).

- jest.angular-defaults.ts (setupFilesAfterEnv, after each project's
  test-setup): a top-level beforeEach adds what the app's root module
  provides - TranslateModule.forRoot, the Nebular forRoot modules,
  NgxPermissionsModule.forRoot, the Tabler/eva icon pack, HttpClient
  testing, router, noop animations, a NbDialogRef stub, GAUZY_ENV - and a
  matchMedia stand-in (jsdom has none). configureTestingModule merges, so a
  spec's own providers still win. It imports no ui-core/core: that would
  cache real modules a spec later jest.mock()s.
- jest.preset.js maps dayjs/esm (imported by ngx-daterangepicker-material's
  .mjs bundle) to dayjs's CommonJS build.
- Specs of non-standalone components import the NgModule that declares
  them, provide what the host feature module provides (EmployeesService,
  AuthService, NbAuthModule.forRoot, PipesModule), and set the inputs /
  route data / date-picker config the app always supplies before render.
- Two stubs imported classes their files do not export
  (GauzyRangePickerComponent, ViewComponent) and declared `undefined`.
- desktop-ui-lib's test-setup installs an inert `window.electronAPI`
  preload bridge of the shape apps/agent exposes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:57:14 +02:00
Ruslan Konviser d0b2a8e080 Merge remote-tracking branch 'origin/develop' into fix/unit-tests-hermetic-harness 2026-09-27 20:53:10 +02:00
Ruslan Konviser 4c4e554e2b Merge remote-tracking branch 'origin/develop' into fix/sentry-quota-flood 2026-09-27 17:48:39 +02:00
Ruslan KonviserandClaude Opus 5.5 91d91ffca3 test: make the unit-test run hermetic and fix the Angular jest harness
The Unit Tests workflow has never been green (24 of 97 projects red at
develop eac7136562, run 36319732347). Causes addressed here:

- Env leak: Nx loads the committed .env.local (DEMO=true,
  WORKER_QUEUE_ENABLED=false, NODE_ENV=development) into every task.
  The test step now sets NX_LOAD_DOT_ENV_FILES=false, and the two specs
  that depend on a mode pin it themselves (role-permission counts pin
  environment.demo=false and gain a demo-mode suite asserting 209 per
  role; the worker spec clears the queue env before the constants load).
  .env.local is unchanged.
- Angular harness: nohoist gives each workspace its own @angular copy,
  so setupZoneTestEnv() initialised a different TestBed from the one the
  spec used. A workspace resolver (jest.resolver.js, wrapping Nx's) makes
  every @angular/* import in a Jest project resolve to one copy. The
  Angular projects now inherit the preset's transformIgnorePatterns,
  which gains the .mjs exception plus @datorama, @ngneat and lodash-es.
- ui-config's environment.ts is generated and gitignored; the workflow
  runs `yarn config:dev` before the tests, as the Playwright workflow does.
- Misconfigured targets: gauzy (jest.config.js -> .ts, Angular transform,
  setupFile moved into the config), integration-sim-ui (.ts -> .cts),
  integration-activepieces (config file added), mcp-auth (ran
  `node build/main.js --test`; now the jest executor like apps/mcp).
- Real failures: the openai "silence" case used a body with no `text`,
  which the shared helper rejects by contract; the toolbar spec read the
  tabIndex property, which is 0 on any button; the docs-ui linearity
  tests asserted wall-clock bounds, now a 4x-input growth ratio.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:39:40 +02:00
Ruslan KonviserandClaude Opus 5.5 fba9302a54 fix(sentry): Render asks for the DSN; desktop apps stop printing it
The Render blueprints declare SENTRY_DSN with sync: false, so Render prompts
the person creating the Blueprint for their own DSN instead of shipping one.
The desktop, desktop-timer, server, server-api and agent apps logged the full
DSN at startup; they now only say that Sentry is enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:33:52 +02:00
Sairaj V 5536f7b711 fix(ui): show invoice View and Payments as active actions
Those buttons used the muted secondary style, so they looked disabled next to Edit and Download. Use the primary action style while they stay clickable.

Fixes #8522
2026-09-27 18:17:09 +05:30
Ruslan Konviser eac7136562 fix unknown employee name in creation toast (#10306) 2026-09-27 09:13:18 +02:00
joel kalema 0fe8348260 Merge pull request #10313 from joel-kalema/fix/screenshots-gallery-viewer-clean
Fix/screenshots gallery viewer clean
2026-09-27 09:09:49 +02:00
joel kalema e9e6bfd9fa Merge pull request #10308 from joel-kalema/feat/contacts-pages-polish
Feat/contacts pages polish
2026-09-26 20:17:45 +02:00
Ruslan KonviserandClaude Opus 5.5 69a293175a fix(sentry): only errors become Sentry events by default; stop logging health probes
The whole ever-co Sentry organisation (5,000 errors a month on the current
plan) has accepted no error after the first hours of each monthly reset:
2026-07-09, 08-09 and 09-09 are the only days with accepted errors in 90 days.
About 97% of what was accepted were info-level log lines, not errors.

- SentryService (the API's Nest logger) turned every log/warn/debug/verbose
  call into its own Sentry event and ignored the `logLevels: ['error']` the API
  passes. It now honours `logLevels`: listed levels become events, the rest
  become breadcrumbs on the next event. An unset or empty list keeps the old
  capture-everything behaviour. The API reads the list from SENTRY_LOG_LEVELS
  (default `error`), so capturing warnings or logs again is a config change.
- RequestContextMiddleware logged the start and end of every request,
  including the Kubernetes readiness probe on /api/health every 10 s on every
  pod: 2 events per probe, ~2,900 events an hour from the four Ever Teams API
  pods alone. /api/health and /api/health/* are no longer logged. This is
  decided by path only, since a client-set User-Agent must not be able to hide
  other requests.
- apps/api/src/sentry.ts printed the DSN at startup and ran the SDK in debug
  mode in production (`environment.production` is false in the published
  image), writing several SDK lines per request. Debug is now opt-in with
  SENTRY_DEBUG=true, and the DSN is no longer printed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:00:44 +02:00
aditya-mitra bc67c035da fix unknown employee name in creation toast 2026-09-25 08:56:57 +05:30
Ruslan Konviser fb2184e41f Merge pull request #10305 from ever-co/fix/desktop-update-followups
fix(desktop): update-feed follow-ups: publish-channel CI guard, updater tag re-resolve, local-update note, stray channel
2026-09-25 00:53:23 +02:00
Aditya Mitra 4a1faad0f2 Merge pull request #10280 from aditya-mitra/confirm-dialog
remove duplicate confirm dialog when deleting proposal template
2026-09-25 00:28:24 +02:00
Ruslan KonviserandClaude Opus 5.5 21abafd1a3 fix(desktop): drop stray committed publish channel from apps/desktop/src/package.json
`"channel": "latest-x64"` is `pack --arch` output that was committed by
accident in 2c28fa70ae. The other 5 desktop apps keep build.publish
channel-less in source and let `pack --arch --platform` stamp
latest-<arch> at build time (win32/linux only). In CI the channel is
overwritten anyway, but a local or manual macOS build of Gauzy Desktop
would publish latest-x64-mac.yml, which the updater never requests on
darwin (it asks for latest-mac.yml).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:09:07 +02:00
joel kalema f0bce080a9 Feat/reports UI polish (#10289)
* feat(ui-core): add a shared stylesheet for the row-table reports

Apps & URLs, the weekly and daily limit reports and the two budget
reports are all built from one skeleton, but each page restyled that
skeleton on its own and the five had drifted apart: different row
heights, different type sizes, different numbers of nested panels.

report-table.scss holds what they share - the card, the hairlines, the
row metrics, the 12px type and the progress bar - behind a report-table()
mixin, plus a report-column() mixin for sizing a column from the row
rather than from whatever happens to be in it.

* refactor(ui-core): rebuild the apps & urls grid on the shared report table

Three levels of wrapper per row each painted their own gauzy-card-1
panel with 10px of padding above and below, so a row sat inside four
edges of one colour and ~40px of dead space separated the last row of
one project from the first row of the next. The surface is drawn once
by the card now and the grouping is carried by hairlines instead.

Two layout faults measured on the running app go with it:

  * the card edge was a box-shadow ring, which paints 1px OUTSIDE the
    border box. These cards sit flush against the left edge of
    .report-container, which is overflow:auto with no left padding, so
    the left-hand edge of every ring fell outside the scroll box and was
    clipped. The right-hand edge survived on that container's 8px of
    right padding, which is why the cards looked like they had three
    sides. A border paints inside the box and cannot be clipped.

  * overflow:hidden on the card changed its automatic minimum size from
    min-content to zero, so flex was free to shrink the cards below
    their own rows - and did: with four date groups in a .weekly-logs of
    definite height every card was squeezed to about a third of its
    content, and the overflow:hidden that allowed it also hid the cut.

* fix(ui-core): let the apps & urls column labels take their own font weight

The bootstrap font-weight-bold utility carries !important, which pinned
the column labels at 700 and made the 600 they are set in unreachable
from SCSS. align-items-center went with it: .table-inner-wrapper already
centres its items.

* refactor(gauzy): rebuild the time limit report on the shared report table

One component behind two routes, so the block that sizes the columns has
to serve both orders they come in.

The widths were plain width values, which a flex item ignores once it has
a basis, and they summed past 100% before the 10px margin report.scss
hangs off each column was counted - so every column was being shrunk, and
an xxl breakpoint then re-shrank two of them again to make room. As
flex-basis with the name column taking the slack nothing is shrunk, and
the breakpoint is gone.

The activity column used to take the remainder, which made the bar 640px
wide on a 1274px table - longer than the other four columns together, for
a value that is a proportion and reads no better for the length.

The three figure columns are sized by their HEADERS rather than their
values: every value is a 00:00:00 about 58px wide, but "Remaining hours"
needs ~118px in caps and was wrapping onto a second line, pushing the
header row taller than any row beneath it.

* fix(gauzy): drop a stray 8 from the daily limits spent hours heading

A literal 8 sat in front of the translated label and rendered as
"8 Spent hours".

Also drops the bootstrap font-weight-bold utility from the column header
wrapper, which carries !important and made the weight the stylesheet sets
unreachable.

* refactor(gauzy): rebuild project budgets on the shared report table

The Budget cell had three competing widths - flex-wrap: wrap, a 5% right
padding and a 65% progress wrapper - which added up differently at every
viewport, so the bar wrapped under the amount on some rows and sat beside
it on others, and the 5% left the column ending somewhere other than the
four columns above it.

Amount then bar on one line now, with the amount right-aligned in a box
of its own width so the bar starts at the same x on every row. Shrink
wrapped and left-aligned it was a different width per row - BGN 602.71
against 122:00:00 Hours - and walked the start of the bar back and forth
down the column.

The Budget column no longer takes the remainder either: that made the bar
grow with the window.

* fix(gauzy): sit the project budgets budget heading over its amounts

The Budget column holds two things, an amount and then a bar, so an
unwrapped label sat at the far left of the whole column - about 110px to
the left of the figures underneath it. Wrapping it in a span lets it be
given the width of the amount it heads.

Also replaces the literal string "No data yet...." in the
Employees/Teams column with the em dash this same template already
renders for a budget type it has no figure for.

* refactor(gauzy): rebuild client budgets on the shared report table

The same table as project budgets with a contact in place of the project,
so it is sized the same way and its Budget cell is rebuilt the same way:
the three competing widths become an amount right-aligned in a box of its
own width, then the bar. This copy also hung a 2% left margin off the
progress wrapper on top of the other three.

* fix(gauzy): sit the client budgets budget heading over its amounts

Wrapping the label lets it be given the width of the amount it heads,
instead of sitting at the far left of a column whose first 140px is the
amount and whose remainder is the bar.

Also replaces the literal string "No data yet...." in the
Employees/Teams column with the em dash this same template already
renders for a budget type it has no figure for.

* refactor(gauzy): rework the all reports page card grid

  * category separation was 1rem, the same step as the gap between two
    cards INSIDE a category, so the grouping the headings announce was
    nowhere in the spacing. It is the larger of the two steps now.

  * the card and preview edges come off the border ramp rather than the
    card-shadow token, which is a grey-lavender line - that tint is what
    made these cards read as coloured.

  * the description is clamped to two lines and reserves the second. The
    seeded descriptions run 60-90 characters against a minmax(20rem, 1fr)
    track, so they wrapped to one line or two depending on the wording,
    and that difference travelled down the card and put each row of
    screenshots on a different top edge.

  * the grid no longer centres its items. A grid item stretches to its
    row height only while its own height is auto, so centring left every
    card at its own content height about a common middle - which is why
    one card's title did not line up with its neighbour's.

  * a ::-webkit-scrollbar { display: none } rule is gone. Emulated
    encapsulation scoped it to this component, so it hid the scrollbar on
    the card body holding the whole report grid: the page still scrolled
    several category rows deep with nothing on screen saying so.

* refactor(gauzy): rebuild the all reports card markup

The card was a div wrapping a flush nb-card with zero margin and zero
border, and the card already drew the shared card-shadow ring - so the
two hairlines landed on top of each other. It is one nb-card now, with
the screenshot in a preview tile of its own.

The view button was visibility:hidden until the card was hovered, which
took it out of the tab order: a mouse was the only way to open a report
from this page. It is always focusable now, and the overlay reveals it on
:focus-within so it is never an invisible target either.

* fix(gauzy): give the limit reports a real empty state

Two of them, and neither was designed.

A day with nobody on it rendered a bare div carrying bootstrap ml-3 - a
left margin and nothing else - so once the card body lost its padding the
line sat hard against the date above it with no vertical space at all. It
is a proper empty row now: the rule the column header would have drawn,
the gutter a row would have used, and the muted colour the column labels
use. The style lives in report-table.scss so the other row tables can
reach it.

It is deliberately not ngx-no-data-message: that is the WHOLE report's
empty state and draws a 48px icon at height 100%, so one per date card
would be louder than the data it stands in for, and a quiet week would
fill the page with chart icons.

The whole-report branch was a hand-rolled nb-card holding a bold centred
line - the only one of the five reports not using the shared empty state,
and the only one with no notion of loading, so it announced "No
Employees" on the first frame of every visit before the request had been
answered. It uses ngx-no-data-message now, which draws a skeleton until
the answer is in.

The spinner moves onto .table from a sibling div that only existed while
loading: with a skeleton below it the two would have stacked, and as an
overlay it now also covers a refetch, which the old sibling could not -
it was only rendered when the list was empty to begin with.

* feat(ui-core): extend the shared report table to accordions and badges

Two of the reports build a group from an `nb-accordion-item` rather than
an `nb-card` - Time & Activity and Manual time edits - but both spell it
with the same `.card` / `.card-title` classes, so the mixin now answers to
either element name. The accordion body also stops painting
`gauzy-sidebar-background-2`, which made an expanded group a panel inside
a panel exactly as the nested row wrappers used to.

`nb-badge` joins the type scale. `report.scss` sets it at 14px, which was
the row's own size when that rule was written; with the tables at 12 it
had become the single largest piece of text in the row, so an activity
percentage outranked the employee it belonged to.

* fix(ui-core): mute the project member count

`.employees-count` set no colour of its own, so it inherited
`text-basic-color` from the table row and came out at the same
full-strength white as the project name above it - two lines of equal
weight where only one is the subject.

It takes `gauzy-text-color-2`, the token the avatar chip's own `.caption`
already uses. The two sit side by side in every one of these tables, each
a title over a secondary line, so they now read as one pattern.

Fixed at the source rather than in the report stylesheets because all
eight call sites are report tables where that line is a caption: the two
budget reports, manual time edits, and the payment, expenses, daily,
activities and desktop recap grids.

* refactor(ui-core): put the last four shared report grids on the report table

Time & Activity, Expense, Payments and Amounts owed are built from the
same skeleton as the reports already converted, but each had restyled it
on its own: 14px cells against 12 everywhere else, bold mixed-case column
labels, and the stack of nested panels the shared mixin exists to undo.

Their COLUMN WIDTHS are deliberately left alone. Those are each report's
own proportions, and re-sizing them is a separate job from the type
scale - doing both at once would have put layout changes into pages whose
layout nobody has complained about.

The one exception is the daily grid's accordion header, which is not a
title but a summary ROW: its columns are its own direct children rather
than a `.table-inner-wrapper`'s, so it takes the row's padding and column
gap instead of the group-title padding every other report gets.

* refactor(gauzy): put weekly and manual time edits on the shared report table

The last two row tables that were still setting their own type. Same
treatment and same limit as the four shared grids: surface, hairlines,
row padding and the 12px scale, with each report's column widths left as
they were.

* fix(gauzy): render the manual time edit rows at all

The rows sat inside a bare `<ng-template class="table-template">`. An
ng-template with no structural directive and no `ngTemplateOutlet` is
never instantiated, so the `@if (dailyData?.length > 0)` branch rendered
nothing - and that is the branch taken whenever the report HAS data. The
page was blank exactly when there was something to show, and the only
thing it could ever display was the empty state in the `@else`.

Unwrapping the template is the whole fix. Behaviour, not styling, so it
is on its own: the report now shows its rows.

* feat(ui-core): give the report group header a surface of its own

The group label - a date, or an employee or project chip - sat on the same
surface as the rows beneath it, so it read as a first line of content
rather than as a band across the table.

`background-basic-color-2` rather than `gauzy-card-2`: the row hover
already uses the latter, and a header painted the same as a hovered row
would look like one. It is a step off the card in both themes without
introducing a third colour - rgb(24, 24, 27) on the dark card's
rgb(18, 18, 20), and #f7f9fc on the light card's white.

* feat(ui-core): let the activities grid take group by from its host

`groupBy` becomes a settable input backed by a getter/setter pair, so it
can be driven from the page while this component's own select still binds
it with `[(ngModel)]`, and a change from either side refetches through the
same subject the filters input uses.

`showGroupBy` turns off the control this grid draws for itself. Nothing
uses it yet; the Apps & URLs page does in a following commit, to put Group
on one line with the other filters instead of on a row beneath them.

* feat(ui-core): make the activities grid group by control optional

Wraps the control in `@if (showGroupBy)`, which defaults to true - so
every existing use of this grid renders exactly as before.

* feat(gauzy): move group by onto the apps & urls filter line

Group used to sit on a row of its own beneath the filters, as a second
band of chrome above the table. The page owns the value now and projects
the control into `ngx-gauzy-filters`, whose `<ng-content>` is the first
item inside its own filter row - so Group lands FIRST on the line, ahead
of the time zone, Source, Activity Level and Log Type, and the grid is
told not to draw its own.

The filter row and its column are also classed, so the stylesheet can
widen that row specifically in the commit that follows.

* fix(gauzy): keep the apps & urls filters on one line

The filter strip was pinned at 972px whatever the window did - measured
identical at a 1600px viewport and a 2400px one - so the fifth control
wrapped on a card 1570px wide with hundreds of pixels going spare.

The cap is four nested bootstrap boxes, every one of them shrink-to-fit:

  .row > .col-auto.ml-auto            (this template)
    ngx-gauzy-filters
      .row.col-auto.main-wrapper      (that component)
        .col-auto.ml-auto
          .filter-item-list

A `.col-auto` is `flex: 0 0 auto`, so it takes its content's width rather
than its parent's, and each level also charges 15px of gutter either side.
Each box now takes the full width of its parent instead; the strip reaches
1248px and the controls stay right-aligned within it. Nothing is made
wider than the card - the cap is simply no longer narrower than it.

Also:

  * Source, Activity Level and Log Type go from 220px to 185px. Two rules
    held that width, `nb-select { width: 100% }` and a 220px `min-width`
    on the wrapper, so narrowing the select alone left the old box around
    a smaller control. No label truncates - the longest needs ~150px.

  * every item loses its 5px bottom margin and the row centres instead.
    The existing filters carried that margin and the projected Group
    control did not, which under `align-items-end` left it sitting 5px
    low. Equal boxes hold even if a control changes height later.

  * wrapping is kept as a safety net over `report.scss`'s
    `nowrap !important`: it cannot trigger at any normal width now, but if
    something does take the room - the AI assistant sidebar is 384px - the
    controls move to a second line rather than off the right edge.

* fix ed the select colors

* fix(ui-core): pack report table columns to the start

`report.scss` sets `justify-content: space-between` on the row, which
hands every column an equal share of whatever width is left over. On the
tables whose columns are all fixed that pushed the last one hard against
the right edge and blew the gaps between the rest out to roughly 95px, so
a row read as a handful of scattered values rather than as a set of
columns.

The gap between two columns is now always the shared column gap and any
slack is left at the END of the row. The header and the body are laid out
by the same rule, so they stay on one grid either way - re-measured at 0px
column drift across every row of all five table reports.

* fix(gauzy): read the limit report figures from the start of their column

Spent, Remaining and Limit were right-aligned, so their values hung off
the right edge of each column while the label above sat elsewhere and
nothing in the row began on a common edge.

All three now read from the start, headers included. The digits still
stack character for character: `tabular-nums` from the shared mixin is
what does that, not the alignment.

* fix(gauzy): align the project budget figures and their headings

Two faults in the same column block.

Spent, Remaining and the Budget amount were right-aligned, so a value hung
off the right edge of its column while the label above it sat elsewhere.
They read from the start now, headers included; `tabular-nums` from the
shared mixin is what keeps the digits stacked, not the alignment.

The Budget heading was also 42px off the amount beneath it despite both
sitting in an identical 140px box. The cause was
`@include nb-ltr(text-align, right)` - that mixin emits a `[dir=ltr]`
prefixed selector which did not match here, so the declaration silently
never applied and the heading kept the default alignment while the amount
did not. The last of those calls is gone from this file; a logical
`text-align: start` resolves per writing direction with no prefix to fail.

Measured after: header text and value text share a left edge to 0px on
every column, across all 16 rows.

* fix(gauzy): align the client budget figures and their headings

The same two faults as the project budget report, fixed the same way:
Spent, Remaining and the Budget amount read from the start of their column
rather than hanging off its right edge, and the Budget heading drops the
`nb-ltr(text-align, ...)` call whose `[dir=ltr]` prefixed selector never
matched - which had left it 30px off the amount below it.

Measured after: 0px between header text and value text on every column,
across all 8 rows.

* fix(gauzy): mark the all reports preview images as decorative

The `<img>` carried no `alt`, so assistive technology falls back to
announcing the image URL in its place. The tile is a screenshot of the
report the card already names in its header, which makes it decorative:
an empty `alt` is what takes it out of the accessibility tree rather than
putting a filename into it.

* fix(gauzy): reveal the all reports view action on touch devices

The View Report button is tappable and focusable while its overlay sits
at `opacity: 0` - it is `:hover` and `:focus-within` that bring the
overlay up. A touch screen has neither of those to offer, so the action
was there but unlabelled and invisible, and the card read as a static
screenshot.

`hover: none` matches exactly those devices - a primary pointer that
cannot hover, which no desktop browser reports - and shows the overlay up
front for them. The hover and focus rules are untouched everywhere else.

Because the overlay is permanent on those devices, the button is anchored
to the bottom of the tile and the gradient's dark end kept to the lower
half, so the screenshot the tile exists to show stays readable underneath
it.

* feat(ui-core): let a host widen and compact the filters strip

Every box `ngx-gauzy-filters` renders is a bootstrap `.col-auto`, i.e.
`flex: 0 0 auto`, so the strip is shrink-to-fit at each of its three
levels: a host that gives it a wide container still gets a strip the
width of the controls inside it. A host that wanted otherwise had no way
to ask for it, and the boxes involved are private to this component -
`::ng-deep` was the only route to them, which the repository's Angular
guide discourages.

Two opt-in inputs instead, reflected onto the host element so this
component's own stylesheet is what keys off them:

  * `fullWidth` spans the container, keeps the controls against the
    trailing edge, and lets them wrap to a second line rather than
    overflow;
  * `compact` narrows each filter column, for a host fitting more onto
    that line than this component renders on its own - one projecting an
    extra control through `<ng-content>`, for instance.

Both default to false, and nothing under either class is reachable
without it, so every existing consumer renders exactly as it did.

* refactor(gauzy): style the apps & urls filter strip without ng-deep

The strip's layout came from a `:host ::ng-deep .filters-col` block
reaching into `ngx-gauzy-filters`' `.main-wrapper`, `.filter-item-list`
and `.filter-item`. Those are the filters component's own boxes, and it
now carries `fullWidth` and `compact` inputs for exactly this - so the
template asks for the layout it needs and the rules live beside the
markup they describe. This page keeps only its own `.col-auto`.

The Group By control needed no API at all. It is declared here and only
projected, so it carries this component's encapsulation attribute
wherever it lands in the filters template, and is styleable directly.

Its select was the one genuinely cross-boundary case, since
`.select-button` lives inside `nb-select`'s own template. That is a token
redefinition now - `--select-min-width: auto` on `.group-by-filter` -
matching the pattern already used for the employees table toggle.

It also fixes the override rather than just relocating it: the old
`::ng-deep` declaration carried no `!important`, so it lost to
`.nb-theme-default nb-select .select-button { min-width: ... !important }`
in `_overrides.scss`, and the control was narrow on every theme EXCEPT
the default one. A token is read through that `!important` rather than
fighting it, so the width is now the same on all of them - which means
the default theme's Group By select is narrower than it was.

* fix the text alignement
2026-09-24 18:54:05 +02:00
joel kalema 5de84b3b25 Fix/date selector unit per route (#10275)
* fix(ui-core): a locked date picker owns its unit — a carried-over unit_of_time no longer wins

`isLockDatePicker` means the page works at ONE granularity: a day page (Time &
Activity, Screenshots, Videos, Apps, Visited Sites, Daily timesheet, Dashboard →
Teams) shows the activity of a single date, a weekly page a whole week, Approvals
a month. The user cannot change that unit there — `createDateRangeMenus` offers
only that unit and `showCustomRangeLabel` is off — yet the resolver let a
`unit_of_time` left in the URL by a PREVIOUS page override the route's own.

So arriving from any week page resolved a WEEK range for a day-locked route. A
carried-over `date_end` made it worse: it stretched the range across seven days,
and the arrows then stepped a week at a time, because the `isCustomDate` branch
of the arrow strategies measures the span of the range itself.

A locked route now derives both ends from the anchor date at its own unit and
ignores `date_end` and `unit_of_time` entirely. Unlocked routes — Reports, the
aggregate dashboards, Invoices, Payments, Time Off — still read the URL, so a
unit the user picked from the ranges menu survives a reload.

Also made the route config null-safe: a route that declares no `datePicker` used
to throw on `.unitOfTime` (reachable from the estimates list route) and now falls
back to DEFAULT_DATE_PICKER_CONFIG. `isCustomDate` keeps its effective meaning —
`parseToBoolean` never returns nullish, so the old `?? !!date_end` was dead code,
and reviving it would have marked every reloaded range custom since the picker
writes `date_end` for predefined ranges too.

* fix(gauzy): fold the resolved unit of time into the date picker config

The resolver is the only place that sees a route's picker configuration and the
URL together, and it runs exactly once per RESOLUTION — which makes it the right
owner of the effective unit for a page: the route's own unit where the picker is
locked, the URL's `unit_of_time` where the user is allowed to change it.

It already returned that unit on the resolved range, and the app component threw
it away, rebuilding the config from the static route data alone. The header
picker was left to re-derive the unit from the URL itself, racing the config it
was handed on NavigationEnd.

Folding `dates.unitOfTime` into the config makes the config the single source of
truth and lets the picker stop reading the URL as an authority (next commit).

* fix(ui-core): the route config outranks the URL for the picker's unit of time

Closes the feedback loop that left Employees → Time & Activity showing a single
date in the input while WEEK was the selected range.

The picker let the URL's `unit_of_time` outrank the route's `datePicker.unitOfTime`
unconditionally. Under `location.replaceState` that was harmless: the picker's own
writes produced no `queryParams` emission, so the URL only ever carried a unit some
route had put there. Once 4ee49a1770 gave the router the write, both legs came
alive and the loop closed:

  1. On a route change `route.queryParams` emits BEFORE NavigationEnd hands the
     picker the new route's config, so for one turn the OLD config is paired with
     the new URL.
  2. The picker derives with that stale unit and writes it back to the URL.
  3. The day-locked config arrives an organization round-trip later — and lost to
     the URL, which now held `week`. It then self-stabilised at the wrong value.

A FRESH config now wins, because the resolver has already folded the URL into it,
honouring `unit_of_time` only on routes that let the user change the unit. The URL
wins only BETWEEN resolutions — the in-page case the query param exists for: a
range picked from the menu, or history stepping back to one. Comparing against the
current unit also drops this picker's own echo, and with it the stale write in (2),
so the URL can no longer carry a unit the current route disagrees with.

Affects every locked page, not only the reported one: Screenshots, Videos, Apps,
Visited Sites, Timesheet → Daily and Dashboard → Teams (day), Timesheet → Weekly
and Reports → Weekly Time Reports (week), Timesheet → Approvals (month).

* fix(ui-core): keep the date range resolver off the core barrel

The locked-unit fix reached for `DEFAULT_DATE_PICKER_CONFIG`, turning what had
been a type-only reference to `@gauzy/ui-core/core` into a VALUE import. That
pulls the whole barrel — and through it the store and @datorama/akita — into
every resolution of a route that resolves dates, and into anything that loads
this file on its own.

The two values actually needed are a boolean that defaults to false and a
fallback unit, so they are read off the route's own config with local defaults
and the type import is marked `import type` so it cannot regress to a value
import again.

Surfaced by writing the resolver's unit test: the suite could not even load the
file, because jest does not transform akita's untransformed ESM.

* test(ui-core): pin the route-versus-URL precedence in the date range resolver

Addresses review feedback: the new precedence and the locked-range
normalization had no focused coverage, so a later routing change could silently
restore the week-on-day regression.

Parameterized over all three locked units:
  - a day/week/month-locked route keeps its own unit against every other
    unit_of_time the URL can carry;
  - a locked route ignores a stale date_end instead of stretching across it, and
    reports the range as not custom so the arrows step by the unit rather than by
    the range's own span;
  - a mid-week anchor snaps to the whole week instead of running from the anchor.

And the other side of the rule, so the fix cannot be "corrected" into ignoring
the URL everywhere: an unlocked route still honours unit_of_time, still preserves
an explicit start and end, and still falls back to the route's unit when the URL
carries none. Plus the missing-datePicker route that used to throw.

The resolver injects nothing, so the tests call it directly — no TestBed, and no
app graph.

* fix(ui-core): a deep-linked end date marks the range custom again

The dashboard widgets and the time-tracking page open the manual-time and
apps-urls reports with only `date` and `date_end`. Both routes are unlocked
`week`, so a range reported as not custom has the arrows step a whole week
instead of the span the caller handed over — a 4-day widget range jumps 7 days
on the first click back.

`is_custom_date` is now read as authoritative only WHEN PRESENT, with a bare
`date_end` standing in for it when it is absent. The obvious shorter form,
`parseToBoolean(is_custom_date) || !!date_end`, is wrong: the picker writes
`date_end` alongside every range it stores, predefined ones included, so that
would mark every reloaded week or month custom and break the arrows in the
common case instead of the rare one.

Not a regression from this branch, despite how it reads. The line used to say
`parseToBoolean(is_custom_date) ?? !!date_end`, and `parseToBoolean` returns a
boolean for every input — never null or undefined — so the fallback was dead
code and the deep-linked ranges have always resolved as not custom. This makes
the original intent actually run.

* test(ui-core): freeze the clock in the resolver spec and cover deep-linked ranges

Two review findings.

The clock: the resolver calls `moment()` for any range the URL does not pin
down, and two expectations called `moment()` again to build the value to compare
against. Crossing a month boundary between the two calls would have failed the
run. The clock is frozen to the same instant the file's date constants describe,
built from local parts so the calendar day is 2026-09-23 in every timezone, and
those expectations now assert the constants directly instead of re-deriving
them.

The coverage: both sides of the `is_custom_date` rule, which has no other guard
against being "simplified" back into a bug. A URL with an end date and no flag
resolves as custom, so the arrows step by the span the dashboard handed over;
an explicit `is_custom_date=false` still wins over the end date the picker
writes for every range, predefined ones included.

18 tests, all passing.

* refactor(ui-core): lift the picker's unit precedence into selectUnitOfTime

The rule that decides which unit of time the header picker runs at — route
config on entry, URL in-page — sat inline in a `tap` inside a combineLatest,
where the only way to reach it is to stand up the component and the app graph
behind it. It is the part of this fix most likely to be silently undone by a
later change, so it moves to a pure function that can be tested on its own.

Behaviour is unchanged; the component now asks `selectUnitOfTime` and applies
the answer, or leaves its unit alone when the answer is null. The reasoning
moves onto the function with it, so the component keeps only the note about why
the config is compared by reference.

* test(ui-core): cover the picker's unit precedence on entry and in-page

Addresses review feedback: the resolver spec calls the resolver directly, so a
regression in what the picker does with the resolved config would have left it
green.

Arriving on a route: a freshly resolved config wins over whatever `unit_of_time`
the URL still carries from the page the user came from — parameterized over the
day/week/month combinations — and the route unit still applies on the very first
emission, when the picker has no current unit yet.

Staying on one: a unit the URL changed to is adopted, so a range picked from the
ranges menu takes effect; the URL echoing back what the picker just wrote is
ignored, which is the write loop this rule closes; an organization or timezone
re-emission leaves an in-page choice alone; and the stale window of a route
transition produces no change at all, so nothing stale reaches the URL.

Tested as a pure function rather than through the component: component specs in
this package cannot currently run at all — they fail loading @datorama/akita's
untransformed ESM through the core barrel, which predates this branch.
2026-09-23 13:12:35 +02:00
joel kalema 6c550da567 Merge pull request #10281 from joel-kalema/fix/i18n-cache-headers-and-missing-keys
Fix/i18n cache headers and missing keys
2026-09-23 13:10:55 +02:00
joel kalema 6ceaccc4db Fix/custom dashboard drag and drop (#10269)
* fix(dashboard): assign both grid axes when re-flowing a canvas layout

`packLayout` only ever corrected `y`, leaving `x` exactly where it was. The
canvas derives reading order from `(y, x)`, so reordering two widgets that
shared a row was a silent no-op: the sort read the untouched `x` values back
and restored the original order.

`flowLayout` assigns both coordinates from the order of the array, which is
what the builder actually models -- widgets are appended, reordered by drag and
resized from a menu, never placed at an arbitrary column. It is the sparse
packing algorithm from CSS Grid 8.5 step 4, because the canvas now renders its
cells with spans only and lets the browser place them; the two have to agree or
the persisted `x`/`y` would describe a layout nobody sees. Verified against a
real browser across four layouts, including ragged heights.

Sparse rather than dense on purpose: dense back-fills earlier gaps, which would
let a widget render before one that precedes it in the array, and the index CDK
reports for a drop is a position in that array.

Also adds `readingOrder`, and the pure drop-point geometry (`isPointInRect`,
`dropIndexAtPoint`) the canvas uses to resolve a release that landed between
cells. `packLayout` is untouched: `normalizeLayout` still depends on it.

* test(dashboard): cover canvas flow layout and drop-point geometry

Locks in the two contracts that are easy to break without noticing:

- `flowLayout` must pack exactly like CSS grid auto-placement, because the
  browser is what actually positions the cells. The ragged-heights case is the
  one that catches drift -- `d` tucks in beside the tall `a` at column 4 rather
  than starting a fresh row.
- A move between two widgets on the same row must survive the reading-order
  sort that follows it. That is the regression that made drag-and-drop look
  dead.

Plus the drop-point geometry: below everything, in a gutter, on the next row,
off either edge, and the half-open edge rule that stops two neighbouring cells
both claiming a boundary pixel.

* chore(i18n): add widget height labels and update the builder drag copy

`HEIGHT` / `HEIGHT_ROWS` back the new height ladder in the widget menu.

The drag copy no longer points at the grip: the whole card is the drag surface
now, and the rail is the keyboard control, so the hint and the handle label say
that instead.

Only `en.json` carries these -- the rest of the builder strings are English-only
too, and the other locales fall back.

* fix(dashboard): make canvas drag-and-drop actually move widgets

Dragging a widget did nothing at all, in any direction.

`cdkDropListOrientation="mixed"` selects CDK's `MixedSortStrategy`, whose
`sort()` shows the new position by MOVING THE PLACEHOLDER NODE between cells
(`overlapElement.after(current)`) and does nothing else. Every cell was pinned
with `grid-column: x+1 / span w`, so DOM order had no bearing on where anything
rendered: CDK was reordering nodes inside a grid that ignored node order.
Measured in a browser -- moving the first cell to the end left every widget's
pixel position identical.

Cells now declare spans only and the grid auto-places them in DOM order, so the
sort is visible and the placeholder shows the real destination.

Three further fixes:

- `cdkDragHandle` is gone. With it, only the 16px rail could start a drag and
  grabbing the card -- what everyone tries -- did nothing. The whole cell is the
  drag surface; the kebab still opens on click because CDK needs 5px of movement
  before treating a press as a drag. The rail stays as the arrow-key control,
  which is the only way to rearrange without a mouse.
- A release over the canvas' empty space is resolved from the drop point.
  CDK only re-sorts while the pointer is over another cell, so dropping into a
  gap, the ragged space beside a tall widget, or the run-off below the last row
  silently kept the index of the last cell crossed -- dragging a widget to the
  bottom put it back near where it started.
- A release off the canvas changes nothing. CDK leaves a rejected or stray item
  at whatever index it sorted to on the way past, so flicking a widget towards
  the palette (which refuses it) quietly reordered the canvas. The canvas rect
  is measured in the handler rather than read from `isPointerOverContainer`,
  which CDK answers from a rect cached at drag start -- a layout shift mid-drag
  leaves it tens of pixels stale and rejects drops made well inside.

Also drops the dead `_normalizePlacementHeight`, which was a no-op today but
would have squashed any widget declaring `minSize.h === 1` to one row on load,
and swaps CDK's default preview (a full clone of the cell -- a large translucent
card covering the slot being aimed at, with a blank cloned canvas) for a chip.

Verified with a real CDK harness driven by Playwright: 17 scenarios covering
horizontal, vertical up and down, to the start and end, ragged rows, gaps, dead
space, palette drops, rejected drops, no-op drags and a scrolled page.

* fix(dashboard): stop canvas rows inflating and tighten the grid

`align-content` behaves as `stretch` on a grid and `grid-auto-rows`' `auto`
maximum is stretchable, so the rows expanded to fill `min-height`: a single
two-row widget on a fresh canvas grew to the full 14rem. `align-content: start`
keeps rows at their own size, and the space left over becomes the run-off the
drop-point fallback reads as "put it at the end".

`min-height` now applies only while editing, where it is that drop target. A
read-only canvas has nothing to drop, and reserving 14rem under two small
widgets was just empty page.

Density: the row unit goes 64px -> 52px and the gap 1rem -> 0.75rem, so a
six-row widget is 372px rather than 464px. Widget bodies scroll, so a tighter
unit costs a little scrolling inside a big widget and buys the whole canvas
fitting on one screen.

The grab rail is visible from the moment edit mode starts -- at `opacity: 0`
the only affordance for rearranging was hovering a strip nobody knew was there
-- and the cell reserves a gutter for it so it sits beside the card instead of
over the first 14px of its content.

Drops the sibling-transform transition: the mixed sort strategy re-orders nodes
and never transforms siblings, so it was dead.

* fix(dashboard): auto-scroll the dashboard while dragging a widget

This host element is the dashboard's scroll surface -- `dashboard.component.scss`
gives `router-outlet ~ *` the `overflow-y: auto`, because the app layout's own
scroller is clamped so the header and footer stay put.

`CdkDropList` only auto-scrolls the viewport and ancestors registered with
`ScrollDispatcher`, which is what `cdkScrollable` does. Without it, dragging a
widget towards the bottom of a canvas taller than the window stopped at the
fold: the page never scrolled, so the lower half was unreachable by drag.

* style(dashboard): match the page spacing to the canvas grid

The page stacked its heading, hint bar, tab strip and body on a 1rem rhythm
wrapped around a 0.75rem grid, which read as a loose frame round a tight
canvas. Same gap on both now, and the heading loses a quarter rem it was not
using.

* feat(dashboard): show a drop slot when dragging a widget from the palette

CDK carries the placeholder into whichever list the pointer is over, so the
default one -- a clone of the palette row -- was being dropped into the canvas
GRID as a full-width list entry.

The slot is styled in the palette's own sheet rather than the canvas': CDK
renders it from this component's template, so it carries this component's style
attribute and a canvas-scoped rule would never match it. Its grid spans are
inert in the palette's own block list and match the most common widget
footprint, since the real one is only known once the drop resolves the widget's
`defaultSize`.

* feat(dashboard): let a widget's height be resized from its menu

The menu offered width only, so a widget arrived at whatever `defaultSize` its
author picked and the only way to stop one eating half the canvas was to remove
it.

`resized` now carries either dimension and the menu shows two ladders. A ladder
rather than every value in range -- a chart allowing 3 to 10 rows would put
eight near-identical buttons in the menu -- but both of the widget's declared
bounds are always included, so the shortest and tallest size it supports are
always reachable.

* style(dashboard): tighten widget card chrome

The header and body paddings are the frame's fixed overhead: paid by every card
on the canvas at every size, and they consumed around 2.4rem of a two-row widget
before its content got a pixel. Tight here so the grid can be tight.

Also renames the menu's width ladder classes to serve both axes.

* fixed the AI comments

* fixed the AI comments
2026-09-22 17:55:55 +02:00
joel kalema 5fafd072af Merge pull request #10260 from joel-kalema/feat/polish-estimate-dialogs-and-tables
Feat/polish estimate dialogs and tables
2026-09-22 11:43:52 +02:00
joel kalema ec1f6d3f7f Merge pull request #10255 from joel-kalema/feat/polish-key-result-dialogs
Feat/polish key result dialogs
2026-09-21 11:32:27 +02:00
joel kalema b781271668 Merge pull request #10236 from joel-kalema/feat/polish-goals-dialogs
Feat/polish goals dialogs
2026-09-20 23:50:40 +02:00
Ruslan KonviserandClaude Opus 5 ebf676b2f8 fix(security): No published default signing secrets (GHSA-39j7, GHSA-chm8, GHSA-4r2r, GHSA-3cgp) (#10240)
* fix(security): never sign tokens with a published default secret

GHSA-39j7-x845-4w3c (high) and GHSA-chm8-2ggf-pgjq: when JWT_SECRET,
JWT_REFRESH_TOKEN_SECRET, JWT_VERIFICATION_TOKEN_SECRET or
EXPRESS_SESSION_SECRET was unset, the API fell back to a literal published in
this repository, so anyone could forge tokens and sessions. resolveSecret() now
substitutes a per-process random value instead, and the startup guard still
reports such a secret as unset, so production keeps refusing to boot. The
known-default list is shared by the config resolver, the startup guard and the
desktop apps, and it catches a default published for any key, not only its own.

Desktop apps generated no secrets at all: every install shipped the same baked
DESKTOP_JWT_* values while binding the local API to 0.0.0.0. They now provision
random per-install secrets on first run and rotate a stored published default.

DEMO=true keeps today's behaviour, with a TODO, pending a decision on
demo.gauzy.co's secrets.

Also: the MCP refresh grant re-checks the account on every refresh instead of
trusting a 30-day-old token (GHSA-3cgp-wmrg-4fqg residual), and the misleading
comment claiming the Electron seed-credential exemption is harmless is corrected
(GHSA-4r2r-mv32-3468).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(config): resolve signing secrets lazily so load order cannot split them

Found by a runtime probe against a locally built API: login returned 200 but the
access token it issued was rejected on the next request, because the process was
signing with one secret and verifying with another.

apps/api/src/main.ts calls loadEnv() (which reads .env.local and friends) only
after its imports have run, so @gauzy/config can be evaluated while JWT_SECRET is
still unset. With the published literal as the fallback that was invisible: the
early copy and any later copy both ended up on 'secretKey'. Once an unset secret
became a per-process random value, the early copy generated one while a copy
imported after loadEnv() read the configured value — so tokens signed by one
never verified in the other, and every authenticated request 401'd.

The four secrets are now getters on `environment` / `environment.prod` /
`defaultConfiguration.authOptions`, so each is read at first use, after the env
files are loaded. Adds the regression test for exactly that order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(security): separate an unavailable account lookup from a dead refresh token

Review pass over PR #10240. The substantive fixes:

- MCP refresh grant (CodeRabbit major, Greptile P1). `getMcpUserInfo` caught
  every error and answered `null`, so a database blip reached
  `refreshAccessToken` as "this user is gone" and the grant handler answered
  `invalid_grant`. Well-behaved OAuth clients discard a refresh token on that,
  so one transient outage signed active users out for good. `getMcpUserInfo`
  now re-throws a failed lookup, `refreshAccessToken` re-throws it as
  `UserLookupUnavailableError` (outside its own catch, so it cannot be
  flattened into `null`), and the grant answers `temporarily_unavailable` /
  503. `null` now means exactly one thing: the account can no longer sign in.
  The refresh token is still never revoked on either path.

- `resolveUser` is now REQUIRED (Greptile P2). It was optional on a public
  method, so a caller could keep the two-argument shape and silently skip the
  account check the parameter exists to perform. A missing resolver now throws
  before anything else (500 server_error), matching the missing-provider path.
  `UserLookupUnavailableError.is()` is used instead of bare `instanceof`: this
  package ships both as source and as a bundle, and a downlevelled
  `extends Error` would break the prototype chain and quietly restore the
  invalid_grant behaviour.

- Nested credentials reached the desktop logs (CodeRabbit major, CWE-532).
  `redactSecretsForLog` only looked at top-level keys, and `apps/desktop` logs
  the whole `DesktopSetupConfig`, so `postgres.dbPassword` and
  `secureProxy.ssl.key` were printed verbatim. It now walks nested objects and
  arrays, with a depth cap and cycle detection.

- The seeded-account warning missed renamed installs (CodeRabbit major).
  `getPublishedSeedAccounts()` read only today's `DEMO_*_EMAIL` values, but the
  database was seeded in the past: an operator who changed the address after
  installing still had `admin@ever.co` with the published password, and the
  check walked past it. Both the configured and the canonical addresses are now
  checked, deduplicated on the (email, password) pair.

- The bounded read could hide a vulnerable account (Greptile P2). One shared
  `IN (...)` query with a global limit of 10 let the rows of whichever address
  came back first use the whole budget. The budget is now per address (5 rows
  each), so every candidate is actually looked at, and rows are re-matched
  against the address they were fetched for.

- The lazy-getter commit (21e0bfd) broke its own regression suite:
  `validate-application-secrets.spec.ts` assigned to `environment.JWT_SECRET`,
  which is now getter-only, and all 8 tests died at "Cannot set property". The
  spec drives `process.env` instead, which is what the getters read, plus a new
  test pinning that a secret set AFTER import still decides the verdict.

Static-analysis cleanups: `config?.secret` next to an unconditional
`config.db` in both server launchers (the inconsistent null check DeepScan
flagged); the duplicated `JWT_VERIFICATION_TOKEN_SECRET` block in
.env.demo.compose; and the Sonar smells (`node:crypto`, assignment inside a
return, optional chaining, useless empty object).

Not applied: CodeRabbit's request to point the three k8s demo manifests and
fly.toml at a secret store. Those are the DEMO=true deployments this PR
deliberately holds; wiring `secretKeyRef` to Secrets that do not exist would
break the demo without changing the effective values, and the manifests already
document how to inject real ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(auth): correct a stale comment about the refresh account lookup

The deactivated-user test still said the wired provider answers `null` for a
transient lookup failure. It rejects now, and that case is covered by its own
test, so the comment described behaviour the suite no longer has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(auth): drop a word cspell rejects from the token-manager comments

The Cspell check failed on "downlevelled" in two comments. Reworded to
"compiled for a pre-ES6 target", which says the same thing in words the
dictionary already has, rather than growing the project word list for prose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(user): split the published-password check into named steps

The per-address rewrite pushed findAccountsUsingPasswords over SonarCloud's
cognitive-complexity limit (16/15) with three nested loops and an inline ORM
switch. The query and the verification loop are now their own methods, and the
candidates collapse into a Map of address -> passwords instead of being
filtered, de-duplicated and re-filtered inline. Same behaviour: one query per
distinct address, every proposed password tested, first match wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(core): report a non-exhaustive seed-password check instead of implying clean

The check reads a bounded number of rows per address, because it runs before
the API listens and every scrypt/bcrypt verification is expensive by design.
With the same seeded address in more tenants than the budget, a vulnerable row
can sit outside the sample — and a boot that found nothing looked exactly like
a boot that checked everything.

findAccountsUsingPasswords now returns `{ matches, inconclusive }`;
`inconclusive` names the addresses whose rows filled the budget without a
match, and boot prints a short note asking for those tenants to be audited
separately. It deliberately does NOT raise INSECURE ACCOUNTS in that case:
a scary warning on every boot of any large multi-tenant install is how a
warning gets ignored.

Raising the budget was the alternative and it does not fix anything — it only
moves the cliff, at the cost of boot time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:59:29 +02:00
Ruslan KonviserandClaude Opus 5 a472bfa248 fix(security): CSV formula injection + rate-limit residuals (GHSA-7xp5, GHSA-86mw) (#10243)
* fix(security): neutralize spreadsheet formulas in CSV exports

GHSA-7xp5-j564-4752 (medium): exported cells were written verbatim, so a stored
value beginning with = + - or @ executed as a formula when a colleague opened the
export in Excel or Sheets. A shared encoder in @gauzy/utils prefixes an
apostrophe to any cell starting with a formula trigger (including the full-width
forms), leaves strictly numeric values alone, and is reversed on import so an
export/import round-trip stays byte-exact. Every field is now quoted, which also
stops a bare CR inside a value from starting a new spreadsheet row, and the
invoice CSV in the web app gets real RFC 4180 quoting instead of JSON.stringify.

GHSA-86mw-2crg-vmhc residuals (low): the public invite routes are throttled, the
shipped compose files no longer trust a forwarded client IP while publishing the
API port directly, and RequestContext.currentIp() resolves the client address the
same way the throttler does instead of reading a spoofable header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(cspell): add the new vocabulary and use US spellings

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(export-import): decode CSV cells only for archives we marked

The import side reversed the spreadsheet-formula escape on every parsed row,
but an uploaded ZIP is not necessarily one this server wrote: it can be a dump
from an older Gauzy, a filled-in `/export/template`, or a CSV set built by
external tooling. For those, un-escaping is data loss — a legitimate value such
as `'=notes` was persisted as `=notes`, silently. Both review bots flagged this
as the one thing blocking the merge, and they were right: the decoder had no way
to tell "we escaped this" from "somebody else wrote this".

A data export now carries a `gauzy-export.json` marker at the archive root
(format, version, `spreadsheetSafeCells`), written by `exportTables` and
`exportSpecificTables`. `ImportService` resolves that marker once per import and
decodes rows only when it is present and recognized; anything else is imported
byte for byte as it was parsed. `/export/template` is deliberately NOT marked —
an operator fills it in by hand, so nothing in it was ever escaped. The manifest
reader is defensive about an attacker-supplied file: missing, oversized,
malformed, a foreign format or a newer version all mean "do not decode".

The invoice/payment CSV builder no longer has a path that skips encoding: a
pre-joined header line used to be written through verbatim, and it was the only
value in the file that reached disk unquoted and un-neutralized. `buildCsv` and
`generateCsv` now declare `headers: string[]` (both callers already pass one),
and a stray string from an untyped caller is split and encoded rather than
trusted.

Tests: `import.service.spec.ts` imports the same escaped CSV with and without a
marker and asserts the apostrophe survives when unmarked (reverting the gate
fails those 5 tests); `export.service.spec.ts` asserts the data archive carries
the marker and the template archive does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:59:19 +02:00
Meet PrajapatiandMeet Prajapati cf438f50b8 feat: allow employees to see their tracked data by default (#9874) (#10212)
Adds an organization setting `allowEmployeeToSeeTrackedData` (default true, so existing organizations
keep today's behaviour) that lets an admin hide an employee's own tracked data — screenshots,
activity, app/URL history, time logs, videos and statistics — from that employee.

- `EmployeeTrackedDataGuard` enforces it on the read routes of the activity, custom-tracking,
  statistic, time-log and time-slot controllers and of the videos, camshot and soundshot plugins.
  It depends only on the global DataSource, so plugins can use it.
- Exempt: admins (`CHANGE_SELECTED_EMPLOYEE`), callers with no employee record in the tenant,
  team/project managers of the organization, and the routes recording depends on — `time-slot/:id`,
  `time-log/:id`, `time-log/conflict`, `POST statistics/tasks` (the desktop timer's task picker)
  and every write route. A spec pins that list.
- The setting is read from the caller's own organization plus any organization the request names, so
  a client-chosen `organizationId` cannot bypass it, and the employee identity is read from the
  database rather than the token claim.
- `GET /timesheet/statistics/tracked-data-access` lets the web UI hide navigation with the same rule;
  the menus, the activity tabs and the dashboard widgets follow it, managers included.
- Adds the admin toggle, seeds, the SQLite/PostgreSQL/MySQL migration and i18n for all 14 locales,
  and removes a UTF-8 BOM from 11 locale files that broke locale loading in the desktop apps.

Resolves #9874.

Co-authored-by: Meet Prajapati <meet987654@users.noreply.github.com>
2026-09-20 10:49:02 +02:00
joel kalema 33d464dba1 Merge pull request #10227 from joel-kalema/fix/polish-goals-pages
Fix/polish goals pages
2026-09-19 14:23:53 +02:00
Trapa-EurekaandClaude Opus 5 d810c81cf3 feat(core): persistence invariants, config/boundary validation, migration smoke test and correlation IDs (#10198)
Adds test harnesses for tenant isolation, TypeORM/MikroORM parity, persistence invariants and idempotency; startup validation for DB config; Nx plugin/core boundary rules; a fresh-database migration smoke test; and a correlation id carried from HTTP requests into docs queue jobs and logs.

Testing (packages/core/src/lib/core/testing):
- tenant-isolation: an in-memory tenant-aware repository, fixtures and strict assertions (a non-empty page, every row in the caller's tenant). Applied to EmployeeService and OrganizationProjectService.
- orm-conformance and persistence-invariants: suites that run the same checks under TypeORM and MikroORM (run-both-orms.sh uses yarn nx).
- idempotency: assertion helpers and specs for token cleanup (positive control), employee notifications and the Zapier timer webhook.
- database/migration-smoke.spec.ts: runs the full migration chain on a fresh better-sqlite3 database. It is excluded from the default core jest run; run it with `nx run core:test-migration-smoke`.

Idempotency:
- EmployeeNotificationService.create() takes an opt-in `absorbRedelivery`. With it set, an identical unread, unarchived notification under 60 s old (same receiver, entity, type, sender, title and message) is returned instead of inserted, and a missing key or a failed lookup inserts as usual. The event handler does not enable it (the in-process EventBus never redelivers), so every caller inserts one row per event as before.
- ZapierWebhookService skips resending a webhook that already succeeded for the same subscription, action and time log within 5 minutes. The key is reserved while in flight, failed deliveries stay retryable, pruning stops at the first unexpired entry, and the cache is capped at 10,000 entries.

Config (packages/config):
- An unknown DB_TYPE fails fast with the list of supported values; an empty DB_TYPE still means better-sqlite3; mongodb throws an Error.
- Pool and timeout variables are parsed with Number.parseInt semantics, only for postgres and mysql. They throw only where tarn already refused to start and warn otherwise. SQLite ignores them and still prints the startup values.

Observability:
- RequestContextMiddleware accepts an inbound x-correlation-id of 1-128 visible ASCII characters (otherwise it generates a UUIDv4) and echoes it on the response. CORS allows and exposes X-Correlation-Id. RequestContext.currentCorrelationId() is added.
- The docs queue carries the correlation id through job payloads (including bulk reindex) into pipeline outcome, error, dead-letter and enqueue-failure logs.

Boundaries and build:
- Every project gets a type tag. The ESLint depConstraints stop type:core depending on plugins, and plugins may depend only on core, shared libs and the known extension points (ai-chat, job-proposal, integration-ai, job-*-ui).
- core declares @gauzy/scheduler (package.json and implicitDependencies); the webapp Dockerfile copies scheduler's package.json.
- The integration-zapier jest config can import @gauzy/core (transformIgnorePatterns, allowJs, isolatedModules).
- Fixes the stale @nrwl/nx eslint-disable id in the e2e roles-permissions steps and the broken @gauzy/core mock in the docs document-scope spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 23:35:52 +02:00
joel kalema da19222a45 Feat/polish timesheet details (#10221)
* style(timesheet): size the page title and breadcrumb at 12px

* fix(timesheet): let each tab's card fill the page down to the footer

* style(timesheet): tighten the filter row on the Calendar, Weekly and Daily tabs

* style(timesheet): set all text on the Calendar tab to 12px

* fix(timesheet): stop hour rows showing through the calendar's sticky day header

* feat(timesheet): open the Daily selection actions in the filter row

* style(timesheet): set Daily row text to 12px

* refactor(timesheet): move the View page's inline styles into classes and drop a debug log

* fix(timesheet): align View page rows with their header and mark the selected log

* feat(time-log): lay out the Edit Time Log form on one shared grid

* fix(time-log): return to the View Time Log popup when the edit is cancelled

* fix(time-log): fit the View Time Log pane to its card so the backdrop closes it

* feat(time-log): redesign the View Time Logs popover with a header, aligned rows and a footer

* fix(timesheet): cap the Weekly tab's text at 12px like the Calendar tab

* feat(timesheet): redesign the screenshot slot popup with a header, summary strip and aligned time logs

* fix(timesheet): give the slot popup's screenshots an employee id so the viewer opens on the clicked one

* feat(timesheet): move the slot popup's summary into its header and put apps beside their title

* style(timesheet): fit the slot popup without a scrollbar and match its thumbnails and bar to the Recent Activities card

* fix(gallery): open the screenshot viewer without focusing its first button

* feat(gallery): redesign the screenshot viewer with a top bar, round nav buttons and a filmstrip

* style(gallery): darken and blur the viewer backdrop and dim missing-screenshot placeholders

* feat(gallery): sort the viewer by time, step with arrow keys and keep the active thumbnail in view

* feat(i18n): add screenshot labels that name each screenshot by its time or place in the set

* fix(timesheet): give the slot popup's screenshots alt text and name their info button

* fix(timesheet): let the Time Logs popover's time range wrap on narrow screens

* fix(gallery): name the viewer's info button, image and thumbnails and mark the selected thumbnail

* fix(gallery): scroll the filmstrip without animation when reduced motion is on
2026-09-17 19:04:04 +02:00
Ruslan KonviserandClaude Opus 5 abd838e202 fix(desktop): mirror ui-core style tokens in desktop apps' shadowing SCSS copies
The desktop-side Angular apps (desktop, desktop-timer, server, server-api,
agent) put apps/<app>/src/assets/styles ahead of
packages/ui-core/static/styles in stylePreprocessorOptions.includePaths.
Their own var.scss and gauzy/_gauzy-overrides.scss therefore shadow the
ui-core files for every bare `@use 'var'` / `@use 'gauzy/_gauzy-overrides'`
in shared ui-core stylesheets, and tokens added only to ui-core are
undefined in these builds:

- $tabset-dsk-tab-padding-block / -inline (#10160), read by
  includes/_tabset.scss and gauzy/_tabset-actions.scss
- $control-hairline / $control-padding-x (#9857), read by
  time-tracker.component.scss since #10207

This has failed build-desktop on develop since c53db3d7c0.

Add these four tokens, with ui-core's exact values, to each app copy and
note why they are mirrored. Additive only: no existing value changes.
A full SCSS sweep using the Angular pipeline's Sass and resolver (2978
compiles) now has zero errors for all five apps. Every stylesheet that
compiled before produces byte-identical CSS. apps/gauzy is unaffected
because its include path never reaches these files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:42:35 +02:00
Ruslan KonviserandClaude Opus 5 dd87b6f71c fix(export-import): keep import archives private, cover plugin entities and stop re-imports clobbering secrets
The uploaded import archive is no longer deleted after an import, because the
Import page offers it for download again. It is instead stored under an
unguessable dotfile name that serve-static refuses to publish, the public
fullUrl derived from it is gone (ImportHistorySubscriber removed), and the file
is served only through a new GET /import/history/:id/download route behind the
existing tenant and permission guards, with an explicit tenant condition that
refuses requests without a tenant. The Angular Import page downloads through
that route.

The export redaction guard now walks every plugin entity in the workspace as
well as the core entities, loading them against the entity layer rather than
the full core barrel. It immediately caught three plugin columns, all
classified with reasons. A PLATFORM inbound address embeds its token, so the
address column is now masked around the token via the new maskEmbeddedSecret
helper.

Re-importing an archive into the tenant it came from no longer writes redaction
placeholders over live values on the update path, which would have broken
public invoice links and nulled password digests. Export hardening: masking no
longer skips the literal strings 'null' and 'undefined'; SMTP credentials use a
fixed opaque mask; the tenant API secret digest and user email token are
blanked; on/off flag spellings round-trip; a failed export job setup removes
its scratch directory; junction rows that carry anything but foreign keys are
refused. The IsSecret half of the tenant-setting check is now tested on its
own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 05:20:36 +02:00
surefirestudios 327dc23cf3 fix: remove comments from PWA manifests so they parse as JSON (#10211)
apps/server/src/manifest.json and apps/server-api/src/manifest.json each
carried a 43-line commented-out "icons" block. A web app manifest is
parsed as strict JSON, where // comments are not valid, so both files
fail to parse. They are copied into the build output by the src/**/*
asset glob, so the invalid files ship.

apps/gauzy/src/manifest.json is the same manifest with that block already
removed, and it is the one actually referenced via <link rel="manifest">.
This brings the other two in line with it.

The commented-out icons were not enabled because the paths they point at
(assets/icons/icon-72x72.png and the rest) do not exist in either app;
uncommenting them would produce a manifest referencing missing files.

All seven key/value pairs are unchanged. Files now use tabs per
.editorconfig and .prettierrc, and end with a newline.
2026-09-16 20:11:12 +02:00
Wu Shuwen 86c0be350b fix: repair web manifest and license links (#10202)
* fix: repair web manifest and license links

* docs: correct LICENSES link label
2026-09-15 15:01:52 +02:00
joel kalema 8c5c24396e Merge pull request #10182 from ever-co/fix/polish-all-table-pages
Fix/polish all table pages
2026-09-15 14:59:40 +02:00
joel kalema c53db3d7c0 Fix/polish organizations pages 2 (#10160)
* style(tables): add the shared list-page block to the table hub

Two mixins, so the eighteen data tables under /pages/organization stop being
eighteen places for the same contract to drift.

`list-page($actions)` bundles what Payments, Invoices, Pipelines, Tasks,
Employees and Job Employees each spell out by hand: density tokens, the table
surface, row hover/selection, the tag-chip stack, the status pills, the pager's
`<nav><ul><li>` and the page-header action row. Those pages interleave
page-specific rules between the blocks, which is why they stay written out; these
have no such exceptions to interleave.

`$actions` defaults to `nb-card-header` rather than `.gauzy-button-container`,
and that is the point: several of these pages put a second row of buttons beside
the title — Inventory's Merchants / Warehouses / Product categories / Product
types, Equipment's "Equipment Sharing" — outside the toolbar container entirely.
Naming only the container left those at full size next to a compacted "+ Add".
Every token the row needs is a custom property, so one declaration on the header
reaches all of them, `ngx-gauzy-button-action`'s own template included.

`thumbnail-row-padding()` restores the app-wide cell padding on a table that
carries a picture column. `density-tokens()` runs at 0.1875rem/0.4375rem, which
is right for a row whose tallest thing is a line of text; a thumbnail row is not
that row, so the tighter box buys no height there and only presses the picture
against the rules above and below it. Set on `angular2-smart-table`, since the
density rules in `_overrides.scss` read these as custom properties inherited by
the `td` — no specificity needed against a `:root`-prefixed block.

The pictures themselves are deliberately left alone: shrinking them to the row's
scale costs the photo its information, and `ga-no-image` clips its "Image not
available" caption mid-word, so a missing image reads as a broken one.

* style(equipment): bring the equipment table onto the list-page scale

Adds the shared block, so this table reads as the same surface as Job Employees
and the rest of the list pages rather than as one drawn a size larger.

The action row takes the block's `nb-card-header` default, which this page needs
twice over: its toolbar sits in a bare `<div class="align-self-end">` with no
container class, and the "Equipment Sharing" button sits beside the title,
outside the toolbar altogether.

Three cells hard-code a box that the density tokens cannot reach, all scoped to
the table rather than the page because `ga-card-grid` renders the same components
for the card layout, where the full-size treatment belongs:

  * Image — `ImageRowComponent` keeps its 74x60 photo; only the cell padding
    around it goes back to the pre-density default.
  * Equipment Name — `ga-picture-name-tags` renders `<ngx-avatar
    class="report-table">`, which pins a 20px image, a 12px name and an 8px gap
    inside a padded pill. Sized off the shared `gauzy-people-*` tokens, the same
    treatment Employees gives its own Full Name cell.
  * Auto Approve — a bare tick or cross, but `nb-icon` defaults to 1.5rem, half a
    rem taller than the row's whole line box, so a column carrying nothing but a
    glyph was setting the height of every row. Re-tokenised through Nebular's own
    `icon-*` keys, so there is no specificity to fight.

Also removes `:host nb-card-body { height: calc($card-height - 0.5rem)
!important }`. `gauzy/_gauzy-cards` already supplies that same chrome estimate as
a FLEX BASIS, which `flex: 1 1 auto` then corrects against the card's real
height; restating it at page level with `!important` turned a starting point into
a floor and a ceiling.

* style(equipment-sharing): bring the sharing table onto the list-page scale

Adds the shared block: density tokens, the table surface, row hover/selection,
the status pills, the pager and the button scale.

The Status column already routes through `ga-status-badge` (see `statusMapper()`
in the component), which is exactly what the block's tint rules address — the
saturated fill with white text becomes the accent-on-tint treatment the rest of
the app uses, and lands the accessible colours with it.

The action row takes the `nb-card-header` default, which also picks up the
"Equipment Sharing Policy" button sitting beside the title.

* style(equipment-sharing-policy): bring the policy table onto the list-page scale

Adds the shared block: density tokens, the table surface, row hover/selection,
the tag-chip stack, the pager's `<nav><ul><li>` and the toolbar scale — so this
table reads as the same surface as its neighbours.

* style(tags): bring the tags table onto the list-page scale

Adds the shared block. The action row takes the `nb-card-header` default, which
covers this page's `.gauzy-action` toolbar — it lays its header out itself,
beside the tag-type rail, and never adopted the shared container class.

The pager was the reason for the one template change: `<ngx-pagination>` was
rendered bare, with no `.pagination-container` around it, so it was one of three
pagers in the section the styling could not reach. Wrapping it matches every
other list page and the Job Employees reference. No bindings, handlers or
conditions touched.

The wrapper also has to opt out of growing: `.table-scroll-container` beside it
takes `flex-grow: 10` from this sheet, which would otherwise leave the pager
fighting it for the remainder of the card body.

* style(approval-policy): bring the approval-policy table onto the list-page scale

Adds the shared block: density tokens, the table surface, row hover/selection,
the tag-chip stack, the pager's `<nav><ul><li>` and the toolbar scale.

* style(departments): bring the departments table onto the list-page scale

Adds the shared block. The Members column renders `EmployeeWithLinksComponent`,
which draws through `ngx-people-list` — already built on the `gauzy-people-*`
tokens, so the density tokens this block puts on `:host` reach it across the
component boundary and the people cells come onto the row's scale for free.

* style(teams): bring the teams table onto the list-page scale

Adds the shared block. Members and Managers both render
`EmployeeWithLinksComponent` and the Tags column renders `TagsOnlyComponent`, all
three of which already read the shared tokens — so the density block on `:host`
carries them onto the row's scale across the component boundary.

* fix(projects): pass the content column's height through the routing shell

This component is the /pages/organization/projects shell: its whole template is a
`<router-outlet>`, and the list, create and edit pages render into it.

`styles/_overrides.scss` hands the routed page the viewport-bounded height the
content column already has — but with `nb-layout-column > router-outlet + *`, a
DIRECT-child selector, so on a nested route it lands on THIS component and stops.
Below it the page had no definite height for `nb-card { height: 100% }` to
resolve against, the card collapsed to its content, and the table inside came out
short of the card, taking its scrollbar with it.

NOT fixed with `:host { display: flex }`, which is the obvious way to hand a flex
height on and cannot win: `pages.component.scss` carries
`:host ::ng-deep router-outlet + * { display: block }`, which shims to
`[_nghost-pages] router-outlet + *` — (0,1,1) — and reaches every routed
component in the app. A component's own `:host` is `[_nghost-x]`, (0,1,0), and
loses. This host stays `display: block` whatever this file says.

What the host DOES get from that same `_overrides` rule is `flex: 1 1 auto` — a
different property, so nothing overrides it — which makes its own height
definite. A percentage resolves against that perfectly well and, unlike a flex
child, does not care what the parent's `display` ended up being. So the routed
page simply takes 100% of the shell.

`::ng-deep` is required and is not a leak: the routed component is a DOM sibling
of `<router-outlet>` but belongs to a different component, so it carries none of
this template's `_ngcontent` attribute and a plain `router-outlet + *` would shim
to a selector matching nothing. Leading with `:host` keeps the rule inside this
shell, and setting no `display` keeps it clear of the rule above.

* style(projects): bring the projects table onto the list-page scale

Adds the shared block, with the action row on the `nb-card-header` default — this
page's toolbar sits in a bare `<div class="d-flex justify-content-end">` with no
container class of its own.

Three cells hard-code a box the tokens cannot reach, all scoped to the table
because `ga-card-grid` renders the same components for the card layout:

  * Project — `gauzy-project-organization` draws a 28px logo beside a 14px/17px
    name and an 11px/13px caption, the single biggest reason a row here stood
    taller than the same row on Employees. Sized off the shared `gauzy-people-*`
    tokens so the project chip matches the people cells beside it.
  * Contact — `ngx-contact-links` draws an 18px avatar and 12px/15px text inside
    a pill padded 5px/14px. Same scale-down; the pill keeps its tint so the cell
    still reads as a link.
  * Public/Private — an `nb-toggle`, which Nebular sizes entirely from theme
    tokens: a 50x30 switch with a 28px knob and a label on a 1.5rem line, i.e.
    twice the row's whole line box. Re-tokenised to 20x12, which keeps Nebular's
    own 1.667 proportion so it reads as the same control drawn smaller — the same
    numbers Employees gives its Screen Capture column.

Also records why the card fits: this page is reached through a routing shell, so
the height chain is repaired in the shell's own stylesheet, which is the only
place it can be.

* fix(inventory): pass the content column's height through the routing shell

This component is the /pages/organization/inventory shell: its whole template is
one wrapper around a `<router-outlet>`, and the items table, the item form and
the item view render into it. It carried no stylesheet at all.

`styles/_overrides.scss` hands the routed page the viewport-bounded height the
content column already has — but with `nb-layout-column > router-outlet + *`, a
DIRECT-child selector, so on a nested route it lands on THIS component and stops.
Below it the page had no definite height for `nb-card { height: 100% }` to
resolve against, the card collapsed to its content, and the table inside came out
short of the card, taking its scrollbar with it.

NOT fixed with `:host { display: flex }`, which is the obvious way to hand a flex
height on and cannot win: `pages.component.scss` carries
`:host ::ng-deep router-outlet + * { display: block }`, which shims to
`[_nghost-pages] router-outlet + *` — (0,1,1) — and reaches every routed
component in the app. A component's own `:host` is `[_nghost-x]`, (0,1,0), and
loses. This host stays `display: block` whatever this file says, and a
`flex: 1 1 auto` on the wrapper would have nothing to grow inside.

What the host DOES get from that same `_overrides` rule is `flex: 1 1 auto` — a
different property, so nothing overrides it — which makes its own height
definite. So the wrapper takes 100% of it rather than trying to be a flex child
of a box that is not a flex container, and the routed page grows inside the
wrapper, which IS one.

`::ng-deep` is required and is not a leak: the routed component is a DOM sibling
of `<router-outlet>` but belongs to a different component, so it carries none of
this template's `_ngcontent` attribute and a plain `router-outlet + *` would shim
to a selector matching nothing. Leading with `:host` keeps the rule inside this
shell, and setting no `display` keeps it clear of the rule above.

* style(inventory): bring the shared inventory-table sheet onto the list-page scale

This file is taken by five tables — product types, product categories,
merchants, warehouses and the inventory items table — so the shared block lands
once for all of them: density tokens, the table surface, row hover/selection, the
tag-chip stack, the pager's `<nav><ul><li>` and the toolbar scale. They are the
same card with the same `.table-scroll-container`, `.pagination-container` and
`.gauzy-button-container` five times over.

Scoped to the table on top of that:

  * The Image / Logo / Icon column — `ImageRowComponent` (74x60),
    `ItemImgTagsComponent` (70x64) and `IconRowComponent` (a 35px disc) keep
    their sizes; only the cell padding around them goes back to the pre-density
    default, since a picture sets the row height whatever the text does.
  * Name and description — both renderers hard-code a 14px/17px name over a
    12px/15px caption, so this one column read a step louder than every other
    column in the row. Pointed at the table's own sizes, which is a match rather
    than a reduction. The 4px between the two lines is left alone.
  * Active / Inactive — `EnabledStatusComponent` emits a bare
    `<div class="badge badge-success">`, so the only thing painting it was
    bootstrap.css: a saturated fill with white text, and the inherited
    `color-success-default` measures 2.9:1 on white. Its counterpart was a literal
    `#ccc` with whatever text colour it inherited — the one chip in these tables
    that ignored the theme. Both now take the accent-on-tint treatment
    `ga-status-badge` gets everywhere else.

* fix(inventory): let the items card fill the content column

Removes `:host nb-card-body { height: calc($card-height - 0.5rem) !important }`.

`gauzy/_gauzy-cards` already gives the body that same chrome estimate, but as a
FLEX BASIS — `flex: 1 1 auto` then grows or shrinks it to whatever the card
actually has, so the estimate being a guess never mattered. Restating it at page
level with `!important` turned a starting point into a floor and a ceiling.
/pages/tasks/dashboard, which fits, pins that height only on its `.project`
variant; with none selected its body is a plain flex child, and that is the whole
difference between the two pages.

With the line gone this page's compiled card and body rules are identical to that
reference, byte for byte.

The other half of the fit is not in this file: the height chain has to reach this
page at all before `nb-card { height: 100% }` can resolve, and that is repaired in
the routing shell.

* style(inventory): bring the warehouse product tables onto the list-page scale

The warehouse's own product table and the product picker it opens are the same
surface as the page behind them, so both take the shared block, the thumbnail
cell padding, and a `.pagination-container` around their pagers.

Both rendered `<ngx-pagination>` bare, which is why their `<nav><ul><li>` was out
of reach of the styling; wrapping it matches every other list page. No bindings,
handlers or conditions touched. Both wrappers also opt out of growing, since
`.table-scroll-container` beside them takes `flex-grow: 10`.

The action rows differ and are named rather than left at the `nb-card-header`
default: the products table has no card header at all — it IS a card body — and
puts its toolbar in a bare `<div class="d-flex justify-content-end">`, so the
button component itself is the one stable handle, and the tokens are custom
properties that inherit into its template from there. The picker has no action
row at all, so it passes `null`.

The picker's select cell keeps its literal `height: 79px`: `display: flex` takes
that `td` out of the row's own height resolution, so it has to state a height or
the checkbox stops lining up with the data beside it, and 79px is the 74x60
thumbnail in the next column — which this change leaves at 74x60.

* style(inventory): bring the variant tables onto the list-page scale

The variant table in the item editor and the variants table on the item view are
the same surface as the inventory list they hang off, so both take the shared
block. Neither has a pager, but the block is the whole contract and splitting it
per page is how the list pages drifted apart in the first place.

Scoped to the table on both: the Image column is `ImageRowComponent`, which keeps
its 74x60 photo and gets back the pre-density cell padding around it; the Enabled
column is `EnabledStatusComponent`, whose `.badge-success` was painted only by
bootstrap.css — a saturated fill with white text at 2.9:1 — and whose disabled
counterpart was a literal `#ccc`.

The editor's table names `.gauzy-button-container` for its action row; the item
view has no action row and passes `null`.

* style(tabset): draw both tab families from one definition

Nebular ships two components for one control — `nb-tabset` switches a panel,
`nb-route-tabset` switches a route — and every sheet in this repo that dressed a
tab strip had named only the route one. So the two were drawn differently
wherever both appear in the same app: the route strips (organization edit,
employee edit) took a 16px box and stacked their icons above the label below
`dsk`, while every `nb-tabset` (Employment types, Time off, Candidates, Jobs,
Inventory) kept the theme's own box and a 0.5rem inline icon gap.

The body of that rule becomes `tab-strip($strip)` and is included for both, so
they cannot drift again. The strip class is a parameter rather than a bare `ul`,
which would also catch a list inside a tab's CONTENT and strip its indent.

Also restores the keyboard focus ring the `outline: none` on `.tab-link` took
with it. That line is there to drop the ring a mouse click leaves behind, and it
removed the keyboard's too — on the one control a keyboard user moves through
with arrow keys. `:focus-visible` gives it back to both families without
bringing the mouse ring back.

* style(tabset): centre nb-tabset labels and stop shouting route tabs

The same oversight as the sheet before this one, in the two places that dress a
tab strip from the theme side.

`nb-tabset-overrides()` centred a tab link's contents for the route family only,
so an `nb-tabset` link took Nebular's own `display: flex` and nothing else and
its label sat wherever the box put it, while a route tab's was centred in the
same box. The icon-gap reset was route-only for the same reason: Nebular gives
the label a 0.5rem inline margin that would be added to the 10px gap
`includes/_tabset.scss` sets on the icon. Both rules now name both families.

And the reverse case in the themes: Nebular ships UPPERCASE for BOTH families
(`styles/themes/_mapping.scss`) and only the `nb-tabset` half of it was ever
overridden here, which is why an `nb-route-tabset` read MAIN / LOCATION /
SETTINGS while every `nb-tabset` in the app read its tabs in sentence case.
`route-tabset-tab-text-transform: capitalize` alongside the existing key, in
both registered themes — the same fix, and the same shout, the ghost-button line
below it describes.

* style(tabset): keep the action row on the strip's box below dsk

The action row that sits ON a tab strip is boxed to match the strip, and it
matched only at the default breakpoint: below `dsk` the strip takes the 16px box
from `styles/includes/_tabset.scss` and this row kept the theme's line height,
so it was no longer the strip's own row there.

Both families now take that 16px box — that sheet stopped naming only the route
one — so this row follows it at the same breakpoint. The inline padding stays
zeroed, as it is at the default size: the row is positioned by the strip's own
end, not padded away from it.

* style(table-hub): add the list-page contract for hand-built card tables

`list-page()` anchors every selector that matters on the `angular2-smart-table`
element, so it never reached the five pages under `/pages/organization` that
build their table BY HAND — Vendors, Positions, Employee levels and Documents
share one stylesheet, Employment types has its own. What that looked like:
full-size toolbar buttons beside the compacted ones on Departments and Teams,
rows better than twice the height, and a stack of separately floating cards
where the rest of the section has one bounded surface with rows on it.

`card-list-page()` takes the three pieces of `list-page()` that are NOT anchored
on the smart table — the density tokens, the toolbar scale, the table surface —
and adds the one thing these rows need that a `<tr>` never did: they arrive as
`nb-card > nb-card-body.custom-table`, carrying a card's background, radius,
ring and bottom margin, all of which is flattened onto the surface and replaced
with one inset hairline per row (a single-column list has neither aligned
columns nor anything else to give it structure).

Hover and selection come with it, for all five pages rather than the four that
share the Vendors sheet. `expense-categories` gives both states the same
`rgba(50, 50, 50, 0.03)`, so hovering looked exactly like selecting and 3% of
black over a near-black card is nothing at all — no visible selection on the
four dark themes. Selection also arrived as a 6px border that exists only when
selected, so the row jumped sideways on click. Now: the neutral tints, primary
on the leading edge, and a transparent edge of the same width at rest.

Two constraints are load-bearing and noted at their rules. The row paint is
deliberately `:host .custom-table`, the same specificity `expense-categories`
uses, because the hover and selection rules sit one pseudo-class above it —
anything stronger would out-rank them and paint every hovered row transparent;
that also means the mixin MUST be included after the `@forward`. And the surface
takes `min-height: 100%` rather than `flex: 1 1 auto`, since two of the five put
this container in a card BODY that is not a flex column.

The row gutter is a parameter: the default suits a row holding one value across
its width, while a row whose markup is a Bootstrap `.row` needs at least the
15px that grid bleeds past its container with negative margins.

* style(vendors): move the column titles onto the table surface

Four pages `@forward` this sheet — Vendors, Positions, Employee levels and
Documents — and all four build their table as a stack of `nb-card` rows, so they
take `card-list-page()`. The gutter is passed as 1.125rem: a row here is a
Bootstrap `.row`, which bleeds 15px past its container through negative margins,
so at the default 0.4375rem the first cell would hang 8px off the left edge of
the surface. The local hover/selection block goes with it — it is now in the
shared mixin, in logical properties, and reaches Employment types too.

Vendors is the only one of the four with column titles, and they were a filled
card-2 band in the CARD HEADER: a second surface floating 10px above the rows
with its own rounded corners, and one that could not line up with the values it
titles, since it sat in a container with 1rem of padding while the rows sat in
one with 18px. The `pl-3` on the first title was there to paper over exactly
that difference.

They are now the first child of the scroll container, boxed exactly like a row —
same padding, same 3px rail reserved on the leading edge for the selection
border — so the titles sit over their columns by construction rather than by
nudging, and `pl-3` comes off. The treatment is the one `list-page()` gives a
smart table's `thead`: no fill, the muted heading colour, one hairline
underneath, and `position: sticky` so it stays while the rows scroll under it —
which is why the fill has to be the opaque surface colour rather than none.

The `@if` on the layout enum moves with the block; it guarded the titles in the
header and still guards them in their new home.

* style(employment-types): bring the hand-built table onto the list-page scale

The fifth of the hand-built tables, and the one that does not share the Vendors
sheet — so it had neither the list-page scale nor any row hover or selection at
all, only what `expense-categories` gives it. Rows were separately floating
cards on the tab body, and the toolbar sat at full size beside the compacted
buttons on Departments and Teams.

The action row is the one thing this page positions itself: it does not sit on
the title line like the rest of the family, it sits ON the tab strip
(`tabset-action-row`), so the toolbar scale is asked for by name —
`.gauzy-button-container` — rather than taken on `nb-card-header`, where this
page has no buttons at all. The `+ Add` button in the template takes `.action`
with the rest of the section, so it is picked up by that scale.

* style(organization): compact the add button on the remaining card-list pages

Positions, Employee levels and Documents take the Vendors sheet and therefore
the toolbar scale that came with it, but their `+ Add` buttons were never
classed `.action`, so the scale had nothing to select and they stayed at full
size beside the compacted ones on Departments and Teams. One class each; no
bindings, permissions or handlers touched.

Documents also had its file timestamp set at `text-caption-font-size`, which is
a step ABOVE the row's own size rather than under it — the secondary value was
rendering larger than the file name it hangs off. It now takes
`--gauzy-table-font-size`, the size the shared card-list block sets the row at.

* fix(company-logo): slug the brand class and fall back to a generic glyph

The company name was interpolated into the class list as it stood
(`'fab fa-' + rowData?.name | lowercase`), which is only a class at all for a
single-word name. "Ever Technologies LTD" produced THREE classes — `fa-ever`,
`technologies` and `ltd` — so any company whose name happens to contain a word
the page styles (`selected`, `primary`, `action`, `row`) was styled by it. The
name is now slugged to one class whatever it contains: lower case, ASCII, hyphen
separated, which is also the form Font Awesome's own names take, so "Pay Pal"
finds `fa-pay-pal` where before it looked for `fa-pay`.

The bigger half is that Font Awesome only ships a logo for companies that ARE
brands, and this table lists a tenant's own vendors — so for most rows `fa-<name>`
matched nothing and the cell drew a blank. FA still gives every icon element a
fixed 1.25em box, so the column paid for an icon it never showed, and the rows
that did match a brand were the only ones with anything on their leading edge.

FA 7 draws its glyph from a custom property (`content: var(--fa)`, set by the
`fa-<name>` class), and custom properties inherit — so a value declared on the
HOST is what the `<i>` gets when no `fa-<name>` class set one, and is overridden
by the class whenever one did. A generic building for a company Font Awesome has
never heard of, with no branch in the template and no list of which names it
knows. The font stack gains the free face behind the brands file, since that is
where the fallback glyph lives, and weight 900 to reach its solid face — harmless
to the brands file, which ships one weight and is matched whatever is asked of
it.

Sizing follows the row rather than a fixed 18px/21px, and the 0.75rem of padding
on all four sides — the better part of a centimetre around a one-em glyph, and
for every logo-less row the only thing there at all — becomes the one gap an icon
column needs, to the name beside it.

* style(inventory): put the section buttons on the title's line

Merchants / Warehouses / Product categories / Product types sat in a nested
`.card-header-title` block of their own, which the outer row's
`align-items: center` then centred against the TWO-line title block — the
heading plus the breadcrumb trail `ngx-header-title` parks under it — dropping
them ~12px below `+ Add`. The heading and the buttons are now direct siblings of
ONE title row, the shape Employees and Equipment already use, so they share the
title's line and therefore `+ Add`'s.

They stay wrapped in a single block so the row's `justify-content:
space-between` (`styles/_overrides.scss`) pushes the four of them to the row's
end as a group, rather than spreading them evenly across it as four separate
flex items would be.

That block is flex, not a plain box: as inline-blocks the buttons sit on a
baseline and the descender space under it makes the box a few pixels taller than
they are — the same offset this change removes, in miniature. The two
declarations that settle the vertical axis are `align-self: flex-start` and
`min-height: gauzy-page-title-row-height`, the pair `_overrides.scss` already
puts on `.ga-page-header-actions` beside it: centring alone is not enough while
the two blocks are centred in different boxes, so both are anchored to the row's
top edge in the same box and share one centre by construction.

`flex-wrap` folds the group onto a second line on a narrow viewport rather than
squeezing the labels, and `min-width: 0` lets it shrink at all.

* style(tags): size the columns, the name chip and the filter rail

The four column widths are a RATIO the library hands to the `<th>`s, so they
have to add up to the table: 20/20/70/10 came to 120%, which is why Description
alone took better than half the row and the other three were squeezed into what
was left. They now total 100. The Name column's `class: 'text-center'` goes —
`class` is not one of the library's settings and was dropped on the floor;
`classHeader` and `classContent` are the real hooks, and the Counter and Tag
type columns use them.

Counter is right-aligned with tabular figures: a count belongs on its units
digit, where "3990" and "1890000" no longer start in the same place, and Inter's
proportional default figures otherwise wander a pixel or two per row. Its
heading follows, via `justify-content` rather than `text-align` — `a.sort` is
laid out `row-reverse`, so the end of its main axis is the left edge. Six-figure
usage counts are common here and are now grouped by the browser locale. Tag type
and Description fall back to an em dash and Tag type takes the table's muted
colour, so the row's three values stop competing at equal weight and a blank
column reads as deliberately empty rather than as a table that failed to load.

The name chip was `width: 100%` — ten saturated full-bleed bars of identical
length down the left edge, whether the label was "VIP" or "dependencies". Sized
to its text the colour is an accent again and the names line up under the "Name"
heading. `nb-badge` sets `position: absolute` on its own host, which is what
forced the wrapper to state a height for a chip contributing none; one
`position: relative` puts it back in flow so the wrapper can align it like any
other child.

The rail: chips take the control step of the radius scale rather than a 20px
stadium — the one shape that matches nothing around it — and so do the search
field and the badges. A chip may now wrap, since "Organization Department" is a
type Gauzy ships and a chip that could neither wrap nor shrink was wider than
the 230px rail, cut off by the card edge and dragging a horizontal scrollbar in
with it. The rail's type drops to 0.75rem throughout, the size of the table
headings beside it, so a filter stops out-typing the rows it filters; the
section label was the largest type on this half of the page and sat over the
smallest controls on it.

The search input opts out of the app-wide `min-height` floor that
`text-inputs-overrides` puts on every bare `input`: it was drawn ~10px taller
than the pill around it, invisible only because the field is transparent, but
the click target and the caret were both outside the box.

And the chips are reachable: they are the page's only filter control and plain
`nb-list-item`s, so they get `role`, `tabindex`, Enter/Space, `aria-pressed` and
a focus ring. The selected one gains an accent inset ring — the tint alone is
one step off hover and the resting chip already carries a neutral hairline, so
which of a dozen chips is filtering the table has to be findable from across the
page.

Rows get their air back locally: `density-tokens()` sizes a row for a line of
text and this table is four short single-line values and a chip, so nothing ever
pushed a row past that floor. The cells take the app-wide default instead, the
same value `thumbnail-row-padding()` hands the picture tables, with the heading
row raised to match. Scoped to this page — widening the shared token would
relayout two dozen tables nobody asked about.

* style(teams): size the columns and give the avatar rows their own rhythm

Set explicit column widths (20/30/30/20) so `table-layout: auto` stops sizing
the two people columns off their widest cell and holding the space as empty,
and drop the inert `class: align-row` the library never reads.

Step the row padding to 0.4375rem: an avatar fills its line box edge to edge
where a glyph fills about two thirds of it, so the shared text floor left the
discs 3px from the rules above and below them.

Zero the `ga-only-tags` block gap in the Tags column, which has chips and no
text above them for that gap to separate.

* fix(ui): address review findings on the tags, vendors and tabset polish

Keep the counter heading on its column under RTL. `justify-content` resolves
against the flex container's own direction, so `flex-start` on the `row-reverse`
sort link swung left while the cells stayed physically right. Overridden with
`:host-context([dir=rtl])` rather than `nb-rtl()`, which expands to `[dir=rtl] &`
and would ask for a `:host` nested inside another element.

Give the tab strip's `dsk` box a single source of truth. `px2rem(16px)` was
hand-copied into `_tabset.scss` and `_tabset-actions.scss`, where the action row
has to match the block value to stay vertically centred on the tab labels — the
exact drift the mixin's "WHY THIS EXISTS" note was written about. Both now read
`$tabset-dsk-tab-padding-*` from `var.scss`, which both sheets already use.

Close the vendors table surface at the top. `surface()` draws its outline with
`box-shadow: inset`, which is part of the element's background layer, so the
opaque sticky head painted straight over the top line and the leading edge. A
real border is geometry rather than paint: it sizes the content box the head is
laid out in, fixing top, leading edge and corners at once and mirroring under
RTL on its own.

Correct the `class` rationale. `class` IS declared on the library's `IColumn`,
which is why a dozen tables pass it without a type error, but `Column` never
reads it and it reaches no element — the effect claim was right, the reason was
not.

Correct the documents timestamp fallback and its reasoning. The row is
0.8125rem, so the previous `text-caption-font-size` was already smaller, not
larger; the fallback now sits below the row size instead of mirroring it.

Model the tag-type rail as a radiogroup. The options are mutually exclusive, so
`role="button"` + `aria-pressed` announced them as independent toggles and never
said the previous choice had been cleared. Now `role="radio"` + `aria-checked`
in a `radiogroup`, with one tab stop and arrow/Home/End navigation.

Reword the comment wording CSpell rejects.

* fixed the spell error

* fix(ui): clear the SonarCloud findings on the polish branch

Reliability, which is what failed the quality gate:

The Font Awesome stack in the company-logo cell ended on two names that
are not web-safe, so it had no last resort at all; it ends on a generic
now. And the slug trim's `/^-+|-+$/g` had two ambiguous quantifiers for
the engine to backtrack over — redundant ones, since the replace above
it has already collapsed every run of non-alphanumerics into a single
hyphen, so at most one can sit at either end. `/^-|-$/g` is the same
output with nothing to retry.

Maintainability:

`String(name ?? '')` took an `unknown`, so a non-primitive stringified
to `[object Object]` and slugged into a class of its own; it now takes
the value only when it is a string or a number.

Three sheets reopened a selector they had already written. Tags needed
the care: its second `:host` widens two density tokens that
`list-page()` sets on `:host`, so merging upward would have let the
shared value win — the include moves above the block instead, and the
tokens sit at the top of the rule rather than after the nested
selectors, which Sass deprecates. Vendors' stray `padding-block` moves
in beside the shorthand it overrides, and the projects shell's second
`:host` folds into its first.

* fix(ui): keep the tag filter rail reachable, and the vendor chips inside their cell

The rail is a radiogroup, so its tab stop is the checked radio — and a
refresh used to take that radio away. `getTags()` emptied
`filterOptions` down to "All" and `getTagTypes()` refilled it a request
later, so for the length of that request `selectedFilterValue` named a
type no rendered option carried: every radio went `tabindex="-1"`, and a
keyboard user whose option had just been re-rendered away had nothing
left to Tab back into.

Three things, because the window was only half of it. `getTagTypes()`
now builds the whole list and swaps it in one statement, so the rail
never renders a state the selection does not match, and `getTags()` no
longer touches it. The `@for` tracks `option.value` rather than the
option object — the old identity tracking rebuilt every radio on a list
of fresh literals, focused one included, which is what actually dropped
focus to the document. And the tab stop falls back to the first option
when nothing is checked, which is what the radiogroup pattern asks for
and covers the cases where the selection really has gone: an
organization switch, or a tag-type fetch that failed.

Elsewhere: a vendor tag longer than its two-column cell kept its chip
wider than the cell and ran it out through the side of the table, since
the chips may not wrap; they stop at the cell's edge with an ellipsis
now. And the specificity noted in the Teams tag-cell comment was (0,2,5)
for a selector that is (0,2,4) — the override still wins, but the count
was wrong for anyone extending the rule.

* fix(tags): drop a superseded load's response instead of applying it

The refresh generation gated whether the second load STARTED, which is
not the same question as whether its answer is still wanted. A
`getTagTypes()` that was current when it went out can be answered after
a newer one — two requests for the same resource settle in either order
— and on arrival it rebuilt the filter rail from the organization the
user had already left, then reconciled the selection against it.

Both loads now take the generation and check it again when their own
request comes home: a superseded response is dropped rather than
applied. `getTags()` gets the same treatment at its own network
boundary, since `allTags` and the pagination totals are what the
reconcile then reads, and leaving half the pipeline guarded would only
have moved the race.

Three details worth naming. `finally` runs on those early returns too,
so the reconcile and the spinner are guarded there rather than left to
fire for a pass that has been superseded — the spinner belongs to
whichever pass is current, and that pass always reaches its own
`finally`. A failure is still logged whatever its generation, because a
console line costs nothing and a swallowed error is hard to find later;
only the toast and the rail reset are held back. And the generation
parameter defaults to the current one, so a standalone call to either
method behaves exactly as it did.
2026-09-13 08:53:36 +02:00
joel kalema 33e3c09f30 Feat/hr dashboard redesign (#10125)
* feat(theme): add validated chart series tokens

The employee statistics charts painted with Nebular's raw status colours,
which are tuned for text rather than for large fills.

Adds gauzy-chart-* to the shared token maps, so every registered theme
carries its own steps for the four series, the negative state, the grid
and the tick labels. Merged through $gauzy-shared-light/dark, the same
path the action accents already take into all eight themes.

Every value was snapped to a step that clears all six checks of the
categorical palette validator against the surface the charts sit on:
the OKLCH lightness band for the mode, the chroma floor, all-pairs CVD
separation, the normal-vision floor and 3:1 contrast. Danger is reserved
for the negative state and so cannot double as the fourth categorical
hue; fuchsia is the step that clears blue under deuteranopia, where
violet and indigo both fail outright.

* refactor(dashboard): resolve chart palette from theme tokens

Reads the gauzy-chart-* custom properties off a live element instead of
Nebular's JS theme variables, so the charts follow the same steps the
rest of the page paints with and pick up a theme switch for free.

Falls back to the previous status colours when a token is absent or when
there is no host element, so nothing regresses outside a browser. Also
exposes the surface, border and primary ink the tooltips need to render
as a card on the theme's own ground.

* feat(dashboard): add shared employee chart options

The three employee statistics charts each spelled out their own legend,
tooltip, scales and mark geometry, which is how they drifted apart.

Collects that chrome in one module: a legend of small dots, a tooltip
painted as a card on the theme surface, a hairline grid on the value
axis only, and thin marks with rounded ends separated by a gap in the
surface colour rather than by an outline. Axis ticks are abbreviated so
a money axis stays on one line without rotating its labels, and the
chart's own animation honours prefers-reduced-motion, which no CSS
media query can reach.

The tooltip takes an optional value resolver because the number a mark
plots is not always the number it should report.

* chore(dashboard): export shared chart options from barrel

* style(dashboard): restyle employee horizontal bar chart

Adopts the shared chrome: grid on the value axis only, no axis borders
or tick spurs, and thin rounded bars with a surface gap in place of the
2px border each bar used to carry.

The container no longer pins itself to 20rem, a height that took no
account of the legend above the plot or the tick band below it. It now
fills the box the panel gives it.

* fix(dashboard): migrate stacked bar chart to Chart.js v4 options

The chart configured itself with v2 option names - legend, tooltips,
elements.rectangle and labels.fontColor - which Chart.js 4 silently
ignores. It therefore rendered with library defaults and never followed
the active theme at all.

Moves those onto plugins.* and the shared chrome, and formats tooltip
values as currency the way the sibling charts do.

The tooltip reports the unscaled figure rather than the plotted one:
this chart normalises each month's segments so the stack sums to that
month's income, so the plotted value is a share and printing it as money
would state something untrue.

Also drops dead code. selectedDate and proportion were declared as
fields and never assigned, so three dataset label ternaries had always
taken their else branch; proportion is now a local in the loop that
computes it.

* fix(dashboard): rework employee doughnut chart layout

The slice labels were the full "Revenue: $12,450.00" strings, and a
doughnut builds its legend from those, so four long entries wrapped
across the top of the chart and squeezed the ring. The tooltip then
echoed the same string back, overriding the currency formatting.

Moves the legend to the right, where a circle in a wide box has room to
spare, and caps its width so a long amount cannot push the ring into a
corner. Slice labels are names only; the amount is appended by the
legend itself and the tooltip formats its own.

Types the chart configuration as 'doughnut'. Bare ChartConfiguration
resolves to the union over every registered chart type, whose options
are only those common to all of them, so arc-only settings such as
cutout and hoverOffset are not known properties there.

Removes the tooltip helper that only echoed the slice label, along with
four now-unused locals and a commented-out span.

* refactor(dashboard): make employee charts fill their host panel

The component rendered its own chart-type switcher right-aligned above
the canvas, which on the HR dashboard produced a second header-ish row
directly beneath the panel's real header with nothing to anchor it to.
selectedChart becomes an input, defaulted so the component still renders
standalone, and the host owns the control.

Also fixes the sizing. align-items: center on a flex column is the cross
axis, so the chart shrank to its intrinsic width and floated in the
middle of the panel instead of spanning it.

* refactor(dashboard): rework HR dashboard component state

Formats every money figure once, on load, instead of repeating a ternary
plus two pipes per figure in the template.

Fixes averageBonus, which the header has always had a row for but which
was declared and never assigned, so the row could not render. It is the
period total over the number of months the API returned.

The page also rendered nothing until the debounced selectors resolved.
loading now starts true and is released on the early return as well, so
the first paint is a spinner rather than a false empty state.

Adds the chart type the statistics panel draws, the initials shown when
an employee has no avatar, and the bonus formula the Total Bonus tile
puts on its tooltip - resolved here because a tooltip is an attribute
binding and cannot host control flow.

* feat(dashboard): restructure HR dashboard template

The page was a two-column split whose left column was one flat stack of
label/value rows, so the totals carried no more weight than their own
components and there was nothing to scan.

It now reads as identity, then the four totals, then what they are made
of beside the same figures over time: an identity bar naming the
employee the figures belong to, a KPI row of four tiles, and a Breakdown
panel next to the Employee Statistics panel, whose header carries the
chart-type switcher on the title's line.

A tile carries a label and a number; the arithmetic behind it moved to
its tooltip, since four tiles each printing two clamped lines of grey
formula read as a wall rather than a summary. Breakdown rows are single
line for the same reason.

Clickable figures are real buttons rather than click handlers on divs,
so they are reachable by keyboard and announced as actions, and each
names itself with its label and value. Adds empty states for no
employee selected, and decorative icons are hidden from assistive tech.

* style(dashboard): rebuild HR dashboard styles

Paints entirely with the shared gauzy-* tokens, so all eight themes and
both canvases follow without a per-theme branch.

Type weight is a three step scale, and only three. Nearly every element
on the page was 600 - the employee name, both panel titles, the group
headings, the KPI values, every figure - and when everything is semibold
nothing reads as emphasis. Hierarchy comes from size and the muted or
strong ink tokens instead, and weight is spent on the four headline
numbers alone.

The panel header's min-height is derived from the chart switcher it has
to hold, so the Breakdown and Employee Statistics headers line up and
their bodies start on the same line. The chart's height is set by the
panel rather than by the canvas, so plot, legend and axis labels live in
one measured area. Money figures use tabular numerals, breakdown
chevrons sit out of flow so values align in a column whether or not a
row is clickable, and the whole page respects prefers-reduced-motion.

* feat(i18n): add HR dashboard breakdown label

Names the panel that itemises what each headline total is made of.

* test(e2e): retarget HR dashboard selectors

The HR dashboard no longer renders ga-info-block, so the selectors that
found its cards matched nothing and every card assertion and click in
the suite would have failed.

Points them at the KPI tiles and breakdown rows that replaced it, and
excludes the Total Bonus tile, which is deliberately static because
there is no BONUS history type to open.

Records why the text filters are now unambiguous: each queried heading
renders in exactly one card, because the formulas that quote other
headings live on tooltips, which Playwright's hasText does not see.

* test(e2e): update HR dashboard page helper notes

Describes where the click handler now sits and why .first() resolves
unambiguously, replacing notes that described the removed info-block
markup and a DOM ordering the page no longer depends on.

* docs(dashboard): correct chart switcher references in widgets

Both files described the switcher as something ga-employee-charts puts
above its own chart. That control now lives in the HR dashboard's
Employee Statistics panel header, and the component only draws the
rendering it is handed.

* fix(dashboard): size employee charts from a positioned parent

With `maintainAspectRatio` disabled, Chart.js measures the canvas against
its offset parent. `.chart` was unpositioned in all three employee charts,
so the plot sized itself against an ancestor further up the tree and came
out small in the middle of the panel.

* fix(dashboard): drop amounts from the doughnut legend

The amounts were appended to each legend entry, producing four ragged
"name + figure" rows whose numbers did not line up — a legend cannot lay
out two columns. Every one of those figures is already stated twice on
the page, in the KPI tile and again in the Breakdown panel, and the exact
value is one hover away on the tooltip.

Series names alone need less room, so the legend's `maxWidth` cap comes
down from 200 to 160 and the ring keeps the space.

* fix(styles): raise chart label contrast to the AA floor

Axis ticks and legend entries are small text, so they owe 4.5:1. On light
they inherited the `gauzy-text-color-2` step, which measures 3.99:1 on
white; the replacement measures 6.08:1. On dark they were a 55% white
step that read as grey against the canvas, now full white.

* style(dashboard): rebuild the HR KPI tiles on flex

The tiles were a three-column grid with the glyph and chevron each
spanning both text rows. Replaced by one flex row — glyph, a label-and-
value block, chevron — which is what the layout always was. The new
`.kpi-text` wrapper carries `min-width: 0`, so a long figure shrinks in
place instead of pushing the chevron off the tile, and the chevron pins
itself to the trailing edge with `margin-inline-start: auto`.

Accents now read the `gauzy-chart-*` tokens the charts paint with, so a
tile and its slice are the same colour by construction. They were not:
`--info` and `--accent` both resolved to the info step, making Profit and
Bonus identical on the tiles while the chart drew Bonus in fuchsia. The
tint fill behind each glyph goes with them — the tile already has its own
surface and hairline to sit on.

Also flattens the page's type scale to 0.75rem and stretches the panel
row so the breakdown and the chart share the taller height rather than
leaving a ragged bottom edge.

* style(dashboard): align history dialogs with the shared table hub

The records and profit dialogs opened from the HR KPI tiles carried their
own table metrics, so they read as a different object from every other
data table in the app. They now take density, surface, row treatment and
pager from `gauzy/_gauzy-table-hub`, the same partial the documents,
employees, invoices, payments, pipelines and tasks tables already use.

Drops the local 11px gutter on `.table-scroll-container`: the cells carry
their own horizontal padding and the card body supplies the outer margin,
so that gutter only pushed the surface outline a column-width off the
table it outlines.

`profit-history.component.scss` @forwards this file, so the profit dialog
picks all of it up unchanged.

* feat(dashboard): render employee chart legends in HTML

Chart.js lays each legend entry out as one run of text, so the doughnut's
"name + figure" entries came out ragged: four rows whose numbers each
started wherever the name before them happened to end. A canvas legend
cannot lay out two columns, and whatever width it claimed was measured out
of the same rectangle the chart draws in — which is why the ring needed a
maxWidth cap to stop a long currency string pushing it into a corner.

The new ga-employee-chart-legend renders the entries as DOM instead. The
amount is pushed to the trailing edge so every figure ends on one right
margin, and tabular-nums lines up the digits inside it. Being a CSS
sibling of the canvas, it divides the panel with the plot rather than
competing for it: the doughnut gets a box with the panel's full height and
inscribes itself in the shorter side, so the ring comes out as large as
the section allows and no larger.

The doughnut's amounts are restored to its legend, where they were before
the redesign. The stacked chart takes the same treatment for its fit, but
keeps names only: its bars are normalised so each month's stack sums to
that month's income, so a total printed beside a series name would not be
the quantity any bar is showing.

The horizontal bar chart still draws its legend on the canvas, so
employeeChartLegend stays — minus its position parameter, whose only
caller was the doughnut.

* fix(dashboard): restore the visible calculation lines on the HR page

ga-info-block, the component this page was built from, rendered its `meta`
input as visible text under every title. The redesign kept all ten of
those strings but moved each one onto a tooltip, so figures that used to
be readable at a glance were reachable only by hovering — and one of them,
the bonus percentage, appears nowhere else on the page at all.

They are body text again: kpi-meta under each tile's figure, stat-row-meta
under each breakdown label. The tooltips that carried them are dropped,
since a tooltip repeating text already on screen is noise. The identity
bar's edit button keeps its own tooltip: that icon has no visible label.

The salary row is left without one — it is new to this page and never
carried a formula — so its label stays a direct child of the row and takes
the flexible width itself.

* style(dashboard): drop the KPI tile formulas to 10px

Splits the shared meta rule so only the four overview tiles shrink. A tile
carries the same sentence in a quarter of a breakdown row's width, so it
wraps to three or four lines where a row takes one; at 11px that turned
the KPI band into the densest thing on the page.

The leading comes down with the size. 1rem of it under a 10px face reads
as separate paragraphs rather than one wrapped sentence.

* fixed the spell error

* fix(dashboard): rebuild the doughnut legend when the organization changes

The tooltip formats its amounts in the callback, so it picks up a new
organization's currency the moment the store emits. The legend does not:
its amounts are strings built the last time the dataset was. Between an
organization change and the arrival of that organization's statistics,
the two disagreed — the legend still printing the previous currency
beside a tooltip already using the new one.

Rebuild the dataset in the organization handler so both switch together.

* fix(dashboard): reach the HR empty state and track traits by position

Two defects on the HR page.

`loading` was only ever cleared inside getEmployeeStatistics(), which is
reached solely through statistics$ — and that only fires downstream of a
filter requiring a truthy employee. With no employee ever selected the
spinner ran forever and the "no employee selected" state, gated on
`!loading`, could not render. Clear the flag before that filter, on the
settled emission that carries no employee.

The identity traits are the employee's short description and level, and
nothing stops those holding the same string, which gave the @for two
identical keys and threw NG0955 instead of rendering the chips. Track
them by index.

* test(e2e): match HR cards on their label, not their whole body

The note on infoBlockCss claimed each card's arithmetic sat on an
nbTooltip, where Playwright cannot see it, so every queried heading
matched exactly one card. That has not been true since the formulas
became body text: the Profit tile prints "Profit (Net Income) = Total
Income X - Total Expenses Y", so a whole-card hasText matched it for
both the "Total Income" and the "Total Expenses" query. The clicks only
landed correctly because .first() happened to return the DOM-first tile.

Scope the filter to the label span, where each of the four headings
identifies one card, and correct both comments.

* chore(cspell): restore alphabetical order in the words list

"deutan" sat after "devkit", "Gridlines" after "Guazy", and
"uppercasing" among the "un" entries. Move each to its own place.

* fix(dashboard): average the HR bonus over the selected range

The divisor came from `employeeStatistics.length`, but the aggregation
endpoint returns a row only for months that hold records — so a range
containing an idle month averaged over its ACTIVE months and overstated
"Average Monthly Bonus". Count the calendar months the picker asked for
instead, both ends included.

* refactor(dashboard): inject the employee chart dependencies

The three employee charts took every dependency — including the newly
added ElementRef — through constructor parameters, against the repo's
Angular rule to prefer `inject()`. TranslateService drops out entirely:
TranslationBaseComponent takes it optionally and injects it itself, so
`super()` covers it. The empty constructors stay because a component
that inherits a parameterised constructor from an undecorated base does
not compile.

* test(e2e): locate the HR chart switcher by its class

The redesigned switcher declares no `placeholder`, so the old
[placeholder="Chart"] locator matched nothing and the chart-options
scenario timed out. Use the `.chart-switcher` class the panel header
gives it — the same nb-select host the attribute sat on, so the click
target is unchanged — rather than the translated aria-label, which would
tie the locator to the active language.
2026-09-11 08:54:45 +02:00
joel kalema 1f69869be0 Fix/polish organizations pages (#10140)
* fix(candidates): give the candidate profile its own stylesheet

`edit-candidate-main.component.ts` listed the Organization > Main tab's
stylesheet in its own `styleUrls`, so every selector in that file was compiled a
SECOND time carrying this template's `_ngcontent` attribute. The two templates
share no markup — only the file — which meant the organization tab could not be
restyled without silently restyling the candidate profile with it.

Copy across the rules this template actually renders: `.content`, the
`.organization-container` panel with its `.organization-photo` children (the
overlay `div`, the 100px `img`, the file `input` and the 68px `svg` placeholder),
the `.row .col` halving, and both responsive blocks.

Left behind, because this template renders none of them: `.main-form` (this one
uses `.employee-form`), `.employees-count-text`, `.tax-form-group`,
`.registration-form-group`, the `ga-currency` and `nb-select` overrides, `.col-6`,
and `.content.main` — the candidate's `.content` carries no `main` class.

Two declarations are dropped rather than copied, both no-ops here:
`width: min(563px, 100%)` on `.organization-container`, which this template
already overrides with bootstrap's `w-25` (`width: 25% !important`), and
`margin-left: 0 !important` on `.organization-photo`, which existed to cancel an
`ml-4` that only the organization template carried.

* fix(organizations): repair the edit-organization header content

Three things were wrong with what the card header rendered.

The headcount ran through the `json` pipe — `{{ organization?.totalEmployees |
json }}`. On a number that is a no-op, but `JSON.stringify(null)` is the STRING
"null", so an organization whose count had not been computed yet announced
"null Employees". It reads the value directly now, falling back to 0.

The logo was an unguarded `<img>`, so an organization with no image got the
browser's broken-image glyph and the alt text sitting in the header. Guard on
there being a URL and fall back to a briefcase icon, the same shape the employee
page uses for its own missing avatars. `logoFailed` also covers the case where a
URL is present but fails to load.

The name and the metadata beneath it were both plain lines of body text, so the
organization read no louder than its own headcount. Name and supporting copy are
now separate rows, with the count joined by official name and currency.

`setOrganization` exists so both subscriptions clear `logoFailed` — without that
a single broken image would leave the placeholder in place for every
organization selected afterwards. A back button joins the header, matching the
employee edit page.

* fix(organizations): restyle the edit-organization page header

The contact meta read louder than the organization it described. `Edit Public
Page` was `display: flex`, so the control stretched the full width of the header
and read as a banner rather than an action, and the organization name was 14px
600 — the same size as the headcount line beneath it.

Lay the header out as one row: back button, logo, identity, then the action
pinned to the trailing edge. The name takes a heading size and the supporting
copy sits under it as a wrapping muted row, dot-separated. `Edit Public Page` is
a real `<button>` now with a resting fill and a hover state, and the logo
placeholder is sized to match the image it replaces.

The old `@include respond(sm)` block stacked `.header-container` into a column,
which put the avatar above the name; the replacement wraps the action under the
identity instead and keeps the avatar beside it.

This stylesheet is NOT scoped to one template — `accounting.component.ts` lists
it alongside its own, so every selector here is compiled a second time against
the Accounting page. Everything above is nested under `.card-scroll`, a class
the Accounting card does not carry (its `nb-card` is `.card`), so none of it can
reach that page. `.body-header` and `.sub-header` stay exactly where they were,
for the reasons already recorded above them.

* fix(organizations): import SharedModule for the header back button

`EditOrganizationModule` declares `EditOrganizationComponent`, whose template now
renders `<ngx-back-navigation>`. That component is declared and exported by
`ComponentsModule`, which reaches this module through `SharedModule`; without the
import the element would be parsed as an unknown tag and silently render nothing.

* fix(organizations): repair headcount, logo and save behaviour on the Main tab

Three defects in this component, none of them cosmetic.

The headcount was destructured out of `route.parent.data`:

    tap(({ employeesCount }) => (this.employeesCount = employeesCount))

but the parent route resolves only `organization` and `organizationTaskSetting`.
There has never been an `employeesCount` key to pick up, so the field stayed
undefined and the panel rendered a bare "Employees" with no number in front of
it. The count lives on the organization itself, maintained by the employee
subscriber, so read it from there.

Picking a logo threw you off the page. `updateImageAsset` called
`updateOrganizationSettings()`, which ends by navigating to the organizations
list — so choosing an image saved the form and then abandoned it. Split the save
in two: `saveOrganization()` persists and returns whether it worked,
`updateOrganizationSettings()` persists and then navigates. The upload path takes
the first, the Save button the second, which also means Save no longer navigates
after a failed request.

The form seeded `imageUrl` from the `imageUrl` column alone, while the card
header a few pixels above resolves `image.fullUrl || imageUrl`. An organization
whose logo came from an image asset therefore showed the logo in the header and a
placeholder in the panel. Both read the same expression now; `imageUrl` is a
disabled control, so this is display-only and never reaches the update payload.

* fix(organizations): rebuild the Main tab as an identity panel and a form

The left panel was a 563px box holding a 100px logo and one line of text, so
most of it was empty; the form beside it was a bootstrap `.row`/`.col` grid whose
fields did not line up, and the Save button was positioned by `margin-top: 60px`
with nothing to scroll when the form outgrew the tab.

Lay it out the way the employee Account tab is laid out: an identity panel and a
form panel, each a bordered flex column. The panel now carries the name, official
name, description, status chips and a facts grid — headcount, time zone, start of
week, date and time format, bonus rules — all guarded, so an organization with
none of them shows a header and nothing else. The logo gains a placeholder, a
hover scrim and a camera badge, so the upload affordance is visible whether or
not an image is set.

The form is a two-column CSS grid. Three fields used to size themselves
independently of it: `ga-currency` was `inline-flex` with a 180px floor and tax
ID was pinned to `width: 80%`, both of which left their columns visibly narrower
than the fields beside them. Tags spans the full row. The fields scroll and Save
sits in a bordered footer beneath them.

Two `::ng-deep` blocks were bare top-level selectors. Angular drops the scoping
attribute from those, so `ga-currency .form-group` and
`nb-select.shape-rectangle .select-button` were global rules applying to every
such element in the app, not just this tab's. Both are `:host`-scoped now.

The tab keeps `height: calc(100vh - 19.25rem)`, which is how the Location and
Settings tabs size themselves too; converting the shared tabset to a flex chain
is a change to all three, not to this one. What changes is that the panels now
scroll their own overflow inside it.

The panel fill was a literal `rgba(126, 126, 143, 0.1)`, which is one fixed grey
across all eight themes; it follows `--gauzy-card-3` now.

* fix(organizations): stop stranding the Settings tab at the far edge

The route tabs are laid out as a flex row, and the strip carried

    li:last-of-type { margin-left: auto; }

An auto inline-start margin on the final flex item absorbs all the free space
before it, so with three route tabs "Settings" was pushed hard against the
opposite edge of the card while "Main" and "Location" stayed together on the
left. The three are peers — one group of tabs — and they now sit as one;
where that group goes is `justify-content`'s job, which the `.full-width` rule
below already sets.

The tab padding was declared only inside three nested max-width queries: `dsk`
(<=1532px) at 20px, a bare `1440px` at 25px and `xxl` (<=1280px) at 20px. They
share a specificity, so the later ones won where they overlapped and the
horizontal padding ran 20 -> 25 -> 20 as the window got NARROWER, while anything
wider than 1532px matched none of them and fell through to Nebular's own
padding. Replaced with one base value, narrowed once below `xxl`.

* fix(organizations): make the settings section index usable

The aside listing the ten settings sections had three problems.

Its markup was `<ul><span (click)="general.toggle()"><li>…</li></span>…</ul>`,
repeated ten times. A `<span>` is not a legal child of `<ul>`, and a span with a
click handler has no role, no tabindex and no Enter/Space handling — the entire
index was unreachable by keyboard. Each entry is now an `<li>` holding a real
`<button>`, and the ten hand-written copies collapse into a `@for` over a
`settingsSections` array declared beside the accordion refs.

Clicking an entry called `toggle()`, so clicking the section you were already
reading closed it. An index into a page should open, not toggle.

Nothing scrolled. The accordion is a single column of roughly two thousand lines,
so opening a section below the fold — Task Setting and Integrations are ninth and
tenth, behind a five-hundred-line Timer section — changed something entirely
off-screen and the lower half of the index looked inert. `openSection` now brings
the section it opened into view.

The ten `@ViewChild` accordion-item fields go with the old markup: they existed
only so the template could call `general.toggle()` and read `general?.expanded`,
and nothing reads them once the aside addresses sections by position. Two
`@ViewChildren` queries replace them — one for the items, one for their elements,
which is what the scroll needs. The `#general` template refs stay on the items as
labels; the `#generalBody` refs they sit beside are still read by each body's
`[hidden]`.

The row styling moves from the `<li>` to the button, which means handing back the
font, colour, border and text alignment a `<button>` overrides by default, and
adding the focus ring the list never needed while it could not be focused. On
small screens the list was laid out by setting `display: inline` on the `<ul>`
and hanging a `margin-right` off each item; it is a wrapping flex row now.

* fix(organizations): repair the settings page layout

Four measurements on this page disagreed with each other.

`.main-form` declared `overflow: hidden` and, three lines later, `overflow-y:
scroll`, while `.accordion-section` inside it declared `overflow: auto`. Two
nested scrollbars ran over the same content, and because the outer one moved
everything, the section index scrolled away from the sections it points at. The
accordion column is the only scroller now and the aside stays put beside it.

The column widths were stated three times and did not agree: a 180px aside, a
`.fields-section` of `calc(100% - 230px)`, and `justify-content: space-between`
to absorb whatever was left. That 50px discrepancy was the gap between the index
and the sections. The aside is a fixed 180px track, the sections take the rest,
and a real `gap` separates them.

Save sat ABOVE the accordion — the control that commits the page placed before
any of the fields it commits, scrolling out of reach on a page this long. It is
the foot of the column now, pinned below the scrolling sections and bordered,
matching the Main tab. The small-screen `order: 2` that used to flip it to the
bottom goes with it, since it is last in the markup.

`.col-6` was capped at `max-width: 49% !important` against bootstrap's own
`flex: 0 0 50%`, so every pair of half-width fields rendered 49% wide with a 2%
hole between them that no gutter accounted for.

Stacked below `md` the whole column scrolls as one, as it did before — the aside
is a wrapping row of ten buttons there, and pinning it would leave almost nothing
for the sections. The aside's flex basis is reset to `auto` in that block: the
form is a column there, so the 180px basis that sets its width on desktop would
otherwise set its height.

* fix(organizations): rebuild the Location tab to match its sibling tabs

The last of the three edit tabs still laid out as a bare 40%/60% split held
apart by `justify-content: space-between`, with no panels, no scrolling and the
Save button pushed down by the same trick. Give it the two bordered panels the
Main tab uses: address fields on the left, map on the right, fields scrolling
with Save pinned in a bordered footer beneath them.

`ga-location-form` places its fields on the bootstrap grid at widths picked for a
full-width form — col-11 for the search and both address lines, col-8 for country
and city, col-6 for the postcode. In a panel this narrow that read as five
different field widths stacked on top of each other rather than one column, so
every field now takes the panel's width. Latitude and longitude stay paired.

`leaflet.component.scss` pins the map to `calc(100vh - 30rem) !important`,
measured against the viewport rather than the box it sits in, so inside a panel
it either left a gap below itself or pushed out past the bottom. It fills the
panel now. Because that changes the map's box, `_setLocationFormValue` calls
`invalidateSize()` — Leaflet caches the container size when it builds the map, on
its own timer, and the method's docs ask callers who resize its box to do exactly
this.

Three rules went out with the rewrite. `.content` named nothing in this
template. `order: 1` on the map put it after a form already ahead of it in the
markup. And `:host ::ng-deep input { background-color: background-basic-color-1 }`
fought the card shell's own `input-appearance(42px, var(--gauzy-card-1, …))`,
winning on injection order — which is why this tab's inputs were the only ones on
the page that did not match the Main tab's.

`:host.ng-star-inserted` is now plain `:host`: `ng-star-inserted` is Angular's
marker for a node created by an embedded view, not an API to hang a layout on.
Save also gains the `type="button"` it needed to stop doubling as a submit.

* fix(organizations): repair the organization name cell in the list

This renderer draws the first column of the organizations list, once per row.

Its logo was a bare `<img src="{{ rowData.imageUrl }}">` with nothing guarding
it, so an organization with no logo drew the browser's broken-image glyph in its
row. It also read `imageUrl` alone, while the card header and the Main tab both
resolve `image.fullUrl` first and fall back to the column — so an organization
whose logo came from an upload had one everywhere except here. Guarded, resolved
the same way, and backed by a briefcase placeholder sized like the image it
replaces so rows without one keep the same rhythm.

The Default badge was painted one colour and lettered for another:

    [style.background]="background(rowData?.color)"
    [style.color]="backgroundContrast(rowData?.brandColor)"

`IOrganization` has no `color` — the fill was computed from `undefined` while the
text contrast was matched against `brandColor`. Both read `brandColor` now.

Two strings were hard-coded English in a translated app: `text="Default"` and
`Members count {{ n }}`. Both have keys already — `FORM.LABELS.DEFAULT` and
`ORGANIZATIONS_PAGE.EMPLOYEES`.

`rowData.tags.length` was reached without a guard, on a row object whose every
other access is optional; the list does load the relation, but this renderer is
not the one deciding that.

In the stylesheet, the logo's `0px 1px 1px rgba(0, 0, 0, 0.25)` drop shadow is
invisible against the dark themes and becomes `var(--gauzy-shadow, …)`, and the
member line's `11px`/`rgba(126, 126, 143, 0.75)` becomes the shared chip size and
the muted text ramp — that literal is the ramp at 75%, which read as a third,
dimmer grey beside every other muted label in the table.

* fix(organizations): tidy the remaining organizations list cells

`OrganizationsFullnameComponent` is gone. It was declared in `OrganizationsModule`
and re-exported from the table-components barrel, but the module declares no
`exports` and no template anywhere renders `ngx-organizations-fullname` — the
list's name column goes through `OrganizationWithTagsComponent`. It carried its
own copy of the unguarded-`<img>` bug fixed in that renderer, on a 70x63 tile
nothing drew, so it was dead code that still had to be read and kept honest.

The currency and employee-count cells each wrapped their value in
`<div class="m-2">`. That is a 0.5rem margin inside a cell the smart table already
pads, and the Name and Status columns add none — so those two columns' contents
sat half a step in from everything else in the row. The margin goes; the values
keep their weight.

* fix(organizations): drop dead CSS from the organization tabset

This component's template is two elements: `.org-settings` wrapping an
`<nb-route-tabset>`. Angular puts the component's content attribute on the
DESCENDANT half of a compiled selector, so a rule here can match neither an
ancestor nor anything inside the routed tab components below it. Five things in
this file were in one of those two positions.

`@forward '@shared/_edit-profile-form'` emitted 28 rules into this stylesheet,
every one rooted at `:host nb-card` or `:host nb-card-body` — an editing form's
chrome, including a whole `.employee-container` photo widget, in the file that
styles a tab strip. It re-exports mixins too, but none are used here and both
`var` and `themes` are already used directly.

`nb-card-body { overflow-y: hidden }` aimed at the card body in
`edit-organization.component.html`, which is this component's PARENT — it is
projected into that element, not the other way round.

`.form-group input`, `nb-tab`, `nb-tabset` and `.header` name nothing in the
template; the tabset element is `nb-route-tabset`, which the `::ng-deep` block
below reaches, and that is exactly why that block works where these did not.

Verified by compiling the file before and after: 45 rules to 13, 5203 bytes to
1713, with every removed selector rooted at `nb-card`, `nb-card-body`, or one of
the four names above, and nothing new appearing.

* fix(organizations): let the organization card fill the layout column

The empty band between the card and the footer on
/pages/organizations/edit/:id had one cause: `:host nb-card { height: unset }`
in this stylesheet compiles to (0,2,1) and silently outranked the global
`.card-scroll { height: calc(...) }` (0,1,0) from styles/_overrides.scss. The
card therefore sized to its CONTENT -- a header, a tab strip, and a tab body
that measured itself off `100vh` -- and always came out shorter than the column
it sits in.

The shell already hands the page the right box: `nb-layout-column` is a flex
column and `> router-outlet + *` (the routed page component, i.e. this `:host`)
gets `flex: 1 1 auto; min-height: 0`. That height just was not being passed on.
The card now takes it and hands it down through its body, which is the first
step of a continuous chain -- column -> host -> card -> body -> tabset -> tab.

`height: unset` is kept for `nb-card:not(.card-scroll)`, because this file is
also listed in `accounting.component.ts`'s styleUrls and Accounting's nested
cards still rely on it; that page has no flex chain to inherit a height from.

Also drops the header's `25px 20px 0` to `1rem 1.25rem 0.75rem`. The band above
the tab strip was the largest block of empty space on the page and it is chrome,
not content.

* fix(organizations): size and shape the organization tab strip

Two things, both about the tabset element itself.

The height. `nb-route-tabset`'s template is `<ul class="route-tabset">`
followed by a bare `<router-outlet>`, so the routed tab component is a SIBLING
of the strip inside this host -- which makes this the place to close the chain
the card now starts. The host, `.org-settings` and the tabset become a flex
column and the routed tab gets `flex: 1 1 auto; min-height: 0`, so each tab is
handed the space left under the strip instead of measuring the viewport for
itself. The outlet element is hidden: it is an anchor for the routed view, and
as a flex item it would take a share of the column.

The tabs. `.tab-text` was 16px/600, sitting on the inner `<span>` where it beat
both shared tab rules -- `route-tabset-tab-text-font-size` (0.875rem, weight
400) and the active-tab weight from `nb-tabset-overrides()` -- so every tab on
this page was a size larger and a weight heavier than the tab bars everywhere
else in the app, active or not. It now sets only what the shared rules do not.
Padding goes from 10px/25px to 7px/16px and the icon from 18x19 to 15px, both
sized for the type rather than for the 16px it used to be, and the link takes
the top half of the card's radius so the active tab and the panel under it read
as one folder.

The small-screen release moves from `sm` (480px) to `lg` (991px), where the
three tabs actually stack their panels. Between the two there was a 500px band
in which the tabs had stacked but nothing had stopped constraining them.

* fix(organizations): drop the 100vh guess from the three organization tabs

Main, Location and Settings each sized themselves with
`height: calc(100vh - 19.25rem)` -- three copies of one estimate of the page's
chrome, and an estimate that cannot be right in both states: the demo-account
banner alone puts the header band about 23px over the theme's `header-height`,
so the tab was that much shorter than the space it had. That is the other half
of the empty band under the card.

The card is a real frame now and the tabset passes its height down, so each tab
takes what it is given -- `flex: 1 1 auto; min-height: 0` on the host, and the
tab's own panel row fills it. Same box in all three, from one source.

Also removes `:host { nb-card-body { overflow: visible } }` from the Settings
tab. The card body belongs to `edit-organization.component.html`, two components
up, and a component stylesheet cannot match an ancestor -- this compiled to
`[_nghost-x] nb-card-body[_ngcontent-x]` against a template that renders no card
at all. It is called out rather than quietly deleted because the card body is
now the page frame's last flexible step.

* fix(organizations): put Registration Date and Tags on one row

The tags field carried `org__field--wide`, whose only rule was
`grid-column: 1 / -1`. In an eight-field, two-column grid that left Registration
Date alone on its row with a column of empty space beside it and then gave Tags
a row of its own -- two half-empty rows for two fields that pair. They share a
row now: eight fields, four rows, no holes. The rule went with it, since tags
was its only user.

Pairing them means the two halves have to agree, and they did not.
`ga-tags-color-input` renders its label and `ng-select` inside a plain `<div>`
rather than a `.form-group`, so nothing the grid says about a field reached it.
It is given the same column shape as its neighbour, and the label gap is stated
once for the whole grid -- two of the eight fields bring their label from a
child component (`ga-currency` and now `ga-tags-color-input`) while the other
six declare it inline, so the space under a label was previously whatever each
one happened to inherit.

* fix(organizations): lay the organization settings fields out on one grid

Every section of this form uses the bootstrap grid, and every section also
decided its own measure on top of it: General's fields were 75% of the section
body, its Time Format select 30% of its own half and its two time pickers 60% of
theirs; Design was a pair of 140px selects; Accounting's fiscal years a 400px row
of 170px pickers and its toggles a 500px column; Date limit a 50% row. Nine
widths down one column of one form, so no two sections began or ended at the same
x and almost nothing lined up with the field above it.

One measure now -- the section body -- divided by the `col-*` classes the markup
already carries. The four fields that use no column at all (Design's selects,
the fiscal years, and the bare `.form-group`s in Accounting and Date limit) take
the same halves through their own wrappers.

The rest of the misalignment was smaller and more mechanical:

- The gutter disagreed with itself. Bootstrap's is 30px (`.row { margin: 0 -15px }`
  against `.col-* { padding: 0 15px }`) and this file narrowed only the column
  half, to 10px, so every row of fields began and ended 5px outside its panel.
  Both halves are now declared together.
- Rows had no vertical rhythm. `.row` wraps, so one row in the markup is usually
  two or three on screen, and with `.form-group { margin-bottom: 0 }` those sat
  flush against each other.
- General's rows were `align-items: center`, which centres a short field against
  a tall one and takes its label out of line with the label beside it.
- Four info buttons sat OUTSIDE their label, between it and the control, each
  pushing its own field a full button-height down the row. They are inside the
  label now, and a label with one is no longer taller than a label without.
- "Standard Work Hours Per Day" was wrapped in a `col-6` nested inside a
  `col-xl-6 col-12`: half of a half, a quarter-width field beside full-width
  toggles.
- Time Zone sat low because `ga-timezone-selector` brings its own `.form-group`
  (keeping bootstrap's 1rem margin) and its own label, neither of which a rule
  scoped to this component can reach.
- General's selects were painted `background-basic-color-1 !important`, so ten
  controls in the first section were a different colour from the same controls
  in the other nine. They all take the card shell's `input-appearance` now.

The per-section rules this replaces were also addressed by POSITION, and had
already gone stale: an `#agent` section was added at index 8 and every
`:nth-of-type` rule from there down slid onto the wrong section, so the pair
meant for Task settings landed on Agent and Integrations lost its own. Nothing
here counts sections any more.

* fix(ui-core): carry a registered tab's router-link inputs onto its tab link

`DynamicTabsComponent.getRegisteredNbTabs()` mapped a `PageTabRegistryConfig`
onto an `NbRouteTab` by copying six fields — title, tabId, route, icon,
responsive and activeLinkOptions — and silently dropped the rest. Seven of the
dropped ones are inputs `nb-route-tabset` binds on every tab link:
`queryParams`, `queryParamsHandling`, `fragment`, `preserveFragment`,
`skipLocationChange`, `replaceUrl` and `state`.

So a tab that asked for `queryParamsHandling: 'merge'` navigated with
`undefined` instead and lost the page's query parameters on every switch. The
Time & Activity page registers all five of its tabs that way
(`employees/activity/layout/layout.component.ts`), and nothing said otherwise.

Also widens `PageTabsetPageId` with 'organization-edit-page', for the tabset
the organization edit page registers next.

* fix(organizations): render the organization tabs through the shared tabset

The organization edit page hand-built an `<nb-route-tabset [tabs] fullWidth>`
and then restated the whole tab bar locally under `:host ::ng-deep`, where no
shared rule could reach it: a 7px/16px tab box against the theme's
0.75rem/1.25rem, 15px icons with a 6px gap, an `!important` svg fill fighting
the `currentColor` chain, `letter-spacing: 0.02em` on `.tab-text`, its own copy
of the active fill, and a `display: flex !important` that suppressed the shared
stacked icon layout at every width. The result was a tab bar visibly smaller
and tighter than every other tab bar in the app.

It now renders `<gz-dynamic-tabs class="tabset-container">` over the page tab
registry — the same component, the same registry and the same template the
employee edit page uses — and registers its three tabs as `PageTabRegistryConfig`
instead of building `NbRouteTab[]` by hand. Nothing here styles a tab link any
more; the strip takes `includes/_tabset.scss`, `nb-tabset-overrides()` and
`nb-overrides()` like every other tabset. The compiled CSS for the strip and its
container chain is byte-identical to `edit-employee-profile.component.scss`.

Two things went with it. `fullWidth` sets Nebular's own
`:host(.full-width) .route-tabset` to `space-around`, which a local
`justify-content: flex-start !important` then had to undo — and that override
was written as a bare `::ng-deep` with no `:host`, so Angular emitted it
globally and it leaked onto the five plugin pages that do use `fullWidth`, but
only once this page had been visited. And `flex-wrap: wrap` on the strip is
replaced by the shared horizontal scroll port with the scrollbar hidden in all
three engines.

* fix(organizations): give the organization edit card the employee card's frame

Two differences from the employee edit card, both visible on the tab strip.

The card body kept Nebular's default `overflow: auto` where the employee card
is `overflow: hidden` (via `nb-card_overrides(hidden, …)`). The tabs own the
scrolling — each has a scroll port over its own fields — so the card body had
nothing to scroll and that `auto` only ever produced a SECOND scrollbar, down
the full height of the body and alongside the tab strip, on top of the real one
inside the panel. It is `hidden` now; the below-`lg` media query still hands the
overflow back, so a stacked tab cannot be clipped. The body is also a flex
column, so the settings component takes its height as a flex item the way
`ngx-edit-employee-profile` does.

The card also painted itself `gauzy-card-2` — the exact colour `nb-overrides()`
paints the ACTIVE route tab — so the selected tab was the same colour as the
strip it sat in and only its text weight marked it. The card takes the theme's
own `card-background-color` now, which is what makes the active tab read as a
folder tab lifted off the header; each tab still paints its own `gauzy-card-2`
panel, so the tint stays where it belongs.

Also drops `ngx-back-navigation` from the card header, and with it the
`SharedModule` import that existed only to provide it — nothing else in this
template comes from that module.

* fix(organizations): put the Location and Settings tabs on the shared panel scale

The Main tab already matches `@shared/_employee-tab-panel` step for step. Its
two siblings did not.

Location was on the same system but compacted at `$break-sm` (480px) where Main
and every employee tab compact at `$break-md` (767px), so between those widths
it was the only tab on the page still holding desktop padding — and its Save row
never tightened at all. It takes the `md` step now, actions included.

Settings was on a px chrome of its own:

  * the tab box framed its panels with `gap: 20px; padding: 20px` and the
    trailing side cut to 8px, against the `1rem` on all four sides its siblings
    use, so its content began at a different x. The 8px bought nothing — the
    scroll port below keeps its own gutter;
  * the section index had the card-3 fill and the radius of a panel but no
    hairline, `13px 18px` of padding, and `height: fit-content`, so it stopped
    at its last item while the sections beside it ran to the bottom of the row;
  * its heading was 14px/600 — the same size as the items under it — rather than
    the shared panel legend `.tab__legend` and `.org__group-title` both use;
  * an accordion section is this tab's panel, and had no hairline either, so ten
    stacked sections read as one undivided block;
  * Nebular renders `<div class="item-body">` INSIDE `nb-accordion-item-body`
    and both carried 15px, so a section's fields sat 30px in from the panel edge
    against the 1.25rem every other panel uses. The host is 0 now and the
    padding lives on the box that holds the fields. The `lg` rule that used to
    zero the host — a no-op once the host is 0 — narrows `.item-body` instead.

Nav items, list gaps and the `md` step follow the same rem scale.

* fix(organizations): rebuild the settings tab as one panel of section rows

The ten sections were ten cards: a `gauzy-card-3` fill, a hairline and the card
radius each, stacked on the `gauzy-card-2` tab body. On the light themes those
two surfaces composite to #fcfcfc boxes on a #f9f9f9 ground — three values apart
out of 255 — so the frames never read as frames. The right-hand column was a
ladder of faint near-identical bars with nine redundant edges down it, while the
index beside it was a single solid panel: two columns of one form built out of
different things.

`.fields-section` is the panel now, put together the way `.org__panel--form` and
`.tab__panel` already are — panel -> scroll port -> foot:

  * a section is a row, told apart from the row above it by a hairline. Nothing
    in the accordion paints a surface, draws a box or reserves a gap any more,
    which also means `nb-accordion-item` had to be told to let the panel through:
    Nebular's default theme paints one a literal `#fbfbfb` and `gauzy-dark` a
    sidebar token;
  * open is painted as a state. The open row takes the shared hover tint at rest
    — the same fill the index gives the entry pointing at it — with its title at
    full strength and a hairline under it. Before, an open section and a closed
    one were the same box with more inside it;
  * the header answers the pointer. It IS the control that opens its section, and
    it had no hover state at all; Nebular also host-binds `tabindex` onto it and
    then strips the ring back off with its own `:host:focus { outline: 0 }`;
  * Save is the panel's foot, `0.875rem 1.25rem` over a hairline, the box
    `.org__actions` and `.tab__actions` are. It was a bare row on the card body
    with a rule across the top of nothing.

The section index gives up its ten raised chips — each a `gauzy-sidebar-background-3`
fill, i.e. a SIDEBAR token inside a card, under a `--gauzy-shadow` that resolves
to a black drop shadow on six of the eight themes — for the shared flat-chrome
tokens every other list-of-links in the app uses: `gauzy-radius-sm` for the row,
`gauzy-hover-tint` for hover, `gauzy-active-tint` plus a primary rail for the row
you are on. Its two states used to be told apart by exactly one thing, since
hover and active both painted `background-basic-color-1`. It is also sized to its
content rather than stretched down the column, so ten short rows stop sitting
inside a panel drawn the whole way down the viewport.

Colour and metric fixes that came with it:

  * `.item-body` was targeted without `::ng-deep`. That div belongs to Nebular's
    template, so it carries Nebular's content attribute and never this
    component's, and Angular stamps this component's onto every compound selector
    that is not behind `::ng-deep` — the rule matched nothing, and every section's
    fields started 10px to the left of the section title they belong to;
  * the select trigger carried `background-color: background-basic-color-1
    !important` plus a `margin-right: 10px` on a `.select-button` already set to
    `width: 100%`, so every select ran 10px past its own field;
  * the unchecked toggle track was a solid mid grey, which at a glance reads as a
    toggle that is on. It takes `toggle-basic-background-color` with
    `toggle-basic-border-color` for the edge;
  * `::ng-deep .ng-value` was written bare at the start of a selector list, which
    drops the scoping attribute — one tab of the organization editor was setting
    `z-index: 2` on every selected value in every `ng-select` in the app;
  * the chevron sat at Nebular's `right: 1rem` against a header padded to
    1.25rem, and was the only icon on the page drawn inside a ring;
  * `pt-2` on seventeen columns and `mt-2` on three stacked on top of the grid's
    own rhythm, so the longest section ran 1.5rem loose against 1rem elsewhere;
  * `.select-wrapper` took the row rhythm twice, and the last-row release could
    not reach `.design-select`, `.year-pick` or the bare `.form-group`s, so those
    sections closed with a 2.25rem gutter against every other section's 1.25rem.

* fix(ui-core): paint the edit-card fields on the surface they actually sit on

Both edit cards passed `gauzy-card-1` to `input-appearance()`. That is the CARD's
surface, the bottom of the elevation ramp, and it is the right fill for a field
that sits on the card — but no field on either page does. Every organization tab
and every employee tab puts its fields on a `gauzy-card-3` panel, two steps up
that ramp: card-1 -> the `gauzy-card-2` tab body -> the card-3 panel.

On the light themes the mistake is invisible, card-1 being white against a card-3
that composites to about #fcfcfc. On `gauzy-dark` it is the whole problem: card-1
is an opaque `rgba(18, 18, 20, 1)` while the panel composites to about #202022,
so each field read as a black hole punched through its panel rather than as a
control resting on it. `gauzy-card-4` is the next step up from the panel, which
is what a control on a card-3 surface should be — 6 % white over the panel's 4 %
on `gauzy-dark`, 75 % over 50 % on the light themes — and it is already the
choice `job-search.component.scss` makes for the same situation. The old value
stays as the fallback for any theme that does not emit card-4.

The height stops being a hardcoded `42px` and takes `$default-height`, which is
`calc(select-medium-text-line-height + input-medium-padding-y * 2)` — one line
box plus both block insets, i.e. the height `nb-select` computes for ITSELF out
of the density tokens, and 2.375rem at the current preset. The literal predates
that preset, so the mixin's `min-height: … !important` was propping every field
4px above the box its own control had already worked out, and the two would drift
further apart the next time the density tokens move.

The organization call is scoped to `.card-scroll` rather than replacing the one
on `:host`. That stylesheet is also listed in `accounting.component.ts`'s own
`styleUrls`, so everything in it compiles a second time against that template,
and Accounting's fields DO sit directly on its card, where card-1 is correct.
`.card-scroll` is on this page's `nb-card` and not on Accounting's, which is the
hook the rest of that file already uses to mean "the organization editor only";
at (0,2,2) against (0,1,1) it wins both arguments and leaves every other
declaration the mixin emits identical. `edit-employee.component.scss` has no such
second template — it is that component's alone — so it is changed in place.

* fix(employees): give the settings tab the organization settings design

The two pages present the same thing — a long form split into collapsible
sections with an index into them — and were built out of different parts. This
sheet is now the counterpart of `edit-organization-other-settings.component.scss`
rule for rule: compiling both and diffing the analogous rules leaves 23 of 25
byte-identical, and the two that differ do so only in where the value sits (the
field inset lives on `.item-body` there and on `.settings__fields` here, because
that page is stuck on bootstrap rows while this one has a grid).

  * ONE panel, not four. The sections were four cards on the card-2 tab body,
    which on the light themes is #fcfcfc boxes on a #f9f9f9 ground. They are rows
    of `.settings__sections` now, divided by a hairline, with the open row taking
    the shared hover tint and a divider under it, and the header answering hover
    and keyboard focus;
  * Save is the panel's foot rather than a bare row that scrolled away with the
    sections, which also makes this tab a fixed frame: `:host` drops the
    `height: auto` / `overflow-y: auto` pair it shared with `employee-tab-host`
    and takes the definite height the tabset already hands it;
  * the index drops its 12.5rem track of outlined pills — each reserving a
    transparent 1px border so the active one could swap it for
    `color-primary-transparent-default` — for the shared flat-chrome tokens, and
    is sized to its content instead of stretched down the column, where four
    entries sat inside a panel drawn the whole way down the tab;
  * the chevron loses the ring drawn round it on every row and moves to the x the
    section title starts at, Nebular having pinned it at its own `accordion-padding`;
  * a toggle is a cell of the field grid, so it is drawn as a control: the same
    fill, the same `$control-hairline` inset ring and the same `$default-height`
    floor as the input beside it. It was a transparent box outlined in
    `gauzy-border-default-color` — the hairline meant for dividing a panel from
    the page, not for drawing a control — which on the light themes is a #f0f0f2
    line around nothing on a #fcfcfc panel;
  * every toggle on this page carries an info button, and `nbButton` brings a
    control-sized box with it, so it was the tallest thing in the box — taller
    than the 24px text line and much taller than the 16px switch. Clamped, the
    way the field labels' buttons already are;
  * `.settings__fields` had `padding: 0 1.25rem 0.25rem`, i.e. no top inset at
    all, so the first row of fields started hard against the header above it. It
    takes the 1.25rem `.tab__fields` uses, and `gap` now does both halves of the
    rhythm so there is no per-field margin to release under the last row;
  * the local `.ng-select-container, .select-button { background-color:
    var(--gauzy-card-1) !important }` goes; the card shell hands every control
    under it one fill.

* fix(employees): open a settings section from the rail instead of toggling it

Each rail entry called `toggle()` on its accordion item and stopped there, which
had two consequences. Clicking the section you were already reading closed it,
leaving the rail with nothing marked active while that section's fields were
still the ones on screen. And because the sections are one scrolling column,
opening anything below the fold moved nothing into view, so the lower entries
looked inert.

The rail is an index into the page, so it opens rather than toggles and brings
the section it opened with it — the same behaviour, and the same implementation,
as the organization settings rail.

The item is matched to its element through two `ViewChildren` queries over
`NbAccordionItemComponent`, one read as the component and one as an `ElementRef`.
They walk the same template in the same order, so an item's position in one is
its element's position in the other; that is what lets the existing per-section
template refs keep addressing the rail entries by name.

* fixed the spell error

* fix AI comments

* style(organizations): tidy settings layout styles

* style(organizations): format settings components

* fix(organizations): address review findings

- Remove unused Input import from OrganizationWithTagsComponent
- Add missing id="settings-section-taskSetting" so the aside Task
  Settings button's aria-controls resolves to a real element
- Declare postcss-scss devDependency required by .stylelintrc.json
  customSyntax

* build: add postcss-scss to yarn.lock

package.json declared postcss-scss without a matching lockfile entry,
so CI's `yarn install --frozen-lockfile` failed with "Your lockfile
needs to be updated".
2026-09-11 08:53:06 +02:00
joel kalema d36dc32b3e Merge pull request #10130 from ever-co/fix/polish-jobs-pages
Fix/polish jobs pages
2026-09-10 13:58:35 +02:00
Ruslan Konviser 382b9f9621 fix(ever-async): serialize setup and harden connector credentials 2026-09-07 13:54:45 +02:00
Ruslan Konviser 625d621f19 feat(integrations): complete organization-scoped Ever Async connector and builds 2026-09-07 12:58:55 +02:00
joel kalema 17125da2e8 Fix/polish employee edit page (#10115)
* fix(employees): put the verified tick after the employee name

The badge preceded the name inside `.employee-details`, a box carrying
bootstrap's `flex-column` but no `display: flex` — the class sets
`flex-direction` only, so the container never became one. Its placement was
therefore whatever inline flow happened to give it, rather than anything the
markup asked for.

Wrap the name and the tick in a `.employee-name-row` that owns its own
alignment, with the tick trailing the name the way a verified badge reads
elsewhere. Re-indent the surrounding block, which had drifted a level out of
step with its nesting.

* fix(employees): restyle the edit-employee page header

The contact meta read louder than the employee it described. `Edit Public
Page` was `display: flex`, so the control stretched the full width of the
header and read as a banner rather than an action; username and email sat
under it as full-width lines of body-sized text, giving the email more visual
weight than the employee's own name.

Lay the three out as one wrapping row of supporting copy, sized down and
indented to start under the name above them. `$meta-indent` spells out the
three widths it follows — back button, avatar, gap — so resizing the avatar
does not silently break the alignment.

`.transparent` was a flat `opacity: 0.7` over the whole line, which dimmed
label and value equally; it is now a muted label with the value carrying the
weight. Also gives the public-page pill a hover state, sizes the avatar
placeholder icon, and puts one gap between the two header rows in place of
whatever margins their children brought.

The directional rules are scoped under `:host` on purpose. `nb-ltr`/`nb-rtl`
emit a `[dir=ltr]` PREFIX on the whole selector, and Angular's style scoping
only leaves that prefix alone when the selector also carries `:host` —
without it the scoping attribute lands on `[dir=ltr]` itself, which is on
`<html>` and never matches, so the rule silently does nothing.

* fix(employees): show a placeholder when an employee has no profile photo

The account tab's `<img>` was guarded on the form existing rather than on
there being a URL to load, so an employee without a photo got the browser's
broken-image glyph and the alt text sitting in the summary panel. Guard on
the value and fall back to a person icon, matching what the page header
already does. The fallback carries the same hover handlers, so the upload
affordance still works with no photo set.

Also names two elements the stylesheet needs to reach — the vetted badge and
the optional-stats list — and moves the badge's inline `position: relative`
out of the template with them.

* fix(employees): restyle the employee account tab

The summary panel had no padding, radius or edge, and stopped at its own
content, so it sat as a short grey stub against a tall empty tab. Give it the
chrome that makes a panel read as one and let it stretch to the height of the
form beside it.

The photo box had no size of its own — only the image did — so with no photo
it collapsed to the width of the alt text, taking the upload glyph that
centres on it off the corner of the avatar. Size the box instead, at 112px.

The optional stats each carried `margin-top: 20px`, opening four uneven gaps
down the panel; they are a divided list now. The save row sat on the default
form-group spacing and crowded the last field, so it gets a rule above it.
Adds `md` stacking as well: the 25/75 split leaves neither column usable on a
narrow screen.

Everything is scoped through `:host` deliberately. This component also loads
`edit-organization-main.component.scss`, whose rules are all written that
way, so a bare `.content { … }` here loses to it — which is why the old
`.content { padding: 20px }` never took effect and the tab was laid out on
the organization page's padding all along. Matching that specificity means
source order decides, and this file is listed second in `styleUrls`.

* fix(ui-core): let a label point at the language selector

ng-select generates no id of its own, so every `<label for="...">` next to
this component was an orphan: clicking it did nothing and screen readers
announced the control unlabelled. `labelForId` is passed through to the
ng-select template; nb-select has no equivalent hook.

* fix(ui-core): rebuild the language option as a flag/name row

Both templates rendered a bare `<img>` next to a text node, aligned with
`vertical-align: middle` — which aligns to the middle of the parent's
x-height rather than the line, so the flag sat high against the name and the
gap between them moved with the font. Each option is now one flex row, and
the name can take an ellipsis instead of pushing the trigger's arrow out.

Also here: `class="language-select"` is the only handle a stylesheet has on
the panel ng-select appends to `<body>`; `[size]` is dropped from the
ng-select branch, which declares no such input, so the binding only ever
wrote a stray DOM property.

* fix(ui-core): style the language option row

The flex row the templates now render: the flag keeps its 3:2 box and the
name takes the ellipsis. Drops `.language-color` and `.language-label`,
which named nothing in the template — the colour swatch they styled was
replaced by the flag.

* fix(ui-core): size the language dropdown panel like its trigger

Two global rules were built for a different kind of select and caught this
one by accident. `nb-overrides()` pins every image in every ng-select panel
to a 24x24 square, right for the avatars and org logos it was written for
but not for 3:2 country flags: they were blown up and cropped square, taking
each row from ~32px to ~36px and leaving the open list heavier than the
control it belongs to. And the `max-content` widening exists for a trigger
too NARROW to show its options — a filter cell in a table row; a form field
is the opposite case, so on a long option ("Limburgish, Limburgan,
Limburger") the panel spilled over the column beside it.

The panel is excluded from the widening rather than overridden: ng-select
writes the correct width INLINE, and an inline style cannot beat an
`!important` rule, so the only way to let it stand is for the rule not to
match. Both rules live here and not with the component because a component
stylesheet cannot reach a panel appended to `<body>`.

* fix(employees): label the preferred-language field

The account tab's `<label for="preferredLanguage">` had nothing to resolve
to until the selector grew a `labelForId` hook.

* fix(employees): make the edit-employee card body a flex column

The card override gives the body a definite height, but it stayed a plain
block, so the profile tabset inside it laid itself out at content height and
the height the body actually had went nowhere. First step of the chain that
lets a tab shorter than the card fill it.

* fix(employees): carry the card body height down to the routed tab

Nebular's `nb-route-tabset` template is `<ul class="route-tabset">` followed
by `<router-outlet>`, so the routed tab renders as the outlet's SIBLING and
`> router-outlet ~ *` is how a stylesheet addresses it. The outlet itself is
taken out of the flow so it is not a zero-height flex item between the strip
and the tab.

`min-height: 0` at every step because a flex item's automatic minimum size
is its content, which is what turns a long tab into a page-level scroll
instead of a scroll inside the card. Mirrors the same chain on the dashboard
tabset. Applies to every tab in the tabset, not only Account — they all
stopped at their own content height.

* fix(employees): fill the account tab and finish the summary panel

The tab measured itself against the viewport (`max-height: calc(100vh -
20.5rem)`), a second guess at the page chrome competing with the card's own;
when the two disagreed — as they do whenever the header collapses to the
"select an employee" line — the tab stopped short of the bottom of the card
and left a band of empty canvas under the form. It is a flex item of the
tabset now, so it takes the height the card body has left over.

Filling it is not enough on its own to SEE it: `.content` paints
`gauzy-card-2`, `rgba(255, 255, 255, 0.02)` on the dark theme, invisible
against the card behind it. The one element that shows the fill is the grey
summary panel, and it was opting out of stretching — `align-items: stretch`
only stretches an item whose cross size is `auto`, and the organization
stylesheet gives it `height: 100%`, which then resolved against a
`min-height`-sized parent and fell back to content height. As `auto` it runs
the full height of the tab.

The rest is the panel's own chrome: the avatar box back to its long-standing
100px, a placeholder fill that reclaims the box from the scrim rule painting
every div in it solid black, and the upload glyph down from 68px — over a
100px avatar it covered two thirds of the photo and, pinned to a permanent
0.3 opacity, read as something broken rather than as a control.

* fix(employees): grow the account tab into the card body

`.content` held its floor with `min-height: 100%`, a percentage against a host
that is `height: auto` — its size comes out of flex layout on the tabset, not
out of anything the cascade can see there. The percentage fell back to zero, so
the tab was laid out at content height and left a band of bare card body under
the Save row, with the summary panel stopping short beside it.

`flex: 1 0 auto` says the same thing in the language the host speaks: it is
already a flex column, so the tab's leftover height goes to its only item.
Shrink stays at 0 because the host scrolls — a form taller than the tab keeps
its own height and scrolls inside the host rather than being squeezed to fit.

* fix(employees): uncap the networks and hiring tabs

Both carried `max-height: calc(100vh - 20.5rem)`, a second guess at the page
chrome alongside the card's own. The two disagree the moment the page header
collapses to the "select an employee" line, and the smaller one wins: the tab
stopped short of the bottom of the card and left a band of bare card body under
the form. The tabset already hands each routed tab the height the card body has
left over, so there is nothing here left to measure.

Both also load `edit-organization-main.component.scss`, whose `height: 100%` on
the host made the flex basis a full tabset height — one the tab strip has
already spent part of — so the tab only came out right by being shrunk back
down. `height: auto` reclaims it, and the form card grows into the tab so the
surface reaches the bottom.

* fix(employees): size the employment card from the tab, not the viewport

`nb_card_overrides` was passed `calc($default-card-height + 0.25rem)`, which put
a viewport-derived height on the inner card body — a second guess at the page
chrome alongside the card's own. The two disagree once the page header collapses
to the "select an employee" line, so the inner card was sized to a height the
tab does not have: it scrolled for no reason on a short form and still left bare
card body under a tall one.

Passing `auto` lets the body take what the flex chain gives it. The mixin's own
`height: 100%` on the card is a percentage of a host sized by flex layout, i.e.
of nothing the cascade can resolve here, so the card grows into the tab instead.

* fix(employees): fill the location and rates tabs

Both carried `height: calc(100vh - 20.5rem)`, the same second guess at the page
chrome that left the other tabs stopping short of the bottom of the card. The
tabset already hands each routed tab the height the card body has left over.

`ga-employee-location` and `ga-employee-rates` are shared with the candidate
pages, so their own `height: 100%` — a percentage of a host sized by flex
layout, which the cascade cannot resolve — is overridden from these wrappers
rather than in their stylesheets, leaving the candidate pages as they were.

Location also gains the tab fill: it only ever coloured its own `nb-card`, and
that card carries an `m-3` body, so the margin around it showed the card body
straight through.

* fix(employees): fill the contacts and projects tabs

Both membership tabs carried `height: calc(100vh - 20.5rem)` on the host, the
same second guess at the page chrome as their siblings, and then asked their
`.container-*` panel for `height: 100%` of it — a percentage of a box whose own
size comes out of flex layout, which resolves to nothing the cascade can use.

The host is a flex column now and the panel grows into it, so the surface
reaches the bottom of the card body. Projects also picks up the `overflow-y`
its sibling already had, so a long list scrolls inside the tab.

* fix(employees): scroll the settings tab where every other tab scrolls

`.main-form` was pinned to `calc(100vh - 19.25rem)` — a second guess at the page
chrome alongside the card's own — and the host asked for `height: 100%` of a
tabset sized by flex layout, a percentage the cascade cannot resolve. Between
them the form either stopped short, leaving bare card body beneath it, or ran
past the bottom of the card.

The form grows into the tab instead. Its `overflow: hidden` and the
`max-height: calc(100% - 3rem)` cap on `.accordion-section` both hung off that
fixed height and are gone with it: they were what made this the one tab that
scrolled inside its own accordion column. The tab host is the scroller now, as
on the other eight.

Trade-off worth naming: the section nav beside the accordion no longer stays
pinned while the sections scroll. Consistency with the rest of the tabset is the
reason; if the pinned nav is wanted back it should come from a flex basis on the
column, not from viewport arithmetic.

* fix(employees): give the documents tab a surface of its own

It carried no styles at all, so it was the one tab with no fill: the links panel
floated on bare card body, and on a deployment with the Documents feature off
the panel renders nothing and the card body showed through for the tab's whole
height.

Same fill and flex column as the sibling tabs, with the panel growing into the
height the tabset hands the tab. `gz-document-links-panel` is shared with the
invoice and estimate pages, so it is sized from here rather than from its own
stylesheet.

* fix(employees): lift the tab scrollbar off the card edge

Every tab now scrolls at the routed component, so its scrollbar ran down the
very edge of the card body — over the card's rounded corner, hard against the
frame. `> router-outlet ~ *` is the one selector that already addresses every
routed tab, so the inset lives there rather than in ten stylesheets.

It has to be a margin: a scrollbar is laid out at the border edge of the scroll
container, outside the padding box, so `padding-inline-end` would only push the
content away from a bar that stayed where it was. 0.5rem is half the 1rem margin
the tabs' inner cards carry, which puts the bar in the gutter beside the card
rather than on top of its edge.

Logical property rather than the `nb-ltr`/`nb-rtl` pair: those emit a `[dir=ltr]`
prefix that only survives Angular's style scoping when the selector also carries
`:host`, which this nested `::ng-deep` selector cannot do.

* fix(rich-text-editor): fit the toolbar on one line and match its control heights

The `standard` preset puts 25 buttons and 2 selects in one row, so in a narrow
column — the employee Employment tab, the task dialog — it wrapped to two and
three lines and the clusters stopped reading as clusters. Roughly 1150px of row
becomes roughly 905px, by token arithmetic rather than measurement.

Where the width comes from, in order:

- Button side padding 0.5rem to 0.25rem. The old rule was scoped
  `button[nbButton]`, which scores below Nebular's icon-only padding
  (`.size-small.icon-start.icon-end.appearance-ghost` — six class-level
  components, applied to any button whose only child is an `nb-icon`). So the
  0.5rem reached the Font Awesome buttons and never the `nb-icon` ones, and the
  two halves of the toolbar have been sitting on different paddings. Naming
  Nebular's own hooks scores one above its highest rule and covers both.
- One icon box for both families. `nb-icon` was getting Nebular's 1rem
  `button-small-icon-size` while the Font Awesome glyphs were set to 0.8125rem
  here — and `<i>` being inline, each was as wide as its own glyph, so no two
  buttons agreed on a width. At 0.875rem every button is 24px, which is the
  WCAG 2.2 (2.5.8) minimum target size; the vertical padding is untouched, so
  the ~30px height is unchanged.
- Divider side margins halved. Eleven of them cost as much row width as three
  buttons.

The two selects are about matching the row rather than width — they were only
about a fifth of it. `input-appearance()` is included by page after page (this
one passes 42px) and reaches in through `::ng-deep` with `!important` to give
every `nb-select` beneath it a form-field height and background, which is why
these sat 12px taller than the buttons beside them, in a different colour, on
that page and nowhere else. Answering an `!important` needs a higher-scoring
selector, hence `:host`.

Nebular also renders an UNFILLED select a type step larger than a filled one
(`select-small-placeholder-text-font-size` 0.9375rem against the value's
0.8125rem), so "Font Family" was bigger than every other word in the row until
something was chosen. Both are 0.75rem now, filled or not.

Two deliberate changes beyond the reported problem: the select paints
`background-basic-color-1` against the toolbar band on every page, so it reads
as a control rather than blending into the bar; and the link/image popover
inputs get the same height treatment, since `input-appearance()` was giving them
the page's 42px too.

Shared component: this reaches every ga-rich-text-editor — the task dialog,
project and module mutation, the proposal editors, the public-page mutations.

* fix(location-form): drop the stray character beside the search icon

The `nb-icon` for the address autocomplete carried a literal "å" as its element
content, so it rendered as a stray character next to the search glyph in every
form that shows the autocomplete — organizations, contacts, candidates and the
employee Location tab. Self-closed instead; the icon is drawn from the `icon`
input, and the element was never meant to have content.

* feat(employee-location): rebuild the tab as two panels

The layout was `nb-card > nb-card-body.m-3 > .container > .row > .col-4 + .col-8`
— three levels of chrome, inside the tab's own card, around what is really one
form and one map. The two are views of the same value: type an address and the
pin follows, click the map and the fields follow. They read better as a pair of
equals than as a form with an illustration beside it, so they are now two
panels on the tab surface, each with its own fill, hairline and radius.

Three things move with the structure:

- The commit action was under BOTH columns, which put it under the map as much
  as under the form and left it reading as a page-level action. It sits in a
  footer on the panel whose fields it commits. It also takes `type="button"`:
  inside a `<form>` with no `ngSubmit` binding it was defaulting to submit, and
  only Angular's own submit handling kept that from doing anything.
- The fields are one column at one width. `ga-location-form` lays itself out on
  a bootstrap grid — `col-11` for search, `col-8` for country and city, `col-6`
  for postcode — widths chosen for a form running the width of a page. In a
  22rem panel they read as four arbitrary field lengths and the postcode lands
  around 150px. Latitude and longitude keep a shared row, being one value read
  in two halves. Done from the consumer rather than in
  `location-form.component.scss`, which is shared far more widely and whose
  other hosts still want their own grid.
- A rule separates the autocomplete search from the fields below it. It FILLS
  them rather than being one of them, and had nothing but the same 1rem gap to
  say so.

The panel width is `min(22rem, 40%)` rather than a flat basis, following the
pattern already used elsewhere here: the layout column loses up to 860px to the
expanded AI chat, which no viewport media query can see because the window did
not change.

`ga-employee-location` has exactly two consumers — this tab and the candidate
Location tab — so the candidate page gets the same treatment. Same UI, same
purpose.

* fix(employee-location): size the map from its panel, not from the viewport

`.leaflet-container` is styled in `forms/maps/leaflet.component.scss` as a bare
`::ng-deep` with no `:host` in front of it, so Angular strips the scoping
attribute and the rule lands GLOBALLY — on every map in the app — carrying
`height: calc(100vh - 30rem) !important`. That is a guess at the chrome of
whichever page a map happens to be on, and it has nothing to do with the panel
the map now sits in: it left the map either short of the panel or running past
it, with the panel fill showing through the difference.

Flex the whole chain instead — `ga-leaflet-map`, the `.row` it wraps its map div
in, and the `.col-12` inside that — so the map is exactly as tall as its panel
and no arithmetic is involved. The `mt-3 mb-3` on that row is unset for the same
reason: inside a panel the spacing is the panel's to give, and a margin there
shows as a band of fill above and below the tiles.

`!important` answers an `!important`: the rule being overridden is global and
carries one, so nothing quieter reaches it. The height is set to `auto` rather
than to a figure of our own, because flex-grow is what sizes it.

The panel keeps a `min-height` floor. Leaflet measures its container when it
initialises, so there has to be something to measure, and the floor also keeps
the map from collapsing to a strip on a short viewport. Left alone: the
component has no resize handling of its own, but Leaflet's `trackResize` covers
window resizes and a container resize with no window resize behind it (the AI
chat opening) was already unhandled before this.

* fix(employees): drop the location tab's patches into the old scaffolding

The wrapper carried `::ng-deep` rules for `.container`, `.col-4` and an
`nb-card` — the markup `ga-employee-location` used to be built from. That
scaffolding is gone and the component owns its own layout now, so the rules name
nothing and can go with it.

What stays is the wrapper's actual job: the tab fill, and the flex handoff that
gives the component the height the tabset hands this tab. The fill is
load-bearing rather than decorative — the component paints its two panels but
not the gap between and around them, so without it the card body shows straight
through.

* fix(country-select): keep the dropdown panel within its field

The global `max-content` widening in _overrides.scss exists for a trigger too NARROW to show its options — a filter cell in a table row. A form field is the opposite case, and country names run to "South Georgia and the South Sandwich Islands", so the panel opened far wider than the field and over whatever sat beside it.

Opts out by class rather than by name: `.form-field-select` is the category — a select standing in a form column — so new ones add the class instead of growing the `:not()` list. ng-select copies the host's static class attribute onto the panel it appends to <body>, which is the only handle a stylesheet has on it.

Also gives the host `display: block`, so the field can be sized and aligned from outside like every other `div.form-group` in the address form, and drops a dead `size="medium"` attribute that NgSelectComponent declares no input for.

* feat(styles): add the employee tabset's shared panel language

Every tab of the employee edit page invented its own chrome. Some wrapped their form in an `nb-card.m-3` inside the tab host — a card inside a card inside the page card. Some borrowed the ORGANISATION page's stylesheet, which brought a 563px panel and a w-25/w-75 split written for a different page. Each laid its fields out on whatever bootstrap columns were to hand.

The location tab was rebuilt as flat panels on the tab surface and the account tab followed it. This is that shape factored out — `employee-tab-host` for the surface and flex chain, `employee-tab-panel` for the panel, field grid and footer — so the remaining tabs do not each re-derive it and the rhythm can be moved from one file.

* fix(employee-account): rebuild the account tab as two panels

The tab was rendering the ORGANISATION page's scaffolding, borrowed through styleUrls: a w-25 column holding nothing but an avatar pinned to its top-left corner, beside a w-75 column of bootstrap rows. At an employee's width that left the left quarter empty from the photo down and stretched every input across half the card. The tab's own stylesheet was spending itself overriding that import selector for selector, winning only on source order; the import is gone and the tab owns its layout.

Identity panel: photo on the leading edge with the name, position, blurb and status chips beside it, then the record's figures as one flat two-column grid. The groups are `display: contents` so the individual rows are the grid items — laid out as blocks, a two-row group beside a three-row group was padded to match and left a hole under the shorter one. Type runs at 11-12px so the whole panel fits without scrolling the tab.

Form panel: six fields on a two-column grid, replacing three bootstrap rows whose columns were declared three different ways, with Save in a footer on the panel's trailing edge.

The avatar guard now watches for a load error too: guarding on the URL alone only proved one was SET, so an employee whose stored photo 404s got the browser's broken-image glyph. The inline 68px SVG hard-coded to #0091FF is an nb-icon camera badge, and the vetted nb-badge — which positions itself absolutely and needed an inline `position: relative` to sit in the flow — is a plain chip. NbBadgeModule drops out of the module with it.

* fix(employee-networks): put the eight profile URLs on one grid

They were four bootstrap rows of two `col-sm-3` cells: each field took a QUARTER of the row and the right half of every row was empty, so the form ran down the left side of a card twice as wide as anything in it.

One panel on the tab surface, eight fields on the tabset's shared two-column grid, Save in the panel footer. The organisation page's stylesheet drops out of styleUrls with the layout it was propping up.

* fix(employee-hiring): put the three dates on the tab's grid

Two bootstrap rows, the second carrying an empty `<div class="col"></div>` to hold the reject date at half width — a spacer standing in for a layout rule. The grid places the third date under the first without one.

Same panel and footer as the sibling tabs; the organisation page's stylesheet drops out of styleUrls.

* fix(employee-employment): drop the nested cards and grid the fields

`nb-card > nb-card-body > .form-container > form` was a card inside a card inside the page card — three levels of frame around one form. It is one panel on the tab surface now.

The fields ran down three different sets of bootstrap columns (col-md-6, then bare col, then col-md-6), so the same form changed its column width twice on the way down. They are on the tabset's two-column grid, with the description and the bonus toggle spanning it.

The start-date note was its own column BESIDE the field it is about, so the amber block sat where the next field belonged and shifted everything below it; it sits under its own field now. The start-date input also gains the id its label's `for` was already pointing at.

* fix(employee-rates): rebuild the rate and limits cards as two panels

Two `nb-card`s in bootstrap columns inside `.content > .employee-form > form` inside the tab host. The nesting also cost the fields their alignment: inside a `col`, a `col-6` is a QUARTER of the row, so the four rate inputs sat at quarter width beside a limits card whose one input ran the full width of its own.

Two panels on the tab surface, 3:2, each with a two-column grid, and Save in a footer on the rate panel it commits rather than under both columns. The card headers are kept as captions — a card header brings its own fill, border and padding on every side, which two words naming a group of fields do not need.

The validation messages were siblings of the .form-group rather than children, so each rendered under the NEXT field down; they sit inside their own field now.

ga-employee-rates is shared with the candidate page, so that tab gains the surface fill and flex chain the new panels need — without it the gutter between them would show its card body through. Its own `max-height` chrome arithmetic is left alone.

* fix(employee-membership): make the assigned entities rows, not cards

Every entity in the list was wrapped in an `nb-card` of its own — fill, border, radius and 1rem of body padding for one line of text and a close icon — inside a card, inside the tab's card. A name with a remove button beside it is a row.

One panel: a header naming the list with Add on its trailing edge, then the entities as rows. The title moved out of the scrolling body, where the one label saying whether you were looking at contacts or projects scrolled away with the rows it named. The add form was `.container.m-0.w-25` — a quarter of the card whatever its width — so the select was narrower than the rows it adds to; it takes the panel's width now.

The remove control was an unlabelled `nb-action` from a toolbar construct, floated right. It is a labelled ghost button, muted at rest and full strength on hover or keyboard focus — deliberately never hidden, since a hover-only affordance is unreachable on a touch screen.

NbCardModule and NbActionsModule drop out; NbTooltipModule comes in for the label. The Contacts and Projects tab shells lose the nb-card overrides that now have nothing to override.

* fix(employee-settings): align the tab and drop the organisation page's dead rules

The fields were laid out on four different sets of bootstrap columns — `col-6` in two sections, `col-xl-6 col-12 pt-2` in the other two, and on one toggle the `form-group invite-toggle` classes were on the COLUMN itself rather than a div inside it, so that row sat on different padding from its five neighbours. Each section body is on one two-column grid now.

`width: calc(100% - 230px)` on the sections column was the rail's width plus a guess at the gap, restated as a percentage — moving the rail meant remembering to move this too. Flex and `min-width: 0` say it directly. Save moves to a footer on the trailing edge, above a rule, like every other tab.

The rail rows were `<ul><span (click)><li>` — the click target was the element BETWEEN the list and its item, which is neither valid list content nor focusable. They are buttons, reachable by keyboard, and the open one reads as a current item rather than as the one dented button among four raised ones.

Fixes two real bugs found on the way: the six info-tooltip buttons had no `type`, so inside the form they defaulted to `type="submit"` and clicking one submitted the settings form; and the Time Format label was `for="timeZone"`, pointing at the select in the field beside it, so clicking it opened the wrong dropdown.

Removes ~150 lines copied from the organisation settings page that name nothing in this template: nth-of-type(5/6/7), .fiscal-years, .half-width-inputs, .design-select, .color-pick, .year-pick, .time-format-select.

* fix(employees): rebuild the edit page header as one identity row

The header was a flex-column of two rows, the second inset by `$meta-indent: calc(3rem + 48px + 0.75rem)` — the summed width of the back button, the avatar and a gap, hand-computed so the meta would line up under the name. That indent is why the public-page button and the email appeared to float in the middle of the header with nothing above or below to align to, and it had to be re-derived by hand every time the avatar changed size. On one row nothing needs measuring.

Position, username and email are one muted supporting line with the separator drawn between items, so a missing position cannot leave a leading dot behind. The em-dash the position used to fall back to — which read as a stray character under the name — is gone with it.

The avatar guard now watches for a load error: guarding on the URL alone only proved one was SET, so an employee whose stored photo 404s got the browser's broken-image glyph in the page header. It falls back to the same placeholder an employee with no photo gets.

Both actions move to the trailing edge. Edit Public Page was a `<span (click)>` — not focusable — and is a button; the favourite toggle renders a FILLED nbButton, which on the dark theme painted a solid near-white block in the corner of a near-black header for a secondary action, so it is quietened to match the pill beside it. Done from this page's stylesheet, not the shared component's, where a filled button is what the list rows want.

* chore(employees): drop the explanatory comments added in this branch

Removes the commentary this branch introduced across the employee edit
page, its tabs and the shared styles they lean on. Comments that predate
the branch are untouched, and two that the branch had reworded are back
to their original wording.

No behaviour or style change: stripping every comment from both revisions
leaves identical code.

* fix(employee-profile): weight the tab inputs from the paragraph token

Nebular's `[nbInput]` carries its own font-weight, which left the values
in the edit-employee tabs heavier than the body copy around them. Resolve
it from `text-paragraph-font-weight` so the fields read at the same weight
as the rest of the page on every theme.

* chore(cspell): add formgroupname and normalise indentation

Adds the formgroupname dictionary word used by the employee edit page
forms, and converts the space-indented entries at the top of the words
list to tabs to match the rest of the file.

* fix(employee-membership): step the card typography down a size

The membership card set its title and its member names at the paragraph
size, so the Contacts and Projects tabs read heavier than the surrounding
form. Resolve both from `text-subtitle-2-font-size`, drop the title to a
500 weight, and pin the names to `text-paragraph-font-weight` so they
cannot inherit a heavier weight from above.

* fix(employee-profile): address review findings on the tab layouts

- let .tab shrink so .tab__fields scrolls internally and .tab__actions
  stays pinned at the panel bottom instead of scrolling out of view
- move the rates submit footer after the limits panel; .rates is a grid
  now, so the footer spans both columns rather than becoming a third one
- render zero-valued employee facts (bill rate, weekly limit, work hours,
  job success, total jobs) by testing for null rather than truthiness
- expose aria-expanded on the settings accordion rail buttons
- reuse employee-tab-host for the contact, projects, documents, rate and
  location tab hosts, which each carried their own copy of it
- floor the candidate rates max-height so it cannot resolve to a
  non-positive length, and collapse the panel, below a 28rem viewport
- merge the duplicate :host blocks in country.component.scss

* fix(employee-profile): patch the avatar control and share the tab form rule

- patch imageUrl and imageId as soon as an upload succeeds, instead of
  waiting on the userForm$ -> user update -> refetch round-trip to bring
  them back. imageId is an enabled control, so saving before the reload
  landed posted the old asset id and reverted the avatar
- move .tab__form into @mixin employee-tab-panel; employment, hiring and
  networks each carried an identical copy
- let ga-employee-location and ga-employee-rates shrink to the host
  height, so their own field areas take the scroll and their footers stay
  pinned rather than scrolling out of view
2026-09-07 12:00:12 +02:00
Ruslan KonviserandClaude Opus 5 52ae8427fe Fix: make ESLint runnable again across the workspace (#10117)
No lint invocation in this repo could reach a single file. `nx run-many -t lint --all` failed for
93 of 93 projects, and no CI workflow runs lint, so nothing ever noticed. Two independent bugs,
either of which alone was fatal.

1. The root `eslint.config.js` was `module.exports = new FlatESLint({ overrides: [] })`, importing
   a `FlatESLint` class from `@nx/eslint-plugin-nx`. That could never have worked: a flat config
   must export an array, and no Nx package has ever exported such a class. `@nx/eslint-plugin-nx`
   is the beta-only predecessor NAME of `@nx/eslint-plugin` — the repo declared both, the real one
   at ^22.5.2 and this one pinned at 16.0.0-beta.1. The v16 beta drags a nested `@nx/devkit@16`
   that expects an `nx` internal path nx@22 no longer ships, so every `eslint` invocation died
   with `Cannot find module 'nx/src/utils/typescript'`.

2. All 41 project configs did `[...require('../../.eslintrc.json')]`. That file is a JSON object,
   so the spread threw `TypeError: baseConfig is not iterable` — a different failure, on the path
   `nx lint <project>` actually takes. `packages/mcp-server` also had the wrong depth.

Changes: remove `@nx/eslint-plugin-nx`, add `typescript-eslint@^8.40.0` (the only genuinely
missing package — `@nx/eslint-plugin`'s flat configs require it unconditionally); rewrite the root
config as a real flat array shaped like Nx 22's own generator output, with real `ignores`
replacing the legacy `"ignorePatterns": ["**/*"]` that disabled linting workspace-wide; repoint
all 41 project configs at the root flat config, dropping the FlatCompat bridge the 12 `.cjs` ones
used; disable the deprecated `@typescript-eslint/no-empty-interface`, which Nx's presets still
enable alongside its v8 replacement `no-empty-object-type` and so double-reported every
occurrence; and re-enable `no-dupe-keys`, `no-dupe-class-members`, `no-dupe-args` and
`no-unreachable`, which typescript-eslint's `eslint-recommended` overlay turns off on the grounds
that `tsc` reports them — no CI job here runs `tsc --noEmit`, and a duplicate key in
`packages/core/jest.config.ts` has already silently changed behaviour once.

The legacy `.eslintrc.json` files are deliberately KEPT: `.codacy/codacy.yaml` pins eslint@8.57.0,
which does read them.

The guardrail is proven, not assumed — against the real historical bug:

    packages/core/jest.config.ts
      29:2  error  Duplicate key 'transformIgnorePatterns'  no-dupe-keys

Findings are REPORTED, NOT FIXED (a separate job): 8,887 files linted, 2,290 with problems,
2,649 errors / 6,110 warnings, zero parse errors. Lint should not become a required check until
that backlog is triaged.

Verified: packages/core 56/56 suites and 612/612 tests; build-monorepo-root, build-api, build-libs
and build-web all green; all 42 configs load; `nx lint <project>` now runs and reports instead of
crashing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:07:25 +02:00
joel kalema b5d375eac4 fix(employees): put the Time & Activity filters on the trailing edge (#10114)
* fix(employees): put the Time & Activity filters on the trailing edge

The filter bar sat wherever its box happened to begin instead of flush
right, on every tab of the Time & Activity page.

`time-activities.component.scss` resets `.col-auto.ml-auto`, dropping the
filter bar's own auto-margin push along with the negative gutters it went
with. Nothing replaced it, so the header's `space-between` had no effect:
`.header-filters` grew to fill the row and its contents started at the
left of that space.

End the row explicitly instead. `.header-filters` becomes a flex slot
with `justify-content: flex-end`, and the projected `.main-wrapper` is
given full width and ends its own contents. Screenshots and Apps /
Visited Sites forward this file, so all three tabs are covered; the
Screenshots stylesheet redeclares the same `.header-bar` block, so it
restates the slot rule that its copy overrides.

The Videos tab lives in `@gauzy/plugin-videos-ui` and forwards nothing,
so it carries an equivalent block of its own, plus the inset that lines
its controls up with the video grid below.

`gauzy-filters.component.scss` is left alone. It is shared with the
timesheet and recap surfaces, where the current alignment is wanted.

* fix(report): drop the project column heading to 12px

The project name in the Project column was set at 14px with 17px leading,
a step above the 12px table-header scale the report tables around it run
at. In a grid whose cells are all body text, it read as a heading rather
than as the first line of a cell.

Bring it onto the same scale: 12px with 15px leading, the values behind
`gauzy-table-header-font-size` and `gauzy-table-header-line-height`.

`.employees-count` beneath it keeps its 11px; it is the secondary line of
the cell and still sits a step below the name. The recap copy of this
component in `@gauzy/desktop-ui-lib` is a separate surface and is left at
the old scale.

* fix(employees): anchor the Time & Activity grid on the table-header scale

The typography cap for the grid was written as a list of overrides hung
off `ga-daily-grid`, which reached the chrome from `report.scss` but not
the nested cell components. Each of those ships its own px size, so the
Employee, Project and Client cells kept their own scale and the rows were
set at three different sizes.

Move the declaration onto `.grid-container` so it inherits down through
everything that carries no size of its own, and list beneath it only the
elements that do declare one: the `report.scss` chrome, `ngx-avatar`,
`ga-project-column-view` and `ngx-contact-links`.

Every size resolves to `gauzy-table-header-font-size` (0.75rem) rather
than a literal, so the grid tracks the same scale as the group-by label
and the stat-strip labels capped above it. Line-height is restated
wherever the size is, because these components pair a px size with a px
leading, and changing the size alone would leave leading set for larger
text.

The 8px counter chip in the client cell is left alone: it is a count
badge rather than body text. Screenshots and Apps / Visited Sites forward
this file, so all three tabs move together.

* fix(report): repeat both To Do columns in the daily grid header

The accordion header stood a single `todo-column header` spacer in place
of the two 30% columns the data rows carry, To Do and Notes. That left
the header summing to 118% against the rows' 150% — and because both
overflow their container, flexbox shrank each by its own ratio, so every
column boundary past To Do resolved somewhere different. Time and
Activity in the trigger sat left of the columns they head.

Repeat the same pair of spacers the rows use, in all four grouping
branches. Both rows now sum to 150% and shrink alike, so the header's
figures line up with the table beneath them.

* fix(report): inset the daily grid header's trailing edge

Two things still separated the accordion header from the rows once both
carried the same columns. The rows resolve inside `nb-accordion-item-body`
and its inset, while the header resolves in the card; and the header
carries the expand chevron as an extra flex item after the last column.
Both leave its columns ending further along than the rows', so Time and
Activity read as pushed right.

Inset the header's trailing edge by the chevron's width to bring them
back onto the columns the table below uses, mirrored for RTL.

`.todo-column.header` and its `.client` variant go with it. Those widths
existed only to approximate the column the header was missing, and
nothing references them now that it carries both.

* fix(employees): let Time & Activity rows size to their content

`report.scss` gives every row a fixed 86px box, pads it 10px top and 20px
bottom and pins the content to the top. That was sized for the old type
scale; at the table-header size the row's single `.table-inner-wrapper`
fills a little over half of it and the remainder reads as a gap beneath
each row.

Let the content set the height and pad it evenly, which brings a row down
to roughly 56px and puts more of the report on screen.

Scoped to this page rather than changed in `report.scss`, which shares
`.table-row` with the payment, expenses and amounts-owed grids; those run
at their own scale and are left as they are.

* fix(report): reduce the project column logo to 24px

The project logo sat at 28px, sized against the 14px project name it used
to stand beside. With the name now at the 12px table-header scale it read
heavy for the cell.

Bring it down a step to 24px. The `min-width` and `min-height` come with
it — they pin the box against the flex row and would otherwise hold it at
28px. The image inside is sized at 100% and follows on its own.

* fix(employees): line the Time & Activity filters up with the table

The filter row ended past the table's trailing edge, and by a different
amount depending on whether the table was long enough to scroll.

The scroll container was padded rather than narrowed. A scrollbar draws on
its element's own trailing edge, outside the padding, so `padding-right`
moved the table's text inward while leaving the bar itself against the
card's edge — and the row above, which has no scrollbar to account for,
had nothing to line up with.

Hold the container 11px off the edge instead. The bar travels in with it
and the 5px it occupies puts the table's trailing edge at a 16px inset,
which is where the filter row already ends: 8px on the header bar plus
the 8px its own wrapper adds.

* fix(employees): pull the Time & Activity filter row off the card's edge

The filter row still ran past the table's trailing edge. `.filter-item-list`
is a bootstrap `.row` and the page resets the negative gutters on
`.main-wrapper` but never on that inner row, so its trailing item overhangs
the wrapper it sits in and the row ends wider than the table below it.

Carry that difference on the header bar. Fixing it at the source would mean
resetting the gutters inside `ngx-gauzy-filters`, which the timesheet and
recap surfaces also use and which the filter work on this branch has so far
left alone.

The inset is set against the rendered result rather than derived, since the
overhang comes from gutters this page does not own.

* fix(report): tune the daily grid header's trailing inset

Brings the trigger's Time and Activity closer to the columns they head.

The 44px this replaces was an estimate, sized to the chevron on the
assumption that it was the only thing separating the header from the rows.
It is not: the rows also resolve inside `nb-accordion-item-body` and its
inset, so no single figure follows from the stylesheet. The comment now
says as much, and the value is set against the rendered result.

* fix(employees): tune the Time & Activity filter row's trailing inset

Moves the filter row 4px towards the card's trailing edge.

Like the inset it adjusts, the value is set against the rendered result:
the overhang comes from bootstrap gutters inside `ngx-gauzy-filters` that
this page does not own and deliberately does not reset.
2026-09-04 19:50:12 +02:00