Merge remote-tracking branch 'origin/develop' into fix/ghsa-44pv-org-scope-residuals

This commit is contained in:
Ruslan Konviser
2026-09-17 03:30:10 +02:00
108 changed files with 4782 additions and 1187 deletions
+5
View File
@@ -83,6 +83,7 @@
"idempotency",
"msgbox",
"nocookie",
"nocheck",
"nofollow",
"opped",
"regexes",
@@ -410,6 +411,7 @@
"hostedtoolcache",
"Hoster",
"hosters",
"Hostinger",
"hubstaff",
"HUBSTAFF",
"huntr",
@@ -828,6 +830,7 @@
"reindex",
"rels",
"relver",
"recolouring",
"Rememberd",
"rememberMe",
"renderable",
@@ -1064,6 +1067,7 @@
"upserted",
"upserting",
"upserts",
"unbolded",
"upvotes",
"UPWORK",
"Upwork",
@@ -1074,6 +1078,7 @@
"USERTRX",
"uuidv4",
"UXGA",
"UA's",
"VARCHAR",
"vcpu",
"vercel",
+10 -4
View File
@@ -71,6 +71,8 @@ jobs:
name: build-monorepo-root
# Moved off the 4-core pool: this is no longer one of five equals, it is the SERIAL CRITICAL PATH
# for the whole gate, and both halves of its work (yarn install, zstd -T0) scale with cores.
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
# 360, not 180. This job now carries the install for the ENTIRE gate, alone, and a cold install
# here has no yarn tarball cache behind it: test_playwright.yml's equivalent deps job measured the
@@ -175,7 +177,8 @@ jobs:
# not block a merge — the exact "no failure just means never ran" trap this file's header warns
# about. `!cancelled()` keeps the ordering but still runs on producer failure, where the restore
# fails loudly and this reports a real red instead of vanishing.
if: ${{ !cancelled() }}
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
timeout-minutes: 180
steps:
@@ -240,7 +243,8 @@ jobs:
# not block a merge — the exact "no failure just means never ran" trap this file's header warns
# about. `!cancelled()` keeps the ordering but still runs on producer failure, where the restore
# fails loudly and this reports a real red instead of vanishing.
if: ${{ !cancelled() }}
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
timeout-minutes: 180
services:
@@ -330,7 +334,8 @@ jobs:
# not block a merge — the exact "no failure just means never ran" trap this file's header warns
# about. `!cancelled()` keeps the ordering but still runs on producer failure, where the restore
# fails loudly and this reports a real red instead of vanishing.
if: ${{ !cancelled() }}
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
timeout-minutes: 180
steps:
@@ -479,7 +484,8 @@ jobs:
# The quota concern is now bounded instead of ignored: red Build runs are rare since the gate
# rework, and retention-days: 1 ages a kept artifact out within a day regardless.
# `skipped` (build-desktop on PR runs) still allows deletion - only failure/cancelled block it.
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -80,6 +80,8 @@ jobs:
probe:
name: Probe public endpoints from outside the homelab
# 🛑 DO NOT CHANGE — see the header block. GitHub-hosted is the whole point.
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest
timeout-minutes: 15
+2
View File
@@ -29,6 +29,8 @@ permissions:
jobs:
build:
name: MegaLinter
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ vars.RUNNER_LINUX_X64_4 || 'ubuntu-latest' }}
timeout-minutes: 300
steps:
+2
View File
@@ -17,6 +17,8 @@ permissions:
jobs:
TruffleHog:
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ vars.RUNNER_LINUX_X64_4 || 'ubuntu-latest' }}
timeout-minutes: 300
steps:
+2
View File
@@ -24,6 +24,8 @@ permissions:
jobs:
analyze:
name: Analyze
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ vars.RUNNER_LINUX_X64_4 || 'ubuntu-latest' }}
timeout-minutes: 300
+4
View File
@@ -36,6 +36,8 @@ env:
jobs:
typecheck-configs:
name: typecheck-configs
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ vars.RUNNER_LINUX_X64_4 || 'ubuntu-latest' }}
timeout-minutes: 10
steps:
@@ -67,6 +69,8 @@ jobs:
lint:
name: lint (non-blocking)
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ vars.RUNNER_LINUX_X64_8 || 'ubuntu-latest' }}
# Non-blocking at the JOB level, not just on the ESLint step. The step-level flag below keeps
# a red lint result from failing the job; this keeps anything else in the job — a cache miss,
+2
View File
@@ -19,6 +19,8 @@ permissions:
jobs:
spellcheck:
name: Cspell
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs and pushes still run.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ${{ vars.RUNNER_LINUX_X64_4 || 'ubuntu-latest' }}
timeout-minutes: 300
steps:
+23 -15
View File
@@ -1,5 +1,7 @@
# Ever Gauzy Platform
<a href="https://trendshift.io/repositories/1775" target="_blank"><img src="https://trendshift.io/api/badge/repositories/1775" alt="ever-co%2Fever-gauzy | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/1775?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-1775" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/1775/daily?language=TypeScript" alt="ever-co%2Fever-gauzy | Trendshift" width="250" height="55"/></a>
[uri_gauzy]: https://gauzy.co
[uri_license]: https://www.gnu.org/licenses/agpl-3.0.html
[uri_license_image]: https://img.shields.io/badge/License-AGPL%20v3-blue.svg
@@ -24,10 +26,10 @@
- **Work and Project Management** (PM)
- **Employee Time-Tracking, Activity & Productivity Tracking**
<img width="1905" height="988" alt="image" src="https://github.com/user-attachments/assets/6197a87a-8ae9-4380-91c3-3f85f8406542" />
Ever® Gauzy™ Platform is a part of our larger Open Platform for **Collaborative, On-Demand and Sharing Economies** - [Ever® Platform™](https://ever.co).
<img width="1905" height="988" alt="image" src="https://github.com/user-attachments/assets/6197a87a-8ae9-4380-91c3-3f85f8406542" />
## ✨ Features
Main features:
@@ -106,12 +108,6 @@ Read more [about Gauzy](https://github.com/ever-co/ever-gauzy/wiki/About-Gauzy)
- **<https://docs.gauzy.co>** - Platform Documentation (WIP). See also our [Wiki](https://github.com/ever-co/ever-gauzy/wiki).
- **<https://ever.co>** - get more information about our company products.
## 📊 Activity
<a href="https://trendshift.io/repositories/1775" target="_blank"><img src="https://trendshift.io/api/badge/repositories/1775" alt="ever-co%2Fever-gauzy | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
![Alt](https://repobeats.axiom.co/api/embed/7c6f6c3bf56fd91647549cf4ae70af49ed5ee106.svg 'Repobeats analytics image')
## 💻 Demo, Downloads, Testing and Production
### Demo
@@ -296,6 +292,10 @@ Notes:
### 🚗 Other self-hosting options
#### Deploy on Hostinger
[![Deploy on Hostinger](https://assets.hostinger.com/vps/deploy.svg)](https://www.hostg.xyz/aff_c?offer_id=815&aff_id=244060&url_id=6822)
#### Deploy on RepoCloud
Deploy Ever Gauzy instantly with one click on [RepoCloud](https://repocloud.io/details/Ever%20Gauzy/).
@@ -316,11 +316,11 @@ Deploy Ever Gauzy instantly with one click on [RepoCloud](https://repocloud.io/d
Ever® Gauzy™ follows good security practices, but 100% security cannot be guaranteed in any software!
Ever® Gauzy™ is provided AS IS without any warranty. Use at your own risk!
See more details in the [LICENSE](LICENSE.md).
See more details in the [LICENSES.md](LICENSES.md).
In a production setup, all client-side to server-side (backend, APIs) communications should be encrypted using HTTPS/WSS/SSL (REST APIs, GraphQL endpoint, Socket.io WebSockets, etc.).
If you discover any issue regarding security, please disclose the information responsibly by sending an email to <mailto:security@ever.co> or on [![huntr](https://cdn.huntr.dev/huntr_security_badge_mono.svg)](https://huntr.dev) and not by creating a GitHub issue.
If you discover any issue regarding security, please disclose the information responsibly by sending an email to <mailto:security@ever.co> or on [huntr](https://huntr.com) and not by creating a GitHub issue.
## 🛡️ License
@@ -328,13 +328,13 @@ We support the open-source community. If you're building awesome non-profit/open
This software is available under the following licenses:
- [Ever® Gauzy™ Platform Community Edition](https://github.com/ever-co/ever-gauzy/blob/master/LICENSE.md#gauzy-platform-community-edition-license)
- [Ever® Gauzy™ Platform Small Business](https://github.com/ever-co/ever-gauzy/blob/master/LICENSE.md#gauzy-platform-small-business-license)
- [Ever® Gauzy™ Platform Enterprise](https://github.com/ever-co/ever-gauzy/blob/master/LICENSE.md#gauzy-platform-enterprise-license)
- [Ever® Gauzy™ Platform Community Edition](https://github.com/ever-co/ever-gauzy/blob/develop/LICENSES.md#ever-gauzy-platform-community-edition-license)
- [Ever® Gauzy™ Platform Small Business](https://github.com/ever-co/ever-gauzy/blob/develop/LICENSES.md#ever-gauzy-platform-small-business-license)
- [Ever® Gauzy™ Platform Enterprise](https://github.com/ever-co/ever-gauzy/blob/develop/LICENSES.md#ever-gauzy-platform-enterprise-license)
#### The default Ever® Gauzy™ Platform license, without a valid Ever® Gauzy™ Platform Enterprise or Ever® Gauzy™ Platform Small Business License agreement, is the Ever® Gauzy™ Platform Community Edition License
#### Please see [LICENSE](LICENSE.md) for more information on licenses. You can also [compare our offering](https://ever.co/compare-gauzy/#compare)
#### Please see [LICENSES.md](LICENSES.md) for more information on licenses. You can also [compare our offering](https://ever.co/compare-gauzy/#compare)
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fever-co%2Fgauzy.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fever-co%2Fgauzy?ref=badge_large)
@@ -360,11 +360,15 @@ You can also view a full list of our [contributors tracked by Github](https://gi
<img src="https://contributors-img.web.app/image?repo=ever-co/ever-gauzy" />
## 📊 Activity
![Alt](https://repobeats.axiom.co/api/embed/7c6f6c3bf56fd91647549cf4ae70af49ed5ee106.svg 'Repobeats analytics image')
## ⭐ Star History
[![Star History Chart](https://stats-github.ever.co/svg?repos=ever-co/ever-gauzy&type=Date)](https://stats-github.ever.co/#ever-co/ever-gauzy&Date)
## ❤️ Powered By
## 💪 Powered By
<p>
<a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=ever-co">
@@ -372,6 +376,10 @@ You can also view a full list of our [contributors tracked by Github](https://gi
</a>
</p>
## ❤️ Featured On
<a href="https://openalternative.co/ever-gauzy?utm_source=openalternative&utm_medium=badge&utm_campaign=embed&utm_content=tool-ever-gauzy" target="_blank"><img src="https://openalternative.co/ever-gauzy/badge.svg?theme=dark&width=200&height=50" width="200" height="50" alt="Ever Gauzy badge" loading="lazy" /></a>
## ©️ Copyright
#### Copyright © 2019-present, Ever Co. LTD. All rights reserved
@@ -2,12 +2,14 @@
// `@forward` re-exports to importers but does NOT bring members into this file,
// so `nb-theme()` needs its own `@use`.
@use 'themes' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
nb-card-body {
height: calc(100vh - 17.5rem) !important;
}
}
// REMOVED: `:host nb-card-body { height: calc(100vh - 17.5rem) !important }`.
// A hand-tuned viewport constant that out-ranked the flex column `_pg-card`
// gives every other list page, so this table stood at a different height from
// its siblings and had back the problem the shared partial exists to solve — a
// gap under a short table, a clipped long one. The same override was taken off
// Expenses for the same reason.
// Approve / Refuse are plain `[nbButton] status="success|danger"` buttons and
// now take their colour from the theme like every other action in the app (see
@@ -27,3 +29,28 @@ ngx-request-approval-action {
gap: 10px;
padding: 0 5px;
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale.
//
// The default `$actions` selector (`nb-card-header`) is the right one here
// precisely because this page puts a SECOND button beside the title — "Approval
// policy" — outside `.gauzy-button-container`. Naming the container alone would
// have left that one at full size next to a compacted toolbar.
@include ga-hub.list-page();
// ── The Name cell ───────────────────────────────────────────────────────
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, which pins
// a 20px image inside a pill padded 3px: 26px of chip, 2px short of the 28px
// `ngx-contact-links` costs the Invoices Contact column, and so 2px short of the
// Invoices row. Only that inset is restated — the image and the name stay the
// renderer's own 20px/12px — which takes the chip to the same 28px and the row
// to the same height.
//
// Table-scoped: `ga-card-grid` instantiates the same `renderComponent` for the
// card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
@@ -74,9 +74,11 @@
></angular2-smart-table>
</div>
</div>
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
<div class="pagination-container">
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
</div>
} @else {
<ga-card-grid
[loading]="loading"
@@ -1,6 +1,7 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-cards' as *;
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.manage-btn {
display: flex;
@@ -41,3 +42,29 @@
}
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale.
//
// The pager needed the `.pagination-container` wrapper every sibling list page
// already had — this template rendered a bare `<ngx-pagination>` straight into
// the card body, so it took neither the shared right-alignment nor the compact
// `<nav><ul><li>` sizing, and sat left-aligned at full size under a table that
// had just been brought down to the shared density.
@include ga-hub.list-page();
// ── The Full Name cell ───────────────────────────────────────────────────────
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, which pins
// a 20px image inside a pill padded 3px: 26px of chip, 2px short of the 28px
// `ngx-contact-links` costs the Invoices Contact column, and so 2px short of the
// Invoices row. Only that inset is restated — the image and the name stay the
// renderer's own 20px/12px — which takes the chip to the same 28px and the row
// to the same height.
//
// Table-scoped: `ga-card-grid` instantiates the same `renderComponent` for the
// card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-dialogs' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.education-form {
width: 100%;
@@ -56,3 +57,20 @@ form {
@include input-appearance(2rem, var(--gauzy-sidebar-background-4));
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// A tab of the candidate profile, brought onto the same footing as the
// Candidates list it is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager.
//
// The default `$actions` (`nb-card-header`), where this used to pass `null` on
// the grounds that the tab had no compact toolbar. It has one: the header holds
// `ngx-gauzy-button-action` unconditionally — Add, the layout selector, and the
// Edit / Delete group in table mode — every button of it `size="small"`, which is
// the exact row the mixin's button block is written for. `null` left that row at
// full size over rows that had just been compacted.
//
// The add FORM is in this header too, but its Save and Cancel take no `size`, so
// they are medium; `page-header-buttons()` only redefines the small-button and
// icon-button-small tokens, and reaches neither.
@include ga-hub.list-page();
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-dialogs' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.experience-form {
width: 100%;
@@ -54,3 +55,21 @@ form {
@include input-appearance(2rem, var(--gauzy-sidebar-background-4));
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// A tab of the candidate profile, brought onto the same footing as the
// Candidates list it is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager.
//
// The default `$actions` (`nb-card-header`), where this used to pass `null` on
// the grounds that the tab had no compact toolbar. It has one: the header holds
// `ngx-gauzy-button-action` unconditionally — Add, the layout selector, and the
// Edit / Delete group in table mode — every button of it `size="small"`, which is
// the exact row the mixin's button block is written for. `null` left that row at
// full size over rows that had just been compacted.
//
// The add form's Save and Cancel sit in this header too and ARE `size="small"`,
// so they come down with it. That is the point rather than a side effect: they
// are the same size class as the toolbar beside them, and leaving them out would
// put two different small-button scales on one header line.
@include ga-hub.list-page();
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-dialogs' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.form {
padding: 0;
@@ -288,3 +289,23 @@
max-height: calc(100vh - 28rem);
@include input-appearance(2rem, var(--gauzy-sidebar-background-4));
}
// ── The canonical list-page block ────────────────────────────────────────────
// A tab of the candidate profile, brought onto the same footing as the
// Candidates list it is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager.
//
// `$actions: '.main-btn'`, not `null`. This tab puts TWO different things in its
// header, on an `@if (!showAddCard)` / `@else`: the list state renders
// `.main-btn`, which is exactly the compact toolbar the mixin's button block is
// written for (`ngx-gauzy-button-action` — Add, plus View / Edit / Delete), and
// the form state renders `.add-card`. `null` left that toolbar at full size above
// a table that had just been compacted.
//
// The default `nb-card-header` would have worked too — `page-header-buttons()`
// only defines the `--button-*-small-*` and `--icon-button-*-small-*` tokens, and
// the form's Save and Cancel take no `size`, so they are medium and no container
// named here reaches them. `.main-btn` is the narrower statement of the same
// thing: it names the row the tokens are FOR, rather than the header that happens
// to contain it.
@include ga-hub.list-page('.main-btn');
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-overrides' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.checkboxes {
display: flex;
@@ -29,3 +30,16 @@
}
@include input-appearance(2rem, var(--gauzy-card-1));
}
// ── The canonical list-page block ────────────────────────────────────────────
// A tab of the candidate profile, brought onto the same footing as the
// Candidates list it is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager.
//
// The default `$actions` (`nb-card-header`), where this used to pass `null` on
// the grounds that the tab had no compact toolbar. It has one, and nothing else:
// the header is a single `ngx-gauzy-button-action` — Add, the layout selector,
// and the Edit / Delete group in table mode — every button of it `size="small"`,
// which is the exact row the mixin's button block is written for. `null` left
// that row at full size over rows that had just been compacted.
@include ga-hub.list-page();
@@ -1,6 +1,7 @@
@use 'gauzy/_gauzy-cards' as *;
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-overrides' as ga-overrides;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host .card {
display: flex;
@@ -91,3 +92,12 @@
@include nb-ltr(padding-right, 0.5rem !important);
@include nb-rtl(padding-left, 0.5rem !important);
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block the Candidates list this page sits beside now carries: density
// tokens, the table surface, row hover/selection, the tag-chip stack, the status
// pills, the pager and the toolbar scale.
//
// `$actions` is `.card-header` rather than `nb-card-header`: this component's
// root IS an `nb-card-body`, and its action row is a plain div inside it.
@include ga-hub.list-page('.card-header');
@@ -1,4 +1,5 @@
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
/*
* REMOVED: the `.contact-list` block (`.member-card`, `.contact-info`,
@@ -52,3 +53,12 @@
flex: 0 0 auto;
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// Customers, Clients, Leads and Visitors all render this component, and all four
// take the same table treatment as Invoices: density tokens, the table surface,
// row hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale. The buttons sit in `nb-card-header`, so the default `$actions`
// selector reaches them.
@include ga-hub.list-page();
@@ -85,6 +85,22 @@ nb-card {
@include ga-hub.rows();
}
// ── The page-header action row ───────────────────────────────────────────────
// The seventh block of the list-page contract, and the one this sheet never
// had: the six above brought the TABLE down to the shared density while "+ Add",
// the selection strip, "Manage invites" and the grid-vs-list selector all stayed
// at their full 0.375rem/0.875rem box around a 1rem icon. A toolbar a size and a
// half larger than the rows beneath it is the most visible half of the
// inconsistency, so this page read as unconverted even though most of it was.
//
// `nb-card-header` rather than `.gauzy-button-container`, because this header
// holds a second group: "Manage invites" is a `<button nbButton class="action"
// size="small">` in `.card-header-title`, outside the toolbar container. Naming
// only the container would have left it behind — see the note in the mixin.
:host nb-card-header {
@include ga-hub.page-header-buttons();
}
// ── Status pills ─────────────────────────────────────────────────────────────
// Every other list page routes its statuses through `ga-status-badge`, which is
// what the canonical block points `status-badge-tints()` at. This page does not:
@@ -107,53 +123,21 @@ nb-card {
}
// ── Full Name cell ───────────────────────────────────────────────────────────
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, and that
// context in `avatar.component.scss` pins a 20px image, a 12px name and an 8px
// gap inside a padded tinted pill — 26px of chip in rows the density tokens
// build to a 1rem line box, which made Full Name both the widest-reading and
// the tallest cell in the table and left every row taller than the equivalent
// row on Payments.
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, and this
// sheet used to shrink it: a 1rem image, a 0.6875rem name and a 2px-inset pill,
// about 20px of chip, so that a person cost the row the same 1rem line box a
// glyph does.
//
// Sized off the `gauzy-people-*` tokens, so the chip matches the scale the rest
// of the row already follows. Table-scoped, not page-wide: `ga-card-grid`
// instantiates the same `renderComponent` and its cards have room for the full
// treatment.
// That block is gone — it is what made this table read a size smaller than the
// Invoices table it is meant to match. The image and the name are the renderer's
// own 20px/12px again; only the pill's vertical inset is restated, which takes a
// 20px image to the same 28px that the 18px-plus-5px `ngx-contact-links` chip
// costs the Invoices Contact column. See `avatar-chip()` for the arithmetic.
//
// SPECIFICITY: `:host-context(.report-table) X` shims to
// `.report-table[_nghost] X[_ngcontent]` — (0,4,0) for `.inner-wrapper`,
// `.link-text` and `.image-container` — while `:host ::ng-deep` buys only the
// one `[_nghost]` attribute. Naming `.report-table` (it sits on the host
// element) plus one doubled class clears those. The nested `img` is the
// exception: it shims to `.report-table[_nghost] .image-container[_ngc]
// img[_ngc]`, (0,5,1), which no reachable combination of doubled classes here
// beats without reading as line noise, so those two declarations are
// `!important` — the same register that rule already uses for its own
// `border-radius`.
:host ::ng-deep angular2-smart-table ngx-avatar.report-table {
.inner-wrapper.inner-wrapper {
gap: var(--gauzy-people-gap, 0.375rem);
// Keeps the original asymmetry — a hair of inset around the image, more
// breathing room after the name — just scaled to the smaller chip. Not zero
// on the image side: `.inner-wrapper` is a stadium with `overflow: hidden`,
// so a flush image would be clipped by the left-hand curve.
@include nb-ltr(padding, 0.125rem 0.375rem 0.125rem 0.125rem);
@include nb-rtl(padding, 0.125rem 0.125rem 0.125rem 0.375rem);
}
.link-text.link-text {
font-size: var(--gauzy-people-font-size, 0.6875rem);
line-height: var(--gauzy-people-avatar-size, 1rem);
}
.image-container.image-container {
width: var(--gauzy-people-avatar-size, 1rem);
height: var(--gauzy-people-avatar-size, 1rem);
img {
width: var(--gauzy-people-avatar-size, 1rem) !important;
height: var(--gauzy-people-avatar-size, 1rem) !important;
}
}
// Table-scoped, as the old block was: `ga-card-grid` instantiates the same
// `renderComponent` for the card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
// ── Screen Capture cell ──────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
@use 'gauzy/_gauzy-cards' as *;
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-overrides' as ga-overrides;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
nb-card-body {
background: nb-theme(gauzy-card-2);
@@ -31,6 +32,20 @@ nb-card-body {
.table-scroll-container {
flex-grow: 10;
max-height: unset;
// Against `card-list-page()`'s `min-height: 100%`. That default is for the
// pages whose container sits in a card body that is NOT a flex column,
// where nothing else would make a short list fill the card. This body IS
// one, and it holds the toolbar above the table: 100% of the body plus the
// toolbar's own height is more than the body has, so the surface ran past
// the bottom of the card. `flex-grow: 10` above already fills whatever the
// toolbar leaves, and `min-height: 0` is what lets a flex item shrink back
// to that — the default `auto` floors it at its content.
//
// Here rather than after the include: this selector is (0,5,3) against the
// mixin's (0,3,1), so it holds no matter where the include lands — which
// matters because the four sheets that `@forward` this file emit their own
// `card-list-page()` call after everything in it.
min-height: 0;
}
.grid {
flex-grow: 10;
@@ -118,3 +133,34 @@ nb-card-body {
nb-card {
margin-bottom: 0.5rem;
}
// ── The list-page contract, for a table built by hand ────────────────────────
//
// Expense categories is the ORIGIN of the `nb-card > nb-card-body.custom-table`
// row pattern — Vendors and Employment types `@forward` this file, and
// Positions, Employee levels and Documents reach it through Vendors — and it was
// the one page of the six still drawn the old way: every row its own card
// (`gauzy-card-1`, a radius, a 0.5rem bottom margin) floating in an unbounded
// scroll container, and a 6px rail that only exists while a row is selected, so
// clicking one shifted it sideways.
//
// `card-list-page()` is the part of the list-page contract that does not depend
// on `angular2-smart-table`: the density tokens, the toolbar scale, the bounded
// table surface, and rows flattened onto it with one hairline between them and a
// rail reserved at rest. Placed at the END of the file, after the `.custom-table`
// paint above, because the mixin deliberately writes at that same specificity and
// relies on source order to settle it.
//
// The five sheets that forward this one call `card-list-page()` themselves with
// their own arguments; those calls are emitted after this one and still win, so
// Vendors keeps its 1.125rem row gutter and Employment types keeps its card-body
// toolbar. The cost is one duplicated block in each of those sheets — the same
// declarations, immediately overridden — which is the price of the row treatment
// finally reaching the page that defines the pattern.
//
// `$actions: '.gauzy-button-container'`, not the default `nb-card-header`: this
// page's header holds the back arrow and the title and nothing else — the
// toolbar is the first child of the card BODY, above the table. The default
// named a block with no buttons in it, so the one thing the toolbar scale was
// there to compact was the one thing it never reached.
@include ga-hub.card-list-page('.gauzy-button-container');
@@ -22,3 +22,12 @@
// different height from its siblings and had back the problem the shared
// partial exists to solve (a gap under a short table, a clipped long one).
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager's
// `<nav><ul><li>` and the toolbar scale. Markup is the standard list shape, so
// the default `$actions` selector (`nb-card-header`) reaches this page's
// `.gauzy-button-container` and its sibling filter buttons alike.
@include ga-hub.list-page();
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/tabset-actions' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
nb-tabset {
@@ -62,3 +63,14 @@
:host .gauzy-button-container {
@include tabset-action-row(1rem);
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale. One `#tableLayout` template serves all three record tabs (time
// frames, KPIs, goal general settings), so the block reaches every one of them.
//
// `$actions` is the toolbar, not `nb-card-header`: this page's header holds only
// the title, and the action row rides the tab strip inside the card BODY (see
// `tabset-action-row()` above).
@include ga-hub.list-page('.gauzy-button-container');
@@ -1,4 +1,5 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.global {
display: flex;
@@ -235,3 +236,182 @@ h6 {
}
}
}
// ── The list-page contract ───────────────────────────────────────────────────
// The article list is built BY HAND — a `.table-scroll` holding one
// `.custom-table` div per article — so nothing in `list-page()` ever reached it:
// every selector in that bundle is anchored on `angular2-smart-table`. And
// `card-list-page()`, which is the mixin for exactly this shape (Vendors,
// Positions, Employee levels, Documents and Employment types all take it), is
// anchored on `.table-scroll-container` — a class this page deliberately does
// not use, because the global rule for it in `_overrides.scss` would overwrite
// the height chain here (`calc(100vh - 21rem)`, and `calc(100vh - 28.7rem)` once
// the filter row opens). So the same blocks are spelled out below against this
// page's own markup.
//
// What they replace: 14px/17px body text under a 16px article name, in rows
// padded 1rem and drawn as separately floating cards with their own radius and
// an 8px gap to the next, on a container with no ring at all — the last list
// under Organization still drawn the way the whole section was before the
// density pass.
// The tokens sit on `:host` rather than on the list, and that placement is the
// point: `ngx-avatar` and `ngx-no-data-message` are separate components, and
// custom properties are the only thing that crosses those boundaries.
:host {
@include ga-hub.density-tokens();
}
// `nb-card-header`, not `.gauzy-button-container` — this header holds THREE
// button groups and only one of them is in that container: "Add article" sits on
// the category line above it, and "Clear" sits in the filter row below it. Every
// metric the mixin sets is a custom property, so one declaration on the header
// reaches all three, `ngx-gauzy-button-action`'s own template included.
:host nb-card-header {
@include ga-hub.page-header-buttons();
}
// ── One surface, with the rows on it ─────────────────────────────────────────
// `row-gap` and the container's own padding go with the cards: the rows are
// flush now and carry their own inset, the same as every `<tr>` in the app.
:host .table-scroll {
@include ga-hub.surface();
padding: 0;
row-gap: 0;
}
// The row itself, flattened onto that surface. Padding, type scale and the
// hairline are `card-list-page()`'s values, so an article row and a vendor row
// measure the same.
//
// The rule is drawn INSIDE the row's box, so nothing moves; the transparent
// leading edge is there at rest so that selecting a row cannot shift it either.
:host .table-scroll .custom-table {
background: transparent;
border-radius: 0;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
line-height: var(--gauzy-table-line-height, 1.25rem);
box-shadow: inset 0 -1px 0 0 var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
border-inline-start: 3px solid transparent;
}
// The rule under the last article would be a second edge just inside the
// surface's own.
:host .table-scroll .custom-table:last-of-type {
box-shadow: none;
}
:host .table-scroll .custom-table:hover:not(.selected) {
background: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12));
}
// Selection was an 8px box-shadow rail hung outside the row's leading edge, in
// the SAME neutral tint as the fill — so it read as part of the row rather than
// as a marker, and the row jumped 8px sideways on click. The primary accent on
// the edge that is already reserved above, which is what every other card list
// in the app uses; the hairline is restated because this rule replaces the
// `box-shadow` that carried it.
:host .table-scroll .custom-table.selected {
background: var(--gauzy-active-tint, rgba(126, 126, 143, 0.2));
border-inline-start-color: var(--color-primary-default);
box-shadow: inset 0 -1px 0 0 var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
}
// ── Inside the row ───────────────────────────────────────────────────────────
:host .table-scroll .custom-table {
.row-name {
align-items: center;
gap: 0.375rem;
}
// The article name was 16px/19px — a step above the page's own control text
// and two above a table cell, which is what made one article read as a card
// heading rather than as a row.
.name {
margin-bottom: 0;
padding-bottom: 0;
font-size: 0.8125rem;
line-height: var(--gauzy-table-line-height, 1.25rem);
}
// The description and the byline, at the secondary scale the chips use.
> p {
margin-bottom: 0.25rem;
color: var(--gauzy-text-color-2, var(--text-hint-color));
}
// DRAFT and EMPLOYEES: the same box a status pill gets on every other list
// page, from the same three tokens. At 14px/600 in a 12px-radius lozenge they
// were the loudest thing in the row and a good deal taller than the name
// beside them. `margin-inline-start` because the pair sat on a `margin-left`
// that stayed on the left in RTL.
.draft,
.privacy {
min-height: var(--gauzy-table-badge-height, 1rem);
margin: 0;
margin-inline-start: 0.375rem;
padding-block: var(--gauzy-table-badge-padding-y, 0.1875rem);
padding-inline: var(--gauzy-table-chip-padding-x, 0.3125rem);
border-radius: var(--gauzy-table-badge-radius, 0.25rem);
font-size: var(--gauzy-table-chip-font-size, 0.625rem);
line-height: var(--gauzy-table-chip-line-height, 0.75rem);
}
// "Written by …" and "Created at …". `.create-at` carried a flat
// `margin-left: 100px` — a hard offset that neither flipped in RTL nor tracked
// the width of the names beside it; `auto` on the leading edge parks the
// timestamp against the row's trailing edge instead, which is where the
// equivalent column sits on a real table.
.user-info {
align-items: center;
gap: 0.5rem;
font-size: var(--gauzy-table-chip-font-size, 0.625rem);
line-height: var(--gauzy-table-chip-line-height, 0.75rem);
color: var(--gauzy-text-color-2, var(--text-hint-color));
p {
margin-bottom: 0;
}
// The negative pull existed to lift 30px avatars back onto the "Written by"
// line; at the people scale they are on that line already, so it only lifted
// them off it.
.image-wrap {
margin: 0;
gap: 0.125rem;
}
.create-at {
align-items: center;
gap: 0.25rem;
margin: 0;
margin-inline-start: auto;
}
.icon {
margin: 0;
font-size: 0.875rem;
}
}
}
// The author avatars, at the people scale the rest of the app's rows use. Same
// selector as the block higher up in this file (which pinned 24px), so it is
// source order that settles it.
//
// `.image-container` AND the image: the container is what RESERVES the space, and
// avatar.component.scss sizes it on its own (`size="sm"` in the template, so a
// 32px box). Shrinking only the picture left each author sitting in a 32px slot
// holding a 20px image — 12px of empty gutter before the name, on every avatar in
// the row. `!important` for the same reason the image line carries it: the rules
// being overridden nest under the avatar's `:host`, which shims to (0,4,0), and
// this block reaches them from outside at (0,2,1).
:host ::ng-deep ngx-avatar {
.image-container,
img {
height: var(--gauzy-people-avatar-size, 1rem) !important;
width: var(--gauzy-people-avatar-size, 1rem) !important;
}
}
@@ -1 +1,11 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// Density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager's `<nav><ul><li>` and the toolbar scale — the same
// block Invoices carries. This page has the standard list markup
// (`nb-card-header.card-custom-header` > `.gauzy-button-container`, a
// `.table-scroll-container` and a `.pagination-container`), so the default
// `$actions` selector reaches its toolbar unchanged.
@include ga-hub.list-page();
@@ -39,15 +39,17 @@
</div>
</div>
<div class="col-sm-6">
<label for="inputOrganizationContact" class="label"
>{{ 'INVOICES_PAGE.CONTACT' | translate }}
</label>
<ga-contact-select
[addTag]="true"
[placeholder]="'POP_UPS.ALL_CONTACTS' | translate"
formControlName="organizationContact"
(onChanged)="selectOrganizationContact($event)"
></ga-contact-select>
<div class="form-group">
<label for="inputOrganizationContact" class="label">
{{ 'INVOICES_PAGE.CONTACT' | translate }}
</label>
<ga-contact-select
[addTag]="true"
[placeholder]="'POP_UPS.ALL_CONTACTS' | translate"
formControlName="organizationContact"
(onChanged)="selectOrganizationContact($event)"
></ga-contact-select>
</div>
</div>
</div>
<div class="row">
@@ -119,13 +121,14 @@
</div>
</div>
<div class="row">
<div class="col-sm-12 mb-4">
<ga-tags-color-input
[selectedTags]="form.get('tags').value"
(selectedTagsEvent)="selectedTagsEvent($event)"
[isOrgLevel]="true"
>
</ga-tags-color-input>
<div class="col-sm-12">
<div class="form-group">
<ga-tags-color-input
[selectedTags]="form.get('tags').value"
(selectedTagsEvent)="selectedTagsEvent($event)"
[isOrgLevel]="true"
></ga-tags-color-input>
</div>
</div>
</div>
<div class="row">
@@ -183,6 +186,7 @@
placeholder="{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_PROJECTS' | translate }}"
formControlName="project"
id="inputProject"
class="item-selector"
(change)="selectProject($event)"
[multiple]="true"
appendTo="body"
@@ -211,6 +215,7 @@
placeholder="{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_TASKS' | translate }}"
formControlName="task"
id="inputTask"
class="item-selector"
(change)="selectTask($event)"
[multiple]="true"
appendTo="body"
@@ -239,6 +244,7 @@
placeholder="{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_PRODUCTS' | translate }}"
formControlName="product"
id="inputProduct"
class="item-selector"
(change)="selectProduct($event)"
[multiple]="true"
appendTo="body"
@@ -267,6 +273,7 @@
placeholder="{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_EXPENSES' | translate }}"
formControlName="expense"
id="inputExpense"
class="item-selector"
(change)="selectExpense($event)"
[multiple]="true"
appendTo="body"
@@ -309,7 +316,6 @@
status="success"
[disabled]="!invoiceType"
nbButton
class="ml-3"
>
{{ 'INVOICES_PAGE.INVOICE_TYPE.GENERATE_FOR_UNINVOICED_EXPENSES' | translate }}
</button>
@@ -489,7 +495,7 @@
</div>
</form>
@if (shouldLoadTable) {
<div>
<div class="items">
<div class="table-scroll-container custom-table">
<angular2-smart-table
[class.ga-table-loading]="loading"
@@ -517,12 +523,12 @@
</div>
}
</nb-card-body>
<nb-card-footer class="text-left">
<button class="mr-3" size="small" status="basic" outline nbButton (click)="cancel()">
<nb-card-footer>
<button size="small" status="basic" outline nbButton (click)="cancel()">
{{ 'BUTTONS.CANCEL' | translate }}
</button>
<button
class="mr-3 gray"
class="gray"
(click)="addInvoice('DRAFT')"
size="small"
status="primary"
@@ -532,7 +538,6 @@
{{ 'BUTTONS.SAVE_AS_DRAFT' | translate }}
</button>
<button
class="mr-3"
(click)="sendToContact()"
status="success"
size="small"
@@ -542,7 +547,6 @@
{{ 'BUTTONS.SAVE_AND_SEND_CONTACT' | translate }}
</button>
<button
class="mr-3"
(click)="sendViaEmail()"
status="success"
size="small"
@@ -7,14 +7,28 @@ $shadow: 0 0 0 nb-theme(button-outline-width)
$alpha: 0.1
),
inset nb-theme(button-outline-focus-inset-shadow-length) transparent;
// "Generate invoice items" — the one action that has to be taken before this
// form has anything to save, so it is the only button in the body and it needs
// to read as one.
//
// It was drawn `background-color: var(--gauzy-card-2)` with a TRANSPARENT
// border: the exact colour of the card body behind it and no edge, so at rest it
// was green text floating on the page with no button around it, and hovering it
// pinned the border transparent again so nothing appeared. Same green, drawn the
// way every other outline button in the app is drawn (see the `.green` block in
// `gauzy/_gauzy-dialogs`): a translucent ring at rest that goes solid on hover.
[nbButton].gen.appearance-outline.status-basic {
background-color: var(--gauzy-card-2);
border-color: transparent;
background-color: transparent;
border-color: rgba(
$color: $green,
$alpha: 0.4
);
box-shadow: $shadow;
border-width: 2px;
color: $green;
&:hover {
border-color: transparent;
border-color: $green;
}
}
[nbButton].gen.appearance-outline:hover {
@@ -365,7 +365,7 @@
</div>
</form>
@if (shouldLoadTable) {
<div>
<div class="items">
<div class="table-scroll-container custom-table">
<angular2-smart-table
[class.ga-table-loading]="loading"
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-dialogs' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// Row actions of the invoice-item grid. These were painted with literals: the
// add/save pill as `#00d68f` and — the visible one — the cancel/delete pill as a
@@ -62,6 +63,10 @@
border-radius: var(--border-radius);
padding: 0.375rem 0.75rem;
font-size: nb-theme(text-paragraph-font-size);
// Money, so the digits have to line up as the figures change: with the
// proportional default the whole chip reflows every time a quantity is
// edited in the grid above it.
font-variant-numeric: tabular-nums;
}
}
@@ -73,26 +78,221 @@
@include dialog(var(--gauzy-card-2), var(--gauzy-card-1));
}
// ── The title line ───────────────────────────────────────────────────────────
// A bare `d-flex` aligned the back arrow and the heading on their box TOPS, and
// the heading's own 0.5rem bottom margin then tipped it half a step below the
// arrow beside it. Centring the row and dropping that margin is what
// invoice-view's header already does; this puts Add / Edit on the same line.
nb-card-header {
&.d-flex {
align-items: center;
gap: 0.5rem;
}
// invoice-edit wraps the arrow and the title in these (and hangs the Payments
// button off the outer one); invoice-add puts them straight in the header.
.card-header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
h4 {
margin-bottom: 0;
}
}
// ── The form body ────────────────────────────────────────────────────────────
// Two columns: the record's own fields on the left, the Discount and Taxes
// panels on the right.
.form {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
margin-bottom: 36px;
// The step down to the item grid belongs to that block (`.items` below), which
// is the only thing that ever follows the form. As a margin here it applied
// just as much when no grid had been generated yet, leaving 36px of nothing
// between the last field and the card footer.
margin-bottom: 0;
.block-wrap {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
// The columns were two 48% blocks pushed apart by `space-between` on a row
// that could not wrap, so the 4% between them shrank with the window and the
// fields inside kept squeezing with no floor. A wrapping row with a real gap
// and a basis wide enough for a pair of half-width controls puts them side
// by side while there is room for both and stacks them when there is not —
// the same behaviour with no breakpoint to keep in step with the grid.
flex-wrap: wrap;
gap: 1rem 1.5rem;
align-items: flex-start;
.block {
width: 48%;
flex: 1 1 22rem;
// A flex item's `min-width` floor is its own content, so without this a
// long contact name or a wide select pushes its column past its share of
// the row instead of the control handling the overflow itself.
min-width: 0;
// Both columns start on the same line. The left one carries `mt-2` in the
// template and the right one carried nothing, so the first panel sat half
// a step above the first field beside it.
margin-top: 0.5rem;
}
}
// The Terms field. A bare `<textarea>` is two rows tall, which in a column of
// one-line controls reads as a slightly-too-tall input rather than somewhere
// to write a paragraph. Still draggable, but only on the axis that has room.
textarea[nbInput] {
min-height: 5rem;
resize: vertical;
}
}
// ── The item-type selectors ──────────────────────────────────────────────────
// Projects / Tasks / Products / Expenses on the Add form: `ng-select`s in
// multiple mode, whose selected values are chips inside the trigger.
//
// The chips already wrapped — `flex-wrap: wrap` on `.ng-value-container` is
// ng-select's own default — but nothing past the first line was ever visible:
// `includes/_ng-select.scss` pins EVERY `.ng-select-container` in the app to
// `height: 40px`, and ng-select's component sheet gives it `overflow: hidden`.
// Between them, picking a fifth project made the four before it disappear.
//
// So let the trigger grow with its content, cap it at two rows of chips, and
// scroll the VALUE box rather than the whole trigger — that keeps the clear
// button and the arrow on their own axis while the chips move under them.
//
// SPECIFICITY: every selector below is spelled out to its full ng-select depth
// on purpose. `@ng-select/ng-select/themes/default.theme.css` is listed after
// `styles.scss` in the app's `styles` array, so its five-class rules win every
// tie; naming the same chain plus this component's `:host` attribute is what
// carries these past it without `!important`.
// The chip's box, and the cap derived FROM it rather than pinned beside it: a
// magic `max-height` next to the padding and line height that decide a chip's
// real height is a pair that drifts the first time either side is retuned.
$chip-line-height: 1.125rem;
$chip-padding-y: 0.125rem;
$chip-padding-x: 0.5rem;
$chip-gap: 0.25rem;
$value-box-padding-y: 0.25rem;
// How many rows of chips the field shows before it starts scrolling.
$visible-chip-rows: 2;
$chip-height: $chip-line-height + $chip-padding-y * 2;
:host ::ng-deep .item-selector.ng-select-multiple {
.ng-select-container {
height: auto;
// NO `align-items` here. It was `flex-start` — on the reasoning that an arrow
// centred against four rows of chips floats halfway down the field — and that
// was the wrong trade: the field is EMPTY or one line deep almost all of the
// time, and top-pinning threw the placeholder and the arrow off the trigger's
// centre in exactly that case. The arrow wrapper has no height of its own, so
// top-aligning it shrank it to its glyph instead of letting it stretch.
//
// Inheriting the shared `center` keeps one line perfectly centred and puts
// the arrow on the middle of a tall chip stack, which is what every
// component library does with a chips input.
}
.ng-select-container .ng-value-container {
flex-wrap: wrap;
// chip↔chip spacing, so the chips carry no margins of their own. The INLINE
// padding is left to the shared `ng-select-overrides()` block, which sets it
// `!important` so every trigger in the app starts its text on one x.
gap: $chip-gap;
// Symmetric, where ng-select's own theme pads this box `5px` at the top and
// nothing at the bottom — which is what tipped the chips above the trigger's
// centre line even before the wrapping was fixed.
padding-block: $value-box-padding-y;
// Two rows of chips and the gap between them, plus this box's own padding
// (`box-sizing: border-box` app-wide, so the padding is inside the cap).
// Anything past that scrolls.
max-height: $visible-chip-rows * $chip-height + ($visible-chip-rows - 1) * $chip-gap +
$value-box-padding-y * 2;
overflow-y: auto;
// The app's scrollbar — the same one the dropdown panels and card bodies use.
scrollbar-width: thin;
&::-webkit-scrollbar {
width: nb-theme(scrollbar-width);
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--gauzy-scrollbar);
border-radius: 1rem;
}
}
// The typing area. ng-select pads it `0 0 3px 3px`, i.e. 3px along the bottom
// and none along the top — so it is the tallest thing on the line and every
// chip beside it sits that much above the line's centre. The chip↔chip `gap`
// above is what separates the caret from the chips now, so the block half of
// that padding has nothing left to do; the inline 3px stays.
.ng-select-container .ng-value-container .ng-input {
padding-block: 0;
}
// One chip. ng-select's theme paints it `#333` on a pale blue with a 2px
// corner — light-mode literals, so in the four dark themes the chips were a
// foreign pale-blue block with near-black text on them. A neutral tint, the
// shared small radius and the same hairline every other small box on this page
// carries, all of which resolve per theme.
.ng-select-container .ng-value-container .ng-value {
// `margin: 0` because the row's spacing is the container's `gap` now.
// ng-select's own theme puts `margin-bottom: 5px` on every chip, which on a
// wrapped row is 5px of dead space under the LAST line as well — the other
// half of why the chips never sat on the centre line.
margin: 0;
padding: $chip-padding-y $chip-padding-x;
background-color: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12));
box-shadow: $action-hairline;
border-radius: var(--gauzy-radius-sm, 0.375rem);
color: var(--gauzy-text-color-1);
font-size: nb-theme(text-caption-font-size);
// Pinned, not left to `normal`: the chip is what sets the height of its row,
// so an unresolved line box would make the row's height depend on the font
// that happened to load — and the two-row cap above is derived from it.
line-height: $chip-line-height;
// The chip is a flex item and a block box; centre its own text in it so a
// chip and the typing caret beside it share one centre line.
display: flex;
align-items: center;
max-width: 100%;
}
// The chip's label comes from this form's own `ng-label-tmp`, which wraps the
// name in a `.selector-template`. A long project name ellipsizes inside the
// chip instead of pushing it past the edge of the field.
.ng-select-container .ng-value-container .ng-value .selector-template {
display: block;
// The chip above is a flex box, and a flex item will not shrink below its own
// content unless it is told it may — without this `text-overflow` never
// engages and a long project name widens the chip instead of picking up an
// ellipsis.
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// The generated item grid, its pager and the totals — one block, stepped away
// from the form above it.
.items {
margin-top: 1.5rem;
}
.footer-block-wrap {
width: 100%;
display: flex;
@@ -109,18 +309,54 @@
width: 98%;
}
// ── The Discount and Taxes panels ────────────────────────────────────────────
// Each one is a Bootstrap `.row` as well as a `.group`, and a `.row` bleeds 15px
// past its container on both sides with negative margins — so the panel was
// drawn wider than the column it belongs to, hanging into the gutter between the
// two columns on one side and off the block on the other. The asymmetric
// `9px 18px 2px 12px` padding was what that bleed had been tuned against.
//
// Zero the bleed, pad the panel evenly, and give it the radius and the hairline
// every other surface on the page already carries. The fill stays where the
// page's other surfaces are painted, on `.content .row.group` below.
.group {
border-radius: 0.6rem;
margin-bottom: 20px;
padding: 9px 18px 2px 12px;
margin: 0 0 1rem;
padding: 1rem;
border-radius: var(--border-radius);
box-shadow: $action-hairline;
.label-group {
font-size: 14px;
display: block;
width: 100%;
// A section title, not a field label: one step above the 0.75rem that
// `input-appearance` gives every `.label`, and in the primary text colour
// rather than the muted one the fields under it use. It was pinned at 14px,
// so it did not follow the root font size either.
font-size: nb-theme(text-subtitle-2-font-size);
font-weight: 600;
line-height: 17px;
line-height: 1.25;
letter-spacing: -0.01em;
text-align: left;
margin-bottom: 18px;
color: var(--gauzy-text-color-1);
margin-bottom: 0.75rem;
}
// The `.row`s INSIDE the panel bleed the same 15px, which put the first field
// flush against the panel's padding edge and the second one past it. A 0.5rem
// gutter keeps a pair of half-width fields inside the panel with a real gap
// between them.
> .row {
margin-inline: -0.5rem;
> [class*='col-'] {
padding-inline: 0.5rem;
}
}
// The panel's own bottom padding is the space under its last row of fields, so
// the field's `.form-group` margin would otherwise double it.
> .row:last-child > [class*='col-'] > .form-group:last-child {
margin-bottom: 0;
}
}
@@ -129,15 +365,40 @@
flex-direction: row;
justify-content: flex-start;
align-items: center;
// Was an `ml-3` utility on the second button. A gap spaces however many
// buttons the selected invoice type puts on the line, and lets them wrap
// rather than overflow the column.
flex-wrap: wrap;
gap: 0.75rem;
}
// Was `20px -1rem`: a negative inline margin that pulled the checkbox out past
// both edges of its column, so it lined up with neither the panels above it nor
// the fields inside them.
.discountCheckbox {
margin: 20px -1rem;
margin: 0 0 1rem;
}
nb-card {
background-color: var(--gauzy-card-2);
}
// The action row under the card body. Its buttons spaced themselves with `mr-3`,
// a Bootstrap utility — so the spacing was `!important`, it had to be repeated
// on every button, and the last one left a trailing 1rem inside the footer. One
// flex line with a gap says it once; the cancel below is for invoice-edit, whose
// template still carries the utility.
nb-card-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
[nbButton] {
margin-right: 0 !important;
}
}
div.button-action {
background-color: var(--gauzy-card-2);
padding: 6px 8px;
@@ -152,8 +413,14 @@ div.button-action {
.custom-table {
display: flex;
flex-direction: column;
background-color: var(--gauzy-card-2);
padding: 12px;
// `gauzy-card-1`, not `-2`: this element also carries
// `.table-scroll-container`, which `list-page()` at the bottom of this file
// paints with the shared table `surface()` — card-1, a hairline and a radius.
// Painting it card-2 here left that ring and radius drawn around a block the
// exact colour of the card body behind it, so the item grid read as a line
// on the page rather than as the bounded table surface every list page has.
background-color: var(--gauzy-card-1);
padding: 0.75rem;
.table {
margin: 0;
}
@@ -173,17 +440,43 @@ div.button-action {
margin-top: 1rem;
}
// Save as draft: the neutral step between the outline Cancel and the two success
// buttons beside it, painted with the grey `_gauzy-dialogs` already shares with
// the rest of the app rather than with a literal repeated here.
//
// The block this replaces set the fill on the button but hung its hover state
// off a `[nbButton]` DESCENDANT of it — markup that does not exist — so the
// button stayed flat under the pointer while every other button in the row
// responded.
:host button.gray {
background-color: rgba(126, 126, 143, 1);
color: nb-theme(text-control-color);
border-color: var(--button-filled-basic-border-color);
[nbButton].appearance-filled.status-basic {
background-color: nb-theme(button-filled-basic-background-color);
border-color: nb-theme(button-filled-basic-border-color);
color: nb-theme(button-filled-basic-text-color);
:hover {
color: nb-theme(text-basic-color);
background-color: rgba($color: rgba(126, 126, 143), $alpha: 0.1);
&[nbButton].appearance-filled {
background-color: $gray;
border-color: $gray;
color: nb-theme(text-control-color);
&:hover:not([disabled]) {
background-color: rgba($color: $gray, $alpha: 0.85);
border-color: rgba($color: $gray, $alpha: 0.85);
}
&:focus:not(:hover):not(:active) {
box-shadow: 0 0 0 nb-theme(button-outline-width) rgba($color: $gray, $alpha: 0.2);
}
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// The invoice-items grid, brought onto the same footing as the Invoices list
// this form is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager. Emitted
// into invoice-add's sheet too, through the `@forward` at the top of that file —
// Add and Edit render the same grid.
//
// The density tokens only reach cell type and padding and the FILTER row's
// controls (see `--gauzy-table-control-height` in `_overrides.scss`); the inline
// row editors this grid is built on keep their own control sizing.
//
// `$actions: null`: there is no page-header action row here. This form's buttons
// are full-size `nb-card-header` form actions (Save, Send, …), not the compact
// list toolbar the mixin's button block is written for.
@include ga-hub.list-page(null);
@@ -1,4 +1,5 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.date-and-value {
display: flex;
@@ -71,3 +72,14 @@
display: flex;
justify-content: space-between;
}
// ── The canonical list-page block ────────────────────────────────────────────
// An invoice's payments are the same table as `/pages/accounting/payments`, one
// click in, so they take the same block: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the toolbar scale.
//
// `$actions` is `.custom-container` — the panel below the invoice summary that
// holds both the toolbar and the table; this page's `nb-card-header` carries only
// the invoice's title. There is no pager on this table, so the pager half of the
// block simply matches nothing.
@include ga-hub.list-page('.custom-container');
@@ -1 +1,9 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// Received invoices and received estimates render this component, and both are
// the same table as `/pages/accounting/invoices` — so they take the same block
// that page does: density tokens, the table surface, row hover/selection, the
// tag-chip stack, the status pills, the pager and the toolbar scale.
@include ga-hub.list-page();
@@ -75,8 +75,8 @@
</nb-accordion-item-header>
<nb-accordion-item-body>
<form [formGroup]="searchForm" (ngSubmit)="search()">
<div class="row w-100">
<div class="col-sm-4">
<div class="row w-100 search-filters">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputInvoiceNumber" class="label">
<span>
@@ -103,7 +103,7 @@
/>
</div>
</div>
<div class="col-sm-3">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputInvoiceDate" class="label">
<span>
@@ -137,7 +137,7 @@
</div>
</div>
</div>
<div class="col-sm-3">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputDueDate" class="label">{{
'INVOICES_PAGE.DUE_DATE' | translate
@@ -157,9 +157,7 @@
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-3">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputOrganizationContact" class="label">
<span>{{ 'INVOICES_PAGE.CONTACT' | translate }}</span>
@@ -171,7 +169,7 @@
></ga-contact-select>
</div>
</div>
<div class="col-sm-2">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputTotalValue" class="label">
{{ 'INVOICES_PAGE.TOTAL_VALUE' | translate }}
@@ -186,7 +184,7 @@
/>
</div>
</div>
<div class="col-sm-2">
<div class="col search-filter-col">
<div class="form-group">
<ga-currency
formControlName="currency"
@@ -196,15 +194,17 @@
</ga-currency>
</div>
</div>
<div class="col-sm-2">
<ga-tags-color-input
[selectedTags]="searchForm.get('tags').value"
(selectedTagsEvent)="selectedTagsEvent($event)"
[isOrgLevel]="true"
>
</ga-tags-color-input>
<div class="col search-filter-col">
<div class="form-group">
<ga-tags-color-input
[selectedTags]="searchForm.get('tags').value"
(selectedTagsEvent)="selectedTagsEvent($event)"
[isOrgLevel]="true"
>
</ga-tags-color-input>
</div>
</div>
<div class="col-sm-2">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputStatus" class="label">
{{ 'INVOICES_PAGE.STATUS' | translate }}
@@ -240,30 +240,20 @@
</div>
</div>
</div>
<div class="row">
<div>
<button
type="submit"
status="success"
class="ml-3"
size="small"
nbButton
>
{{ 'BUTTONS.SEARCH' | translate }}
</button>
</div>
<div>
<button
type="reset"
status="basic"
outline
size="small"
nbButton
(click)="reset()"
>
{{ 'BUTTONS.RESET' | translate }}
</button>
</div>
<div class="row search-actions">
<button type="submit" status="success" size="small" nbButton>
{{ 'BUTTONS.SEARCH' | translate }}
</button>
<button
type="reset"
status="basic"
outline
size="small"
nbButton
(click)="reset()"
>
{{ 'BUTTONS.RESET' | translate }}
</button>
</div>
</form>
</nb-accordion-item-body>
@@ -303,80 +293,167 @@
</div>
</nb-accordion-item-header>
<nb-accordion-item-body>
<div class="row comments-container">
<div class="col-6">
<div class="comments-container">
<section class="comment-panel comment-composer">
<header class="comment-panel-header">
<nb-icon icon="edit-2-outline" pack="eva"></nb-icon>
<span class="comment-panel-title">
{{ 'BUTTONS.ADD_COMMENT' | translate }}
</span>
</header>
@if (selectedInvoice) {
{{ 'INVOICES_PAGE.COMMENT' | translate }}
<form
class="comment-form"
[formGroup]="historyForm"
#historyFormDirective="ngForm"
(ngSubmit)="addComment(historyFormDirective)"
>
<div class="col-8 mt-3 px-0">
<input
nbInput
type="text"
placeholder="{{ 'INVOICES_PAGE.TITLE' | translate }}"
fullWidth
formControlName="title"
/>
</div>
<div class="col px-0 mt-3">
<textarea
nbInput
placeholder="{{ 'INVOICES_PAGE.COMMENT' | translate }}"
fullWidth
formControlName="comment"
></textarea>
</div>
<div class="mt-3">
<button
type="submit"
class="mx-0"
nbButton
status="info"
[disabled]="historyForm.invalid"
>
{{ 'BUTTONS.ADD_COMMENT' | translate }}
</button>
<div class="comment-form-row">
<span class="comment-avatar" aria-hidden="true">
@if (currentUser?.imageUrl) {
<img [src]="currentUser.imageUrl" alt="" />
} @else {
{{ authorInitials(currentUser?.name) }}
}
</span>
<div class="comment-form-fields">
<input
nbInput
type="text"
fullWidth
fieldSize="small"
class="comment-form-title"
formControlName="title"
placeholder="{{
'FORM.PLACEHOLDERS.TITLE' | translate
}}"
/>
<div class="comment-form-box">
<textarea
nbInput
fullWidth
fieldSize="small"
rows="3"
formControlName="comment"
placeholder="{{
'INVOICES_PAGE.COMMENT_PLACEHOLDER' | translate
}}"
></textarea>
<div class="comment-form-actions">
<button
type="button"
nbButton
ghost
size="tiny"
status="basic"
[disabled]="historyForm.pristine"
(click)="resetComment(historyFormDirective)"
>
{{ 'BUTTONS.RESET' | translate }}
</button>
<button
type="submit"
nbButton
size="tiny"
status="primary"
[disabled]="historyForm.invalid"
>
<nb-icon
icon="paper-plane-outline"
pack="eva"
></nb-icon>
{{ 'BUTTONS.ADD_COMMENT' | translate }}
</button>
</div>
</div>
</div>
</div>
</form>
}
</div>
@if (!disableButton) {
<div class="col-6">
{{ 'INVOICES_PAGE.COMMENTS' | translate }}
<div class="comments w-100">
<nb-list class="history-list">
@for (history of histories; track history) {
<nb-list-item
class="history-list-item"
style="align-items: baseline"
>
<div class="d-flex">
<div>
<ngx-avatar
[src]="history.user.imageUrl"
[name]="history.user.name"
[value]="history.user"
></ngx-avatar>
<div class="history-date history-item">
{{ history.createdAt | dateTimeFormat }}
</div>
<div class="history-item history-title">
<span>{{ history.title }}</span>
</div>
<div class="history-item history-comment">
<span>{{ history.action }}</span>
</div>
</div>
</div>
</nb-list-item>
}
</nb-list>
} @else {
<div class="comment-empty">
<nb-icon icon="file-text-outline" pack="eva"></nb-icon>
<span>
{{
(!isEstimate
? 'INVOICES_PAGE.SELECT_INVOICE_TO_VIEW_HISTORY'
: 'INVOICES_PAGE.ESTIMATES.SELECT_ESTIMATE_TO_VIEW_HISTORY'
) | translate
}}
</span>
</div>
}
</section>
<section class="comment-panel comment-thread">
<header class="comment-panel-header">
<nb-icon icon="message-square-outline" pack="eva"></nb-icon>
<span class="comment-panel-title">
{{ 'INVOICES_PAGE.COMMENTS' | translate }}
</span>
@if (selectedInvoice && histories.length) {
<span class="comment-count">{{ histories.length }}</span>
}
</header>
<div class="comments">
@if (selectedInvoice && histories.length) {
<ul class="comment-list">
@for (history of histories; track history.id) {
<li class="comment-item">
<span class="comment-avatar" aria-hidden="true">
@if (history.user?.imageUrl) {
<img [src]="history.user.imageUrl" alt="" />
} @else {
{{ authorInitials(history.user?.name) }}
}
</span>
<div class="comment-body">
<div class="comment-byline">
@if (canOpenUserProfile && history.user?.id) {
<a
class="comment-author"
[routerLink]="[
'/pages/users/edit',
history.user.id
]"
>
{{ history.user?.name }}
</a>
} @else {
<span class="comment-author">
{{ history.user?.name }}
</span>
}
<span
class="comment-time"
[nbTooltip]="
history.createdAt | dateTimeFormat
"
>
{{ commentTimeAgo(history.createdAt) }}
</span>
</div>
@if (history.title) {
<div class="comment-subject">
{{ history.title }}
</div>
}
<p class="comment-text">{{ history.action }}</p>
</div>
</li>
}
</ul>
} @else {
<div class="comment-empty">
<nb-icon icon="message-square-outline" pack="eva"></nb-icon>
<span class="comment-empty-title">
{{ 'INVOICES_PAGE.NO_COMMENTS' | translate }}
</span>
<span class="comment-empty-hint">
{{ 'INVOICES_PAGE.NO_COMMENTS_HINT' | translate }}
</span>
</div>
}
</div>
}
</section>
</div>
</nb-accordion-item-body>
</nb-accordion-item>
@@ -445,7 +522,7 @@
<nb-select
filled
status="basic"
class="action select-nb mr-2 ml-1"
class="action select-nb"
[placeholder]="'INVOICES_PAGE.SET_STATUS' | translate | titlecase"
[(selected)]="status"
[disabled]="disableButton"
@@ -463,7 +540,7 @@
<nb-select
filled
status="basic"
class="action select-nb mr-2"
class="action select-nb"
[placeholder]="'INVOICES_PAGE.SET_STATUS' | translate | titlecase"
[(selected)]="status"
[disabled]="disableButton"
@@ -95,33 +95,12 @@
margin-bottom: 20px;
}
.history-list {
margin: 20px 0;
}
.history-list-item {
display: flex;
flex-direction: column;
}
.history-list-select {
font-weight: bold;
// margin: 20px 0;
width: 50%;
}
.history-action {
font-weight: bold;
}
.history-date {
font-size: 12px;
}
.history-item {
align-self: flex-start;
}
.btn-group {
display: flex;
align-items: center;
@@ -259,23 +238,27 @@ ga-pagination {
width: 100%;
}
.comments {
// `scroll` reserved a scrollbar gutter on the History tab even when an invoice
// has one comment or none, so the empty panel showed a dead scrollbar track.
overflow-y: auto;
height: 15rem;
}
textarea {
height: 85px;
resize: none;
}
// Same hairline ring as the shared `.action` block in `gauzy/_gauzy-table.scss`
// — this file carries a near-verbatim copy of it (with the drop shadow spelled
// out as a literal rather than read from `--gauzy-shadow`).
.action {
box-shadow: inset 0 0 0 1px var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
// The one line the copy had dropped, and the one that matters on the dark
// theme: Nebular draws a REAL 1px border on every filled button, and for
// `status="basic"` it colours it `color-basic-default-border` →
// `color-basic-300` → #edf1f7. Neither `dark` nor `gauzy-dark` restates the
// `color-basic-*` ramp, so that near-white is what the whole toolbar strip
// beside the tabs was ringed with — and the hover/active/focus steps are
// whiter still (#f7f9fc / #e4e9f2 / #c5cee0). Only the FILL was overridden
// below, which left every button outlined in white on a near-black page.
//
// Dropping the border rather than recolouring it, because the ring above is
// already this button's edge: it is the same hairline the cards, overlays and
// table use, it self-adjusts on the light themes, and it costs no layout box
// — so the strip also stops standing 2px taller than the identical strip on
// every other list page, all of which take the shared block and its
// `border: none` with it.
border: none;
&[nbButton].appearance-filled.status-basic {
background-color: var(--gauzy-card-2);
@@ -303,14 +286,134 @@ textarea {
}
}
&.select-nb ::ng-deep {
// ── The "Set status" trigger, rebuilt as a View button ──────────────
// Matching the select's metrics one by one got the numbers right and still
// left it reading as a form field, because its BOX MODEL is a field's: the
// label is an inline `<span>` in a block button, and the chevron is taken out
// of the flow and parked in a gutter the padding reserves for it — hence a
// wide control with the label jammed left and the arrow far away.
//
// So the box is rebuilt the way `[nbButton]` builds one: inline-flex, centred,
// with the chevron an ordinary sibling of the label separated by the same
// `button-small-icon-offset` that sits between View's eye icon and its text.
// Nothing is pinned after that — the padding, line box and icon size are the
// button's own tokens, so the height comes out of the same arithmetic rather
// than being hard-coded to match.
&.select-nb {
// The ring belongs on the inner <button>, which is the box that is painted.
box-shadow: none;
// `button { margin: 5px }` near the top of this file spaces every button on
// the strip, and it cannot reach `.select-button`: that <button> lives in
// nb-select's template and carries THAT component's content attribute, not
// this one. The trigger wore `mr-2 ml-1` instead — 0.5rem / 0.25rem and
// nothing vertical. Those are Bootstrap utilities, i.e. `!important`, so
// they could not be overridden from here and came off the template; the
// host now takes the same 5px the buttons do.
margin: 5px;
.select-button {
box-shadow: inset 0 0 0 1px var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
::ng-deep .select-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: nb-theme(button-small-icon-offset);
// Nebular's stock `select-min-width` is 13rem, and the app does not take
// it: of the eight themes `themes.scss` registers, six restate the token
// as 6rem, and the two that do not — `gauzy-light` and `gauzy-dark`, the
// two the app ships on — extend `default` and `dark`, which are among the
// six. `nb-register-theme` flattens a parent's map into the child at
// registration, so both inherit 6rem rather than falling back to the stock
// value. Either way this trigger takes none of it: the box hugs its label,
// the way every button on the strip does.
min-width: 0;
width: auto;
padding: nb-theme(button-filled-small-padding);
// View computes to 1.5rem: a 1rem line box inside 0.25rem of padding,
// with no border since the rule above drops it. The padding and the line
// box below reproduce that arithmetic, and the height is stated outright
// as well — a derived height is only ever as tall as the TALLEST thing in
// the flex line, so anything that resolves larger than the line box (the
// chevron, or a line-height that fails to resolve) would silently push
// the box past the buttons again.
height: 1.5rem;
// Nebular stacks `select-icon-offset` (2rem) on top of that shorthand
// from a `[dir=ltr]`-qualified rule, to clear the absolutely positioned
// chevron. There is no gutter to reserve now that the chevron is back in
// the flow, so the inline-end side is restated the same way — it is the
// only way to out-rank it.
@include nb-ltr(padding-right, 0.625rem);
@include nb-rtl(padding-left, 0.625rem);
line-height: nb-theme(button-small-text-line-height);
font-size: nb-theme(button-small-text-font-size);
font-weight: nb-theme(button-text-font-weight);
// `select-filled-basic-text-color` is `color-basic-800` (#222b45), a navy
// that all but vanishes on the near-black panel. This is what `.secondary`
// paints View with.
color: var(--text-hint-color);
background: var(--gauzy-card-2);
height: 2rem;
// `select-filled-basic-border-color` resolves down the same chain as the
// buttons' — `color-basic-default-border` → `color-basic-300` → #edf1f7 —
// so the one control here that is not a button was ringed in white
// alongside them. `border-color` alone would not do it:
// `nb-select.appearance-filled .select-button` sets the style and the
// width too, and this selector out-ranks it, so the whole shorthand goes.
border: none;
border-radius: nb-theme(button-rectangle-border-radius);
box-shadow: inset 0 0 0 1px var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
// Nebular draws a select's PLACEHOLDER from its own pair of tokens —
// 0.875rem at weight 400, against the buttons' 0.75rem at 700 — and "Set
// Status" IS the placeholder, i.e. the state on screen until a row is
// picked.
&.placeholder {
font-size: nb-theme(button-small-text-font-size);
font-weight: nb-theme(button-text-font-weight);
color: var(--text-hint-color);
}
// Nebular repaints a filled select on hover and focus out of the
// `color-basic-*` ramp — the same near-whites the border rule above got
// rid of. The buttons never move: this page's `.action` fill out-ranks
// Nebular's hover on an `[nbButton]`, and these restore that standoff.
&:hover,
&:focus,
&:focus:hover {
background: var(--gauzy-card-2);
}
// Disabled — which is how the strip starts out, before a row is picked.
// A button dims its LABEL and keeps its fill; a select swaps its fill for
// an opaque `background-basic-color-2` instead. Last of the three, so it
// settles the tie with `.placeholder` above.
&[disabled] {
background: var(--gauzy-card-2);
color: var(--text-disabled-color);
}
nb-icon {
// Back into the flow, which is the whole point of the block above:
// `position: absolute` + `top: 50%` + `translateY(-50%)` is what put
// the chevron in a gutter instead of beside the label.
position: static;
transform: none;
// View's icon is a 0.875rem BOX — `[nbButton].size-#{size} nb-icon` sets
// an explicit width and height from `--button-small-icon-size`, which
// `page-header-buttons()` compacts. A select's chevron gets neither: it
// is sized `1.5em` and then taken out of the flow, so its box never had
// to be right. Back in the flow it is a flex item whose height counts
// toward the button's, and `nb-icon`'s only other sizing is the global
// `width/height: 1em` — so both are spelled out here exactly as the
// button spells them.
font-size: nb-theme(button-small-icon-size);
width: nb-theme(button-small-icon-size);
height: nb-theme(button-small-icon-size);
color: var(--text-hint-color);
}
}
// The open-state flip, which Nebular writes as a SECOND transform including
// the centring translate it no longer needs.
&.open ::ng-deep .select-button nb-icon {
transform: rotate(180deg);
}
}
}
@@ -440,60 +543,411 @@ nb-tab {
@include ga-overrides.dialog(var(--gauzy-card-1), var(--gauzy-card-1));
}
// ── History tab: comment thread ──────────────────────────────────────────────
// A composer on the left and the thread on the right, rather than the pair of bare
// Bootstrap `col-6`s this used to be. A grid instead of a `.row`: with neither
// column drawing a box, the space between them is the ONLY thing holding them
// apart, and a `.row` cannot give a real gutter (it pads each child inwards, then
// pulls the whole thing back out with negative margins). This gap plus each
// panel's own side padding is what that space adds up to. One breakpoint is enough
// to stack them on a narrow window.
//
// The height is pinned for the same reason the old `.comments` carried a fixed
// 15rem: this accordion shares `.custom-content-body` (`calc(100vh - 19.5rem)`)
// with the table below it, so a thread that grew with its content would eat the
// table's rows one comment at a time. The thread scrolls inside that budget; the
// composer keeps its natural size and its column absorbs the rest.
.comments-container {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
gap: 0.75rem;
height: 15rem;
margin-inline: 0;
& nb-list {
@media (max-width: 991px) {
grid-template-columns: minmax(0, 1fr);
height: auto;
}
}
// Flat: no fill and no ring of its own. Each panel is a REGION of the accordion
// body, not a card sitting on it, and boxing the two of them inside a body that is
// already a bordered surface put three edges between a comment and the page. What
// separates them now is the column gap and the rule under each panel's own title.
.comment-panel {
display: flex;
flex-direction: column;
min-height: 0;
padding: 0.5rem 0.75rem 0.75rem;
color: var(--gauzy-text-color-2);
}
.comment-panel-header {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.375rem;
padding-bottom: 0.625rem;
margin-bottom: 0.875rem;
border-bottom: thin solid var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
nb-icon {
width: 0.875rem;
height: 0.875rem;
font-size: 0.875rem;
color: var(--gauzy-text-color-2);
}
}
.comment-panel-title {
font-size: 0.75rem;
font-weight: 600;
line-height: 1rem;
letter-spacing: -0.01em;
color: var(--gauzy-text-color-1);
}
// Comment count, on the far end of the thread's header line.
.comment-count {
@include nb-ltr(margin-left, auto);
@include nb-rtl(margin-right, auto);
min-width: 1.25rem;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12));
font-size: 0.6875rem;
font-weight: 600;
line-height: 1rem;
text-align: center;
color: var(--gauzy-text-color-2);
}
// The composer's avatar column: the avatar itself and the gap between it and the
// fields beside it. Declared once so the avatar's own box, the row's gap and the
// centring nudge below cannot drift apart the next time the avatar is resized.
$composer-avatar: 1.5rem;
$composer-gap: 0.5rem;
// One avatar treatment for the composer and for every comment. The shared
// `ngx-avatar` is deliberately NOT used here: it renders nothing at all when its
// `src` is empty, so every author who never uploaded a photo left a hole where the
// avatar belongs. `authorInitials()` in the component covers that case, and this
// is the box both a photo and the initials are drawn in.
.comment-avatar {
flex: 0 0 auto;
display: grid;
place-items: center;
width: $composer-avatar;
height: $composer-avatar;
overflow: hidden;
border-radius: 50%;
background: var(--gauzy-active-tint, rgba(126, 126, 143, 0.2));
font-size: 0.6875rem;
font-weight: 600;
line-height: 1;
color: var(--gauzy-text-color-1);
user-select: none;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.comment-form {
display: flex;
flex-direction: column;
min-height: 0;
}
// The composer is a face beside a stack of fields — one flex row holding the
// avatar and a flex COLUMN holding the subject and the comment box. That nesting
// is what makes both fields start on the same line as each other, past the avatar,
// without either of them having to carry a margin that guesses the avatar's width.
.comment-form-row {
display: flex;
align-items: flex-start;
gap: $composer-gap;
}
.comment-form-fields {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 0.375rem;
}
// Centres the avatar on the subject field it opens beside — half the difference
// between the two heights.
.comment-form-row .comment-avatar {
margin-top: calc((#{ga-overrides.$compact-control-height} - #{$composer-avatar}) / 2);
}
// The subject is a field of its own beside the avatar, NOT a second row inside the
// comment box: sharing that box's edge made the two read as one input that had
// somehow split in two. So it gets the ordinary treatment — the same borderless,
// hairline-ringed field the Search tab's controls use, at the same height.
//
// `input-appearance()` — pulled in by the `:host nb-accordion` dialog include
// above — paints every field in this accordion `--gauzy-card-1`, borderless, on a
// 42px floor, all as `!important`, so both the fill and the floor have to be
// re-stated at that weight to be reachable at all.
.comment-form-title[nbInput] {
// `fullWidth` on the element resolves to Nebular's `.input-full-width` (0,1,0),
// which this out-ranks: as a column child the field is stretched to the column's
// width already, and a second 100% on top of that is what it does not need.
width: auto;
min-width: 0;
min-height: ga-overrides.$compact-control-height !important;
height: ga-overrides.$compact-control-height;
padding-block: 0;
background-color: var(--gauzy-card-2) !important;
border: none;
box-shadow: ga-overrides.$default-box-shadow-inset;
font-size: 0.75rem;
// The hairline is the field's only edge, so focus has to move it rather than add
// to it — with the border gone, Nebular's own focus state has nothing to colour.
&:focus {
box-shadow: inset 0 0 0 1px nb-theme(color-primary-500);
}
}
// The comment box is ONE control, the way a chat input is: the box owns the
// border, the fill and the focus ring, and the body and its buttons live inside
// it. Previously the field drew its own outline and the buttons sat on the panel
// below it, which read as two separate widgets stacked up.
.comment-form-box {
display: flex;
flex-direction: column;
padding: 0.375rem 0.5rem;
border-radius: var(--border-radius);
background: var(--gauzy-card-2);
box-shadow: ga-overrides.$default-box-shadow-inset;
// Focus lands on a field inside; the ring belongs on the box that draws the edge.
&:focus-within {
box-shadow: inset 0 0 0 1px nb-theme(color-primary-500);
}
// The textarea has to disappear into the box: no fill, no edge of its own, no
// inset padding (the box already provides it). `border: none` because Nebular
// draws one on `[nbInput]` by default — inside the box that would be a second
// line weight against the box's own.
//
// Replaces the file-wide `textarea { height: 85px; resize: none }` this page
// used to carry for this one control.
textarea[nbInput] {
min-height: 3.5rem !important;
height: 3.5rem;
padding-block: 0.125rem;
padding-inline: 0;
background-color: transparent !important;
border: none;
box-shadow: none;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.45;
resize: none;
}
}
// The toolbar row inside the box, along its bottom edge.
.comment-form-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
margin-top: 0.25rem;
// Qualified with `[nbButton]` so these out-rank Nebular's own `.size-tiny`
// paddings (0,2,0) rather than tying with them; the file-wide
// `button { margin: 5px }` is the only thing that would otherwise space them,
// and the flex `gap` owns that here.
button[nbButton] {
margin: 0;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
font-size: 0.6875rem;
line-height: 1rem;
}
& .col-6:nth-child(1) {
padding-top: 15px;
color: var(--gauzy-text-color-2);
// Nebular gives an icon beside a label its own margin; the `gap` above is the
// spacing, so that margin has to go.
button[nbButton] nb-icon {
width: 0.75rem;
height: 0.75rem;
margin: 0;
font-size: 0.75rem;
}
}
& textarea {
font-weight: 400;
}
.comment-thread .comments {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
// `scroll` reserved a scrollbar gutter on the History tab even when an invoice
// has one comment or none, so the empty panel showed a dead scrollbar track.
overflow-y: auto;
// A trackpad flick that reaches the end of the thread should not then scroll the
// page behind it.
overscroll-behavior-y: contain;
// Keeps the last character of a comment off the scrollbar.
@include nb-ltr(padding-right, 0.25rem);
@include nb-rtl(padding-left, 0.25rem);
}
.comment-list {
display: flex;
flex-direction: column;
gap: 0.625rem;
margin: 0;
padding: 0;
list-style: none;
}
// One rule between comments and no fill behind them: the thread is a list of
// entries on the panel, not a stack of cards, and a hairline separates two of them
// with far less weight than a filled block each. The last entry drops its rule so
// the list does not end on a line with nothing under it.
.comment-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding-bottom: 0.625rem;
border-bottom: thin solid var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
&:last-child {
padding-bottom: 0;
border-bottom: none;
}
}
// Smaller here than on the composer: in the thread the avatar identifies a line of
// text, while on the composer it sits against a full-height input.
.comment-item .comment-avatar {
width: 1.25rem;
height: 1.25rem;
font-size: 0.5625rem;
}
.comment-body {
flex: 1 1 auto;
min-width: 0;
}
.comment-byline {
display: flex;
align-items: baseline;
gap: 0.375rem;
}
// The name, and where it is a link, a link that does not announce itself as one
// until you reach for it — an underline on every name would turn the thread into a
// column of blue rules.
.comment-author {
overflow: hidden;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.125rem;
color: var(--gauzy-text-color-1);
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
a.comment-author {
cursor: pointer;
&:hover,
&:focus-visible {
color: nb-theme(color-primary-500);
text-decoration: underline;
}
}
// Relative age ("3 hours ago"), with the exact timestamp on the tooltip — the
// trade every comment thread makes, and what lets the byline sit on one line
// instead of the three stacked rows this used to be.
.comment-time {
flex: 0 0 auto;
@include nb-ltr(margin-left, auto);
@include nb-rtl(margin-right, auto);
font-size: 0.6875rem;
line-height: 1.125rem;
color: var(--text-hint-color);
cursor: default;
}
// The line under the name, the way a thread puts an author's subtitle there: one
// step down in size and weight from the name, and muted, so the eye goes name →
// comment and picks this up only on the way past. It carried the name's own weight
// before, which made every entry read as two headings stacked.
.comment-subject {
font-size: 0.6875rem;
font-weight: 400;
line-height: 1.125rem;
color: var(--text-hint-color);
overflow-wrap: anywhere;
&::first-letter {
text-transform: uppercase;
}
}
// `pre-wrap` so a comment keeps the line breaks its author typed — the old single
// `<span>` collapsed them into one run — and `overflow-wrap` so an unbroken URL or
// reference number cannot push the entry wider than its column.
.comment-text {
margin: 0.3125rem 0 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--gauzy-text-color-1);
white-space: pre-wrap;
overflow-wrap: anywhere;
&::first-letter {
text-transform: uppercase;
}
}
// Both panels' resting state: no invoice picked yet (composer) and no comments on
// the one that is (thread). The old markup rendered nothing at all in either case,
// so the History tab opened as two empty boxes with nothing to say which.
.comment-empty {
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 1rem 0.75rem;
text-align: center;
color: var(--text-hint-color);
nb-icon {
width: 1.25rem;
height: 1.25rem;
margin-bottom: 0.125rem;
font-size: 1.25rem;
opacity: 0.55;
}
& .col-6:nth-child(2) {
background: var(--gauzy-card-1);
border-radius: var(--border-radius);
padding: 15px 6px 0 15px;
span {
max-width: 32ch;
font-size: 0.6875rem;
line-height: 1.125rem;
}
.comment-empty-title {
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.25rem;
color: var(--gauzy-text-color-2);
& nb-list-item {
padding-left: 0;
position: relative;
& .history-item.history-date {
color: var(--text-hint-color);
margin-top: 3px;
}
& .history-item.history-title {
margin-top: 10px;
text-transform: capitalize;
font-weight: bold;
}
& .history-item.history-comment {
margin-top: 6px;
font-size: 0.85rem;
&.history-comment::first-letter {
text-transform: capitalize;
}
}
}
// border bottom
& nb-list-item::after {
content: '';
display: block;
width: 100%;
border-bottom: thin solid var(--accordion-header-border-color);
position: absolute;
bottom: 0;
}
}
}
@@ -659,12 +1113,45 @@ nb-tab {
font-size: 0.75rem;
}
// Submit / reset sit in a bare `.row`; the file-wide `button { margin: 5px }`
// is the only thing spacing them.
button {
// The file-wide `button { margin: 5px }` near the top was the only thing
// spacing anything in this form.
margin: 0.25rem;
font-size: 0.75rem;
}
// ── Submit / reset ───────────────────────────────────────────────────────
// The two used to be `<div>`-wrapped items in a bare `.row`, left-aligned
// under the first filter with their leading gap coming from an `ml-3` on the
// submit button — so they read as a third row of fields rather than as the
// form's actions. The wrappers are gone (the buttons are the flex items now)
// and the pair closes the form off against the same edge the filter row ends
// on.
.search-actions {
justify-content: flex-end;
align-items: center;
// `.row`'s own negative gutters, which would hang the buttons past the
// accordion's padding on the right.
margin-inline: 0;
// Clear of the filter row above — with the fields' own 0.25rem underneath
// them that comes to 0.75rem: enough to read as the form's footer without
// spending height the table below is short of.
margin-top: 0.5rem;
gap: 0.375rem;
// The compact button scale the page header already runs on this page (see
// the `page-header-buttons()` includes further up), instead of Nebular's
// stock `small` box — that is what takes the size down, and it puts the
// form's actions on the same scale as the toolbar above the table rather
// than a step larger than everything around them.
@include ga-hub.page-header-buttons();
button {
// The row's `gap` spaces them now, so neither the file-wide 5px nor the
// 0.25rem above it should still be adding to the ends of the row.
margin: 0;
}
}
}
}
@@ -738,13 +1225,167 @@ nb-tab {
padding: 0.375rem 0.5rem;
}
// ── Search form: controls sized to their content ─────────────────────────────
// The fields are laid out on a Bootstrap grid (`col-sm-4` / `-3` / `-2`) and every
// control carries `fullWidth`, so each one stretched to a third or a quarter of
// the card — a number field nearly 500px wide for a 6-digit estimate number.
// Capping the COLUMN rather than the control keeps the grid's wrapping and order
// intact (the cap only bites where the percentage is the larger of the two) and
// carries the label above the field with it.
:host .search-tab nb-accordion .row > [class*='col-'] {
max-width: 14rem;
// ── Search form: all eight filters on one line ───────────────────────────────
// The fields used to sit on two Bootstrap rows (`col-sm-4` / `-3` / `-2`) with
// every control carrying `fullWidth`, so each stretched to a third or a quarter
// of the card — a number field nearly 500px wide for a 6-digit estimate number —
// and the form stood two field-rows tall above a table that is already short on
// height. They are now one `.search-filters` row: equal-width flex columns that
// are allowed to shrink, which is what fits all eight on a single line instead of
// letting Bootstrap's percentage widths wrap them.
:host .search-tab nb-accordion .search-filters {
flex-wrap: nowrap;
// Tops aligned, so the labels — and therefore the controls under them — share
// one baseline across the row.
align-items: flex-start;
margin-inline: 0;
// Replaces the columns' own 0.75rem gutters, halved: eight fields on one line
// are better off spending that width on the controls.
gap: 0.5rem;
> .search-filter-col {
// `flex-basis: 0`, not `auto`, so the eight share the line evenly rather than
// each claiming its content width.
flex: 1 1 0;
// A flex item's floor is `min-content` by default, and for a number input or
// a select that is wider than an eighth of the card — without this the row
// overflows the accordion instead of shrinking.
min-width: 0;
max-width: none;
padding-inline: 0;
}
// ── The one control that did not obey that ─────────────────────────────────
// Nebular's own `nb-select-theme()` floors every trigger at
// `nb-theme(select-min-width)`, which resolves to 6rem in all eight registered
// themes (see the note on `.select-nb` higher up for why that includes the two
// the app ships on). Eight of those side by side need 8 x 6rem + 7 x 0.5rem =
// 824px of ROW before a column is wide enough to hold one — well past the point
// where the card itself is 824px wide, and far past the 768px at which this row
// is allowed to wrap. Between the two, the Status trigger kept a floor its
// column could not give it and hung out past the accordion, taking the fields
// beside it with it.
//
// Unset rather than lowered: the premise of the row is that the eight columns
// divide the line evenly and shrink together, and a floor on one of them is the
// one thing that cannot hold. This is the same treatment `_overrides.scss`
// applies to selects inside `.card-scroll`. `!important` because that same file
// restates the floor with one of its own under `.nb-theme-default`; under every
// other theme the specificity here would have been enough on its own.
::ng-deep nb-select .select-button {
min-width: 0 !important;
// Nebular gives the button `text-overflow: ellipsis`, but `[nbButton]` is an
// `inline-flex` container, so that applies to nothing — the label is a child
// `<span>`, and a flex item will not shrink below its content unless it is
// told it may. Without the pair, a status name at an eighth of the card is
// cut off mid-glyph by the button's own `overflow: hidden`. Same treatment
// the `ng-select` triggers in this row get below.
> span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
// ── The labels, so the controls under them start at one height ─────────────
// Every label in the row is pinned to the same type size, line box and bottom
// margin. Two of the eight are drawn by child components (`ga-currency`,
// `ga-tags-color-input`), which the `.label` rule higher up in this file —
// scoped, no `::ng-deep` — never reached: those two kept Nebular's default
// 0.5rem gap under the label while the six from this template had 0.1875rem,
// so their inputs sat several pixels lower than the rest. That, and not the
// grid, is what stopped the fields lining up.
//
// The single-line clamp is the other half: the labels differ a lot in length
// ("Currency" against "Organization contact"), and at an eighth of the card the
// long ones wrapped to a second line and pushed their input a whole line down.
::ng-deep .label {
display: block;
margin-bottom: 0.1875rem;
font-size: nb-theme(text-label-font-size);
line-height: 0.8125rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// Same reach: `ga-currency` renders its own `.form-group` wrapper.
::ng-deep .form-group {
margin-bottom: 0.25rem;
}
// ── One type size across the row ───────────────────────────────────────────
// The eight filters are four different widgets — a plain `nbInput`, two
// datepicker inputs, three `ng-select`s (contact, currency, tags) and the
// `nb-select` for status — and each arrives with its own size: `ng-select`
// reads `select-medium-text-font-size` from `ng-select-overrides`, a selected
// tag is an `nb-badge` at `text-caption-font-size`, and a bare input inherits
// the paragraph size. On two rows that was easy to miss; on one line it reads
// as four different fields.
//
// Status is the one that was already right (`#inputStatus .select-button`
// higher up in this file), so its 0.75rem is the row's size. Set on each text
// surface rather than left to inherit, because the library sheets put a size
// on the descendants themselves.
::ng-deep {
input,
input::placeholder,
.ng-select,
.ng-select .ng-select-container,
.ng-select .ng-value-container,
.ng-select .ng-value,
.ng-select .ng-value-label,
.ng-select .ng-placeholder,
.ng-select .ng-input > input,
nb-select .select-button,
nb-select .select-button.placeholder,
nb-tag-list input,
.tag-label,
.tag-overflow {
font-size: 0.75rem;
}
// At an eighth of the card a contact or currency name is routinely longer
// than its trigger. `ng-select`'s own container clips it, which cuts the text
// mid-glyph; a flex item will not shrink below its content unless told it
// may, so both halves are needed for an ellipsis to engage instead.
.ng-select.ng-select-single .ng-select-container .ng-value-container {
min-width: 0;
}
.ng-select.ng-select-single .ng-select-container .ng-value {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
// The two date fields park a calendar glyph inside the control (`.input-icon
// .icon`). At full width the placeholder never reached it; at an eighth of the
// card it runs underneath, so the glyph is pulled in tight against the edge and
// the text gets the room that buys.
.input-date {
padding-inline-end: 1.5rem;
}
.input-icon .icon {
@include nb-ltr(right, 0.5rem);
@include nb-rtl(left, 0.5rem);
}
}
// One line is the point of the row, so it holds for every desktop width — the
// columns shrink instead of wrapping. Below the tablet breakpoint eight controls
// on a line stop being controls at all, so there the row wraps in the same order,
// a few filters per line.
@media (max-width: 767.98px) {
:host .search-tab nb-accordion .search-filters {
flex-wrap: wrap;
> .search-filter-col {
flex: 1 1 9rem;
}
}
}
@@ -35,6 +35,7 @@ import {
PermissionsEnum,
ICurrency,
IInvoiceItemCreateInput,
IUser,
InvoiceTabsEnum,
DiscountTaxTypeEnum,
IDateRangePicker
@@ -796,6 +797,80 @@ export class InvoicesComponent extends PaginationFilterBaseComponent implements
}
}
/**
* The user the History tab composes comments as. Read through a getter rather
* than a field so the avatar follows a mid-session user switch, and so the
* template does not have to reach into the private `store`.
*/
get currentUser(): IUser {
return this.store.user;
}
/**
* Whether a comment author's name should be a link to their profile.
*
* `/pages/users/edit/:id` is guarded by `ORG_USERS_EDIT`, and its guard
* redirects to the dashboard rather than refusing — so without this check a
* viewer who lacks the permission would click a name and silently land on a
* different page. They get plain text instead.
*/
get canOpenUserProfile(): boolean {
return this.ngxPermissionsService.getPermission(PermissionsEnum.ORG_USERS_EDIT) != null;
}
/**
* Initials fallback for a comment author with no `imageUrl`. The shared
* `ngx-avatar` renders nothing at all when its `src` is empty, which left a
* hole where the avatar should be for every user who never uploaded a photo —
* the common case on a fresh workspace.
*
* @param name - the author's display name
* @returns up to two upper-cased initials, or `?` when there is no name
*/
authorInitials(name: string): string {
const initials = (name || '')
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part: string) => part.charAt(0))
.join('');
return initials ? initials.toUpperCase() : '?';
}
/**
* Relative age of a comment ("3 hours ago"), the way every comment thread
* dates its entries. The exact timestamp stays available on the tooltip, so
* nothing is lost by not printing it inline.
*
* `createdAt` arrives here as the `Date.toString()` slice built in
* `selectInvoice`, not as an ISO string, so it goes through `new Date` first
* rather than moment's (deprecated) free-form string parser.
*
* A record with no usable date renders nothing rather than moment's literal
* "Invalid date": the line is a subtitle beside the author's name, and a blank
* one reads as "no timestamp" while that string reads as a broken comment. The
* tooltip beside it is already empty in the same case.
*
* @param createdAt - the history record's creation date
* @returns a humanized, locale-aware distance from now, or an empty string
*/
commentTimeAgo(createdAt: string | Date): string {
const parsed = new Date(createdAt);
return isNaN(parsed.getTime()) ? '' : moment(parsed).fromNow();
}
/**
* Clears the comment composer without submitting it.
*
* @param historyFormDirective - the composer's `ngForm`, reset alongside the
* form group so the controls drop their touched/dirty state too
*/
resetComment(historyFormDirective): void {
historyFormDirective.resetForm();
this.historyForm.reset();
}
async addComment(historyFormDirective) {
if (this.historyForm.invalid) {
return;
@@ -806,8 +881,7 @@ export class InvoicesComponent extends PaginationFilterBaseComponent implements
const action = comment;
await this.createInvoiceHistory(action, title);
historyFormDirective.resetForm();
this.historyForm.reset();
this.resetComment(historyFormDirective);
const invoice = await this.invoicesService.getById(invoiceId, [
'invoiceItems',
@@ -901,6 +975,11 @@ export class InvoicesComponent extends PaginationFilterBaseComponent implements
return +new Date(b.createdAt) - +new Date(a.createdAt);
});
this.histories = histories;
} else {
// Deselecting used to leave the previous invoice's comments standing in
// the History tab. The thread is now guarded on `selectedInvoice`, but
// dropping the records keeps the count badge honest either way.
this.histories = [];
}
}
@@ -1 +1,10 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// Density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager's `<nav><ul><li>` and the toolbar scale — the same
// block Invoices carries. The action row here is `ngx-gauzy-button-action`
// inside `nb-card-header`, which the default `$actions` selector covers: the
// tokens are custom properties, so they cross into that component on their own.
@include ga-hub.list-page();
@@ -1,4 +1,5 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
.card-header-title {
@@ -15,3 +16,10 @@
overflow: auto;
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block the Pipelines list this page drills into already carries:
// density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager and the toolbar scale. Without it a pipeline's
// deals were drawn a size larger than the pipeline list one click behind them.
@include ga-hub.list-page();
@@ -185,8 +185,15 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements
* Sets up the initial view configuration and subscribes to changes in the component layout.
*/
setView() {
// Set the default view component name to PROPOSALS
this.viewComponentName = ComponentEnum.PROPOSALS;
// `PIPELINES`, not `PROPOSALS`. This page registered itself under the
// Proposals key, and `job-proposal-ui`'s own proposal page registers the
// SAME key — so the two shared one stored layout preference. Setting
// Proposals to the card view silently switched Pipelines to `ga-card-grid`
// as well, which is why this page could render as cards while every style
// written for its table was correct and simply never applied; toggling the
// layout on either page also moved the other. `ComponentEnum.PIPELINES` was
// declared for this page and, until now, referenced nowhere.
this.viewComponentName = ComponentEnum.PIPELINES;
// Subscribe to changes in the component layout
this.store
@@ -1,6 +1,17 @@
@for (stage of pipelineStages; track stage) {
<div>
<div>{{stage.name}}</div>
<div>{{stage.description}}</div>
<!--
Only when there is one, and `trim()` because a blank string is not one. Most
stages carry no description, and an element emitted unconditionally is an
empty flex item holding the chip's gap open on its trailing edge — a chip
wider than its own text. A whitespace-only value looks identical on screen
and is truthy, so a bare `@if` would let exactly that case back through.
Dropping the node is what the stylesheet's `:nth-child(2)` description rule
expects; see the note there.
-->
@if (stage.description?.trim()) {
<div>{{stage.description}}</div>
}
</div>
}
@@ -1,18 +1,81 @@
div > div {
&:first-child {
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 15px;
letter-spacing: 0em;
text-align: left;
}
&:last-child {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 15px;
letter-spacing: 0em;
text-align: left;
}
// ── A pipeline's stages, as chips on one line box ────────────────────────────
//
// WHAT THIS REPLACES: a bare `div > div` block setting 12px/15px on each of the
// two lines the template emits per stage — a name line and a description line —
// with the stages themselves left as plain blocks, stacked. So the cell cost the
// row 30px PER STAGE and nothing capped it: a pipeline with five stages made a
// 150px row, in a table whose other three columns are a string, a string and a
// 1rem status pill. It was the one cell on this page that read no density tokens
// at all, and the only reason a Pipelines row did not measure what an Invoices
// row does.
//
// The treatment is the app's existing answer to "several small labels in one
// cell" — the tag chips `ga-notes-with-tags` renders — rather than anything new:
// the same `--gauzy-table-chip-*` scale, the same `--gauzy-table-badge-*` box,
// so a stage chip and the status pill in the very next column are drawn alike.
//
// Chips WRAP rather than truncating to one line with an overflow count (the
// `ngx-people-list` treatment). A pipeline's stages are the payload of this
// column — the thing the row is read for — so a tall-ish cell on a pipeline with
// many stages is the honest outcome; what is fixed is that each stage now costs
// one chip instead of two full-size text lines.
//
// Plain custom properties with literal fallbacks, no `@use 'themes'`: this sheet
// had no imports and needs none, and the tokens reach it by inheritance from the
// page's `:host` the same way every other cell renderer here is sized.
:host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gauzy-table-chip-gap, 0.125rem);
min-width: 0;
}
// One stage. `inline-flex` with a baseline cross-axis so the name and its
// description sit on the same line inside the chip instead of stacking.
:host > div {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
max-width: 100%;
min-width: 0;
min-height: var(--gauzy-table-badge-height, 1rem);
padding-block: var(--gauzy-table-badge-padding-y, 0.1875rem);
padding-inline: var(--gauzy-table-chip-padding-x, 0.3125rem);
border-radius: var(--gauzy-table-badge-radius, 0.25rem);
background: var(--gauzy-sidebar-background-3, rgba(126, 126, 143, 0.1));
}
// The stage name, and the description after it. Both truncate on WIDTH, so a
// short one renders in full and only a genuinely long one picks up an ellipsis —
// a stage name is short and a description is not, and it is the description that
// would otherwise push the chip past the column.
:host > div > div {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--gauzy-table-chip-font-size, 0.625rem);
line-height: var(--gauzy-table-chip-line-height, 0.75rem);
letter-spacing: 0em;
text-align: left;
}
:host > div > div:first-child {
flex: 0 1 auto;
font-weight: 600;
}
// `:nth-child(2)`, not `:last-child`. The template now emits the description
// element only when the stage has a non-blank one (a `display: none` on `:empty`
// used to do that job, which leant on a zero-length interpolation text node still
// counting as empty and could not catch a whitespace-only description at all).
// With the node genuinely absent, the NAME is both first and last child, and a
// `:last-child` rule at this same specificity would come after the `:first-child`
// one above and paint every description-less stage muted and unbolded.
:host > div > div:nth-child(2) {
flex: 1 1 auto;
max-width: 12rem;
font-weight: 400;
color: var(--gauzy-text-color-2, var(--text-hint-color));
}
@@ -38,25 +38,40 @@
// attributes and a class before anything else, which clears that outright — no
// `!important` anywhere below.
:host .table-scroll-container ::ng-deep angular2-smart-table {
// ── Project cell ───────────────────────────────────────────────────────────
// `gauzy-project-organization` draws a 28px logo beside a 14px/17px name and an
// 11px/13px caption, i.e. a 28px chip in rows the density tokens build to a
// 1rem line box — the single biggest reason a row here stood taller than the
// same row on Employees or Payments. Sized off the shared `gauzy-people-*`
// tokens, so the project chip matches the people cells beside it.
// ── Project and Contact cells ──────────────────────────────────────────────
// Two blocks used to sit here, shrinking `gauzy-project-organization`'s logo
// to 1rem and `ngx-contact-links`' avatar and pill to about 20px of chip, so
// that each cost the row a single 1rem line box.
//
// Both are gone. `ngx-contact-links` is the very renderer the Invoices Contact
// column draws — an 18px avatar and a 12px/15px name in a pill padded 5px, a
// 28px chip — so overriding it here was this page setting itself BELOW the
// table it is meant to match, using the identical component. Left alone it is
// now exact by construction.
//
// The project logo comes back to the `gauzy-people-*` scale rather than to its
// own 28px: those tokens are the restored 1.25rem/0.75rem, which puts the logo
// and the name on the same line box as the people chip in the next column.
.project-render {
gap: var(--gauzy-people-gap, 0.375rem);
gap: var(--gauzy-people-gap, 0.5rem);
img {
width: var(--gauzy-people-avatar-size, 1rem);
height: var(--gauzy-people-avatar-size, 1rem);
// A 35 %-black drop shadow under a 16px thumbnail is a smudge, not a lift.
width: var(--gauzy-people-avatar-size, 1.25rem);
height: var(--gauzy-people-avatar-size, 1.25rem);
// A 35 %-black drop shadow under a small thumbnail is a smudge, not a lift.
box-shadow: none;
}
.name {
font-size: var(--gauzy-people-font-size, 0.6875rem);
line-height: var(--gauzy-people-avatar-size, 1rem);
font-size: var(--gauzy-people-font-size, 0.75rem);
// The TABLE line box, not the avatar size. The name is one line of a stack,
// not a line box that has to clear an avatar beside it: a project with a
// member count puts this over `.member`, so a 1.25rem name and a 0.75rem
// caption add up to 2rem of cell — taller than the 28px `ngx-contact-links`
// chip in the next column, which is the row height this page is being
// brought to. At 1rem the pair comes to exactly that 28px, and the logo
// beside them still gets its own 1.25rem box.
line-height: var(--gauzy-table-line-height, 1rem);
}
span .member {
@@ -65,38 +80,6 @@
}
}
// ── Contact cell ───────────────────────────────────────────────────────────
// `ngx-contact-links` draws an 18px avatar and 12px/15px text inside a pill
// padded 5px/14px — a 28px control in a 1rem row. Same scale-down as the
// people chips, and the pill keeps its tint so the cell still reads as a link
// rather than as plain text.
ngx-contact-links .inner-wrapper {
gap: var(--gauzy-people-gap, 0.375rem);
@include nb-ltr(padding, 0.125rem 0.375rem 0.125rem 0.125rem);
@include nb-rtl(padding, 0.125rem 0.125rem 0.125rem 0.375rem);
.names-wrapper {
max-width: var(--gauzy-people-name-max-width, 8rem);
}
.link-text {
font-size: var(--gauzy-people-font-size, 0.6875rem);
line-height: var(--gauzy-people-avatar-size, 1rem);
}
.avatar img,
.prefix {
width: var(--gauzy-people-avatar-size, 1rem);
height: var(--gauzy-people-avatar-size, 1rem);
}
.prefix {
padding: 0;
font-size: var(--gauzy-people-initials-font-size, 0.5rem);
line-height: var(--gauzy-people-avatar-size, 1rem);
}
}
// ── Public / Private cell ──────────────────────────────────────────────────
// The cell is an `nb-toggle`, which Nebular sizes entirely from theme tokens:
// a 50x30 switch with a 28px knob and a 0.8125rem label on a 1.5rem line — the
@@ -1,4 +1,5 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// Removed `.setting-name` (font-size 24px / bold) and `.body-header` (flex row,
// margin-bottom 35px). This stylesheet is emitted for exactly two templates —
@@ -103,8 +104,10 @@
// `.setting-name` and `.body-header`, matched nothing in either template that
// uses this stylesheet.
//
// The rows are laid out with fixed column widths (~57rem), so this really does
// need to scroll sideways on a narrow viewport; what it does NOT need is
// The rows are laid out with fixed column widths — about 41rem for the employee
// list and 28rem for the organization one, off the `--gauzy-recurring-col*`
// properties at the foot of this file — so this can still need to scroll
// sideways on a narrow viewport; what it does NOT need is
// `overflow-x: scroll`, which reserves a scrollbar gutter unconditionally. With
// `auto` the bar only exists when there is something to scroll, and the two
// hide-the-bar declarations now cover Firefox as well as WebKit rather than
@@ -130,3 +133,147 @@
align-items: center;
gap: 8px;
}
// ── The list-page contract ───────────────────────────────────────────────────
// Both Recurring Expenses pages take this block: Employees > Recurring Expenses
// directly, and Organization > Recurring Expenses through the `@forward` in
// `expense-recurring.component.scss`. The two templates differ by one column and
// are otherwise the same markup.
//
// That markup is a list built BY HAND — a `.sub-header` band of column labels
// over a `.table-scroll` of `ga-recurring-expense-block` rows — so neither
// `list-page()` (anchored on `angular2-smart-table`) nor `card-list-page()`
// (anchored on `.table-scroll-container`) ever reached it. What that left: 84px
// rows, each a separately floating card with its own fill, radius and 8px bottom
// margin, 14px values under a 0.75rem-padded `gauzy-card-3` label slab, and a
// toolbar at full size — the last two lists in these sections still drawn the
// way the whole app was before the density pass.
//
// The row component is styled from its own sheet rather than reached with
// `::ng-deep` from here: `ga-recurring-expense-block` has exactly two consumers
// and they are the two pages this file serves, so there is no third caller for a
// page-scoped override to protect. It reads the density tokens below, which
// inherit into it — the same way `ngx-people-list` and `ngx-created-by-user`
// already size themselves.
:host {
@include ga-hub.density-tokens();
// ── The three metrics the label band and the rows MUST agree on ────────────
// They are custom properties, and on `:host`, for one reason: the labels are
// in this component's template and the values are in
// `recurring-expense-block.component.scss`, so a literal would have to be
// written twice in two files and kept in step by hand. That is exactly how the
// page arrived here — `42px`, `11rem` and `17.5rem` were each spelled out in
// both places.
//
// The inset was 42px, room for a selection rail and a settings fly-out; the
// rail is 3px now (see below) and the fly-out is `display: none`.
--gauzy-recurring-row-inset: 0.5rem;
--gauzy-recurring-col: 8rem;
--gauzy-recurring-col-big: 12rem;
}
:host nb-card-header {
@include ga-hub.page-header-buttons();
}
// ── One surface, around the labels AND the rows ──────────────────────────────
// On `.container` rather than on `.table-scroll`, which is the only interesting
// choice here: the label band is a `thead` in everything but markup, and a
// surface that started below it would have left the header outside the frame it
// labels. `.container` is the one element that wraps both.
//
// `.table-scroll` inside it keeps the scrolling, in both axes, exactly as before;
// the ring is on the box around it, so it stays put rather than scrolling with
// the rows.
:host .container {
@include ga-hub.surface();
// A flex column, so the list below can take exactly the room this surface has
// rather than guessing at it — see the note on `.table-scroll`.
display: flex;
flex-direction: column;
padding: 0;
}
// ── The label band, as the table header it already is ────────────────────────
// It was a `gauzy-card-3` slab with a radius and 0.75rem of padding, i.e. a
// second surface sitting on the first, with labels at the body size. Now: the
// header type scale, the header padding, and the hairline that separates a
// `thead` from the rows under it.
//
// The transparent 3px leading edge matches the selection rail the rows reserve,
// so a label and the value under it start at the same x — the alignment the old
// pair of hand-matched 18px / 42px insets was trying to hold.
:host .sub-header.header-content {
// The fixed part of the column: a `thead` is not what should give way when the
// card is short.
flex: 0 0 auto;
margin: 0;
padding: var(--gauzy-table-header-padding-y, 0.3125rem) var(--gauzy-table-header-padding-x, 0.4375rem);
border-inline-start: 3px solid transparent;
border-radius: 0;
background-color: transparent;
box-shadow: inset 0 -1px 0 0 var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
font-size: var(--gauzy-table-header-font-size, 0.75rem);
font-weight: 600;
line-height: var(--gauzy-table-header-line-height, 0.8125rem);
color: var(--gauzy-text-color-2, var(--text-hint-color, rgba(113, 113, 122, 1)));
}
// SPECIFICITY: the rules these replace are `[dir=ltr]` / `[dir=rtl]` pairs at
// (0,3,0), so naming both classes on the band is what carries these past them.
:host .sub-header.header-content .header-info {
@include nb-ltr(padding-left, var(--gauzy-recurring-row-inset));
@include nb-rtl(padding-right, var(--gauzy-recurring-row-inset));
}
:host .sub-header.header-content .block .block-item {
width: var(--gauzy-recurring-col);
}
:host .sub-header.header-content .block .block-item-big {
width: var(--gauzy-recurring-col-big);
}
// ── The scroll port ──────────────────────────────────────────────────────────
// The 18px leading pad was there to line the cards up under the label band's own
// 18px margin; both are gone, and the rows now carry their inset themselves.
//
// `height: calc(100vh - 23.5rem)` goes with them. That number was an estimate of
// the chrome above this list, and the label band is a good deal shorter than it
// was when the estimate was taken — the list would now stop several rem short of
// the surface's bottom edge, leaving a band of empty ring under the last row. As
// a flex child it takes exactly the room the surface has, which is both accurate
// and immune to the next change above it; `min-height: 0` because a flex child
// that is itself a scroll port defaults to `min-height: auto` and would
// otherwise refuse to shrink below its content.
:host .table-scroll {
flex: 1 1 auto;
min-height: 0;
height: auto;
padding: 0;
}
// The Employee column's avatar chip, at the Invoices scale. `ngx-avatar` pins a
// 20px image inside a pill padded 3px — 26px of chip, 2px short of the 28px the
// Invoices Contact column costs its row — so only that inset is restated; the
// image and the name are the renderer's own.
//
// Reached through `.table-scroll` for the specificity: the avatar nests its
// rules under `:host-context(.report-table)`, which shims to (0,4,0), and that
// wrapper — this component's own element, so it carries this component's content
// attribute — is what takes the block past it without `!important`.
:host .table-scroll ::ng-deep {
@include ga-hub.avatar-chip();
}
// The empty state stands in for the list INSIDE that frame now, rather than
// floating on the card body, so it must not be taller than the box framing it.
// `max-height: 100%` bounds it wherever the parent has a definite height and
// resolves to `none` where it does not — the same treatment `.card-scroll` gets
// in `styles/_overrides.scss`.
:host .container .no-data {
max-height: 100%;
}
@@ -134,12 +134,22 @@ nb-card-body {
// attribute. The doubled classes make up the difference, the same idiom the
// Invoices and Pipelines sheets use on `.tab-link.tab-link`.
gauzy-task-badge-view .badge-color.badge-color {
height: var(--gauzy-table-badge-height, 1rem);
padding: var(--gauzy-table-chip-padding-y, 0) var(--gauzy-table-chip-padding-x, 0.3125rem);
// Same box as `ga-status-badge` now draws, from the same two tokens: the
// pills in the Status column and the pills every other list page shows were
// otherwise a different height and a different corner from each other.
//
// `min-height` rather than `height`, and `padding-block` rather than a
// padding shorthand with 0 in the block axis. The old pair pinned a 1rem box
// and then spent nothing on vertical padding — which it could not have done
// anyway, since under `border-box` padding inside a pinned height comes out
// of the text's box rather than adding to the pill.
min-height: var(--gauzy-table-badge-height, 1rem);
padding-block: var(--gauzy-table-badge-padding-y, 0.1875rem);
padding-inline: var(--gauzy-table-chip-padding-x, 0.3125rem);
gap: var(--gauzy-table-chip-gap, 0.125rem);
font-size: var(--gauzy-table-header-font-size, 0.75rem);
line-height: var(--gauzy-table-header-line-height, 0.8125rem);
border-radius: var(--gauzy-radius-sm, 0.375rem);
border-radius: var(--gauzy-table-badge-radius, 0.25rem);
.badge-img.badge-img {
width: var(--gauzy-table-header-font-size, 0.75rem);
@@ -199,6 +209,19 @@ nb-card-body {
// The page-header action row: "+ Add", the selection strip and the grid-vs-list
// selector, compacted to the table's scale (see the mixin).
:host .gauzy-button-container {
//
// `nb-card-header`, not `.gauzy-button-container` — which is what this was, and
// is the same mistake the mixin's own doc comment warns about. This header holds
// TWO button groups: the toolbar in `.tasks-component__settings >
// .gauzy-button-container`, and the project-settings gear, which is a bare
// `<button nbButton size="small">` sitting in `.card-header-title` beside the
// heading. Naming only the container left that gear at its full 0.5625rem box
// next to a toolbar that had shrunk to 0.25rem, so the one control the eye lands
// on first was the one still drawn oversized.
//
// Every metric the row needs is a CUSTOM PROPERTY, so one declaration on the
// header reaches both groups — `ngx-gauzy-button-action`'s own template and
// `ga-layout-selector` included, across their component boundaries.
:host nb-card-header {
@include ga-hub.page-header-buttons();
}
@@ -9,41 +9,19 @@
// one still drawn a size larger than its neighbours.
@include ga-hub.list-page();
// ── Row breathing room ───────────────────────────────────────────────────────
// ── No step up from the shared cell box ──────────────────────────────────────
//
// `density-tokens()` sizes a row for a line of TEXT: 1rem of line box plus
// 0.1875rem above and below. Text is the right thing to size that box around
// because a glyph only ever fills about two thirds of its line box, so the row
// still reads as padded while sitting at the floor.
// This page used to raise `--gauzy-table-cell-padding-*` to 0.4375rem/0.625rem
// and the header pair with it, because half its columns are people and, at the
// time, a person was pinned to a 1rem avatar — a disc that came within 3px of
// the rule above and the rule below it in ten consecutive rows.
//
// Half of this table's columns are people — Managers and Members, the two
// widest — and a person renders as an AVATAR, which fills its line box edge to
// edge: 1rem of disc in a 1rem box. At the shared floor those discs came within
// 3px of the rule above and the rule below them, in ten consecutive rows, which
// is what made a page of people read as one solid block with no rhythm for the
// eye to track along.
//
// 0.4375rem instead, which is the same ~30px row the rest of the section lands
// on once its text cells are padded (Tags runs 0.375rem around a 1rem line box)
// — the extra 1px a side here is the avatar being taller than the text it sits
// beside, not this page choosing a different scale.
//
// The gutter comes up to the app-wide 0.625rem with it: at 0.4375rem the Name
// column's first character sat against the surface's own ring, and the Tags
// column — the one the header's filter funnel hangs over — had less clear space
// than the chips inside it.
//
// Scoped to this page rather than pushed into `density-tokens()`: the shared
// values are what two dozen other tables are drawn at, and the avatar columns
// are what justifies the step.
:host {
--gauzy-table-cell-padding-y: 0.4375rem;
--gauzy-table-cell-padding-x: 0.625rem;
// The heading row with them, or the header ends up shorter than the rows it
// heads.
--gauzy-table-header-padding-y: 0.4375rem;
--gauzy-table-header-padding-x: 0.625rem;
}
// The height is in the chip now rather than in the cell: `ngx-people-list` is
// back at a 1.25rem avatar with `--gauzy-people-chip-padding-y` around it, which
// is the same 28px the Invoices Contact chip costs its row. So a `<td>` here is
// the Invoices `<td>` — 0.1875rem/0.4375rem around it — and the row lands at the
// same height from the same direction, instead of this one page holding a
// padding value of its own to make up the difference.
// ── The Tags cell ────────────────────────────────────────────────────────────
//
@@ -32,9 +32,11 @@
(userRowSelect)="selectTimeOffPolicy($event)"
></angular2-smart-table>
</div>
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
<div class="pagination-container">
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
</div>
}
<!-- Card Grid View -->
@case (componentLayoutStyleEnum.CARDS_GRID) {
@@ -1 +1,12 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale.
//
// The pager needed the `.pagination-container` wrapper the sibling list pages
// already had — the template rendered a bare `<ngx-pagination>` into the card
// body, so it was neither right-aligned nor compacted.
@include ga-hub.list-page();
@@ -89,9 +89,12 @@
#timeOffTable
></angular2-smart-table>
</div>
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
} }
<div class="pagination-container">
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
</div>
}
<!-- Grid Card View -->
@case (componentLayoutStyleEnum.CARDS_GRID) {
<div class="custom-grid">
@@ -1,4 +1,5 @@
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
nb-radio-group {
display: flex;
@@ -46,3 +47,29 @@ nb-radio-group {
margin-right: 16px;
border-radius: nb-theme(border-radius);
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale. The header holds `ngx-gauzy-button-action` plus this page's own
// filter strip and archive toggle, and the default `$actions` selector takes all
// of them together.
//
// The pager needed the `.pagination-container` wrapper the sibling list pages
// already had — the template rendered a bare `<ngx-pagination>` into the card
// body, so it was neither right-aligned nor compacted.
@include ga-hub.list-page();
// ── The Employee cell ───────────────────────────────────────────────────────
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, which pins
// a 20px image inside a pill padded 3px: 26px of chip, 2px short of the 28px
// `ngx-contact-links` costs the Invoices Contact column, and so 2px short of the
// Invoices row. Only that inset is restated — the image and the name stay the
// renderer's own 20px/12px — which takes the chip to the same 28px and the row
// to the same height.
//
// Table-scoped: `ga-card-grid` instantiates the same `renderComponent` for the
// card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
@@ -1,4 +1,5 @@
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
nb-card-body {
@@ -23,3 +24,25 @@
width: 100%;
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// Density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager's `<nav><ul><li>` and the toolbar scale — the same
// block Invoices carries. The toolbar is `ngx-gauzy-button-action` in
// `nb-card-header`, which the default `$actions` selector covers.
@include ga-hub.list-page();
// The one renderer `list-page()` does not reach: the People column renders
// `PictureNameTagsComponent`, i.e. `<ngx-avatar class="report-table">`, whose own
// sheet pins a 20px image inside a pill padded 3px — 26px of chip, 2px short of
// the 28px `ngx-contact-links` costs the Invoices Contact column, and so 2px
// short of the Invoices row. `avatar-chip()` restates only that inset; the image
// and the name stay the renderer's own. Every other people table in the app
// (Employees, Candidates, Approvals, Time Off, Recurring Expenses) carries this
// include — Users was the table left 2px shorter than the contract it shares.
//
// Table-scoped: `ga-card-grid` instantiates the same `renderComponent` for the
// card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
+13 -19
View File
@@ -176,28 +176,22 @@
// lays its row out in columns. A bare `.row` here would have redesigned three
// pages that were not asked about.
//
// THE CELL GUTTER, and why it is declared here rather than in the template.
//
// The cells used to carry Bootstrap's `p-0`. Every Bootstrap utility is
// `!important` — `.p-0 { padding: 0 !important }` — so no gutter could be added
// to these columns from a stylesheet at ANY specificity, and the grid had none:
// THE CELL GUTTER is Bootstrap's own. The cells used to carry `p-0`, and every
// Bootstrap utility is `!important` — `.p-0 { padding: 0 !important }` — so no
// gutter could be added to these columns from a stylesheet at ANY specificity:
// the website's ellipsis ended one pixel before the first tag chip began, and
// "Phone" and "Email" were two headings with nothing between them. The template
// drops `p-0`; this replaces it, which is what makes a per-edge value possible.
// drops `p-0`, which is what lets the grid's own COLUMN PADDING apply: on
// Bootstrap 4.6 (this app's version) a `.col-*` carries a fixed 15px either side
// and the `.row` pulls the outer pair back with a -15px margin. There is no
// gutter custom property to reach for here — `--bs-gutter-x` is Bootstrap 5 — so
// the value is the framework's and is changed by overriding the padding, not by
// setting a variable.
//
// Zero leading, 1rem trailing. A Bootstrap `.row` bleeds 15px past its container
// through negative margins, and the row's own 1.125rem gutter is sized to cover
// exactly that (see the `card-list-page()` call above) — so a leading pad would
// push the first cell off the edge that establishes, and the last column needs
// no trailing edge of its own. 1rem matches what a smart-table cell gets from
// `--gauzy-table-cell-padding-x` on either side of its border.
:host .vendor-row > [class*='col-'] {
padding-inline: 0 1rem;
}
:host .vendor-row > [class*='col-']:last-child {
padding-inline-end: 0;
}
// A hand-written `padding-inline: 0 1rem` pair used to sit here in its place. It
// is gone: a literal that had to be kept in step by eye with the row's own
// gutter and with the cell padding it was copied from, for a spacing the grid
// already provides.
// The name is the row's identity: it is the only value every vendor has, the
// one the list is scanned by, and the one the row is selected by. At the same
+1 -44
View File
@@ -6,48 +6,5 @@
"display": "standalone",
"scope": "/",
"start_url": "/"
// TODO add icons
// "icons": [
// {
// "src": "assets/icons/icon-72x72.png",
// "sizes": "72x72",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-96x96.png",
// "sizes": "96x96",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-128x128.png",
// "sizes": "128x128",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-144x144.png",
// "sizes": "144x144",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-152x152.png",
// "sizes": "152x152",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-192x192.png",
// "sizes": "192x192",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-384x384.png",
// "sizes": "384x384",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-512x512.png",
// "sizes": "512x512",
// "type": "image/png"
// }
// ]
}
+8 -52
View File
@@ -1,53 +1,9 @@
{
"name": "Gauzy",
"short_name": "gauzy",
"theme_color": "#666",
"background_color": "#fff",
"display": "standalone",
"scope": "/",
"start_url": "/"
// TODO add icons
// "icons": [
// {
// "src": "assets/icons/icon-72x72.png",
// "sizes": "72x72",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-96x96.png",
// "sizes": "96x96",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-128x128.png",
// "sizes": "128x128",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-144x144.png",
// "sizes": "144x144",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-152x152.png",
// "sizes": "152x152",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-192x192.png",
// "sizes": "192x192",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-384x384.png",
// "sizes": "384x384",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-512x512.png",
// "sizes": "512x512",
// "type": "image/png"
// }
// ]
}
"name": "Gauzy",
"short_name": "gauzy",
"theme_color": "#666",
"background_color": "#fff",
"display": "standalone",
"scope": "/",
"start_url": "/"
}
+8 -52
View File
@@ -1,53 +1,9 @@
{
"name": "Gauzy",
"short_name": "gauzy",
"theme_color": "#666",
"background_color": "#fff",
"display": "standalone",
"scope": "/",
"start_url": "/"
// TODO add icons
// "icons": [
// {
// "src": "assets/icons/icon-72x72.png",
// "sizes": "72x72",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-96x96.png",
// "sizes": "96x96",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-128x128.png",
// "sizes": "128x128",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-144x144.png",
// "sizes": "144x144",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-152x152.png",
// "sizes": "152x152",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-192x192.png",
// "sizes": "192x192",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-384x384.png",
// "sizes": "384x384",
// "type": "image/png"
// },
// {
// "src": "assets/icons/icon-512x512.png",
// "sizes": "512x512",
// "type": "image/png"
// }
// ]
}
"name": "Gauzy",
"short_name": "gauzy",
"theme_color": "#666",
"background_color": "#fff",
"display": "standalone",
"scope": "/",
"start_url": "/"
}
@@ -0,0 +1,140 @@
import '../core/entities/internal';
import { In } from 'typeorm';
import { PermissionsEnum } from '@gauzy/contracts';
import { RequestContext } from '../core/context';
import { ManagedEmployeeService } from './managed-employee.service';
const TENANT_ID = '9d347c5c-5b96-4ef3-9799-b5fa0ca09111';
const ORGANIZATION_ID = '3c6bdbd4-4e3f-4d8b-8a0e-8cf2c50ee2d1';
const TEAM_ID = '66ba5d6e-a3c6-4be8-b74d-fb8aece4bd58';
const OTHER_TEAM_ID = 'c5fc5345-c47b-4b08-b63c-f17c88679aca';
const ACTOR_ID = '7f0d2585-296f-49cc-a229-210f5f11e372';
const TARGET_ID = '12128029-8b07-45a0-9690-181a66a660fc';
describe('ManagedEmployeeService.canManageEmployee without a team context', () => {
let service: ManagedEmployeeService;
let teamEmployeeRepository: { find: jest.Mock; existsBy: jest.Mock };
beforeEach(() => {
teamEmployeeRepository = {
find: jest.fn(async () => []),
existsBy: jest.fn(async () => false)
};
service = new ManagedEmployeeService(teamEmployeeRepository as any, {} as any);
jest.spyOn(RequestContext, 'hasPermission').mockReturnValue(false);
jest.spyOn(RequestContext, 'currentTenantId').mockReturnValue(TENANT_ID);
jest.spyOn(RequestContext, 'currentEmployeeId').mockReturnValue(ACTOR_ID);
jest.spyOn(RequestContext, 'currentUser').mockReturnValue({ employeeId: ACTOR_ID } as any);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('allows a manager whose managed team, in the given organization, contains the target employee', async () => {
teamEmployeeRepository.find.mockResolvedValue([{ organizationTeamId: TEAM_ID }]);
teamEmployeeRepository.existsBy.mockResolvedValue(true);
await expect(service.canManageEmployee(TARGET_ID, undefined, ORGANIZATION_ID)).resolves.toBe(true);
// The full predicate is asserted on purpose: dropping isActive/isArchived or the organization
// anchor would silently widen the fallback, and objectContaining would not notice.
expect(teamEmployeeRepository.find).toHaveBeenCalledTimes(1);
expect(teamEmployeeRepository.find.mock.calls[0][0].where).toEqual({
employeeId: ACTOR_ID,
isManager: true,
isActive: true,
isArchived: false,
tenantId: TENANT_ID,
organizationTeam: { organizationId: ORGANIZATION_ID }
});
expect(teamEmployeeRepository.existsBy).toHaveBeenCalledTimes(1);
expect(teamEmployeeRepository.existsBy).toHaveBeenCalledWith({
employeeId: TARGET_ID,
organizationTeamId: In([TEAM_ID]),
isActive: true,
isArchived: false,
tenantId: TENANT_ID
});
});
it('denies when the target employee belongs to no team the caller manages', async () => {
teamEmployeeRepository.find.mockResolvedValue([{ organizationTeamId: OTHER_TEAM_ID }]);
teamEmployeeRepository.existsBy.mockResolvedValue(false);
await expect(service.canManageEmployee(TARGET_ID, undefined, ORGANIZATION_ID)).resolves.toBe(false);
expect(teamEmployeeRepository.existsBy).toHaveBeenCalledWith(
expect.objectContaining({ organizationTeamId: In([OTHER_TEAM_ID]) })
);
});
it('denies without a membership query when the caller manages no team', async () => {
teamEmployeeRepository.find.mockResolvedValue([]);
await expect(service.canManageEmployee(TARGET_ID, undefined, ORGANIZATION_ID)).resolves.toBe(false);
expect(teamEmployeeRepository.existsBy).not.toHaveBeenCalled();
});
it('denies without any query when no organization is supplied', async () => {
// Without an organization the fallback has no anchor: an undefined where key is dropped from
// the query, so the check would span every organization of the tenant. It must fail closed.
teamEmployeeRepository.find.mockResolvedValue([{ organizationTeamId: TEAM_ID }]);
teamEmployeeRepository.existsBy.mockResolvedValue(true);
await expect(service.canManageEmployee(TARGET_ID)).resolves.toBe(false);
expect(teamEmployeeRepository.find).not.toHaveBeenCalled();
expect(teamEmployeeRepository.existsBy).not.toHaveBeenCalled();
});
it('denies a missing target employee before querying', async () => {
await expect(service.canManageEmployee(undefined as any, undefined, ORGANIZATION_ID)).resolves.toBe(false);
await expect(service.canManageEmployee(undefined as any, TEAM_ID, ORGANIZATION_ID)).resolves.toBe(false);
expect(teamEmployeeRepository.find).not.toHaveBeenCalled();
expect(teamEmployeeRepository.existsBy).not.toHaveBeenCalled();
});
it('does not fall back to other teams when a team is supplied and the caller does not manage it', async () => {
// The caller manages TEAM_ID, where the target is a member, but the record belongs to OTHER_TEAM_ID.
teamEmployeeRepository.find.mockResolvedValue([{ organizationTeamId: TEAM_ID }]);
teamEmployeeRepository.existsBy.mockResolvedValue(false);
await expect(service.canManageEmployee(TARGET_ID, OTHER_TEAM_ID, ORGANIZATION_ID)).resolves.toBe(false);
expect(teamEmployeeRepository.existsBy).toHaveBeenCalledTimes(1);
expect(teamEmployeeRepository.existsBy).toHaveBeenCalledWith(
expect.objectContaining({ employeeId: ACTOR_ID, organizationTeamId: OTHER_TEAM_ID, isManager: true })
);
expect(teamEmployeeRepository.find).not.toHaveBeenCalled();
});
it('checks the supplied team only when the caller manages it', async () => {
teamEmployeeRepository.existsBy.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
await expect(service.canManageEmployee(TARGET_ID, TEAM_ID, ORGANIZATION_ID)).resolves.toBe(true);
expect(teamEmployeeRepository.existsBy).toHaveBeenCalledTimes(2);
expect(teamEmployeeRepository.existsBy).toHaveBeenLastCalledWith(
expect.objectContaining({ employeeId: TARGET_ID, organizationTeamId: TEAM_ID })
);
expect(teamEmployeeRepository.find).not.toHaveBeenCalled();
});
it('keeps the self-access and global permission short circuits', async () => {
await expect(service.canManageEmployee(ACTOR_ID)).resolves.toBe(true);
jest.spyOn(RequestContext, 'hasPermission').mockImplementation(
(permission) => permission === PermissionsEnum.CHANGE_SELECTED_EMPLOYEE
);
await expect(service.canManageEmployee(TARGET_ID)).resolves.toBe(true);
expect(teamEmployeeRepository.find).not.toHaveBeenCalled();
expect(teamEmployeeRepository.existsBy).not.toHaveBeenCalled();
});
});
@@ -145,12 +145,15 @@ export class ManagedEmployeeService {
* 1. Global permissions (CHANGE_SELECTED_EMPLOYEE)
* 2. Self-access (currentEmployeeId === targetEmployeeId)
* 3. Manager status in the specified team (if organizationTeamId provided)
* 4. Otherwise, manager status in any team of the record's organization that the target
* employee belongs to. This fallback needs `organizationId` and denies without it.
*
* @param targetEmployeeId - The employee ID to check access for
* @param organizationTeamId - Optional team ID to check manager status
* @param organizationId - The organization the record belongs to; anchors the no-team fallback
* @returns true if the current employee can manage the target employee
*/
async canManageEmployee(targetEmployeeId: ID, organizationTeamId?: ID): Promise<boolean> {
async canManageEmployee(targetEmployeeId: ID, organizationTeamId?: ID, organizationId?: ID): Promise<boolean> {
const user = RequestContext.currentUser();
const currentEmployeeId = user?.employeeId;
@@ -159,8 +162,10 @@ export class ManagedEmployeeService {
return true;
}
// Case 2: No employeeId (user not logged in as employee)
if (!currentEmployeeId) {
// Case 2: No employee identity on either side (user not logged in as employee, or no target).
// Fail closed: an undefined target would be dropped from the membership queries below and
// match any member of the team.
if (!currentEmployeeId || !targetEmployeeId) {
return false;
}
@@ -203,8 +208,16 @@ export class ManagedEmployeeService {
return isTargetMemberOfTeam;
}
// Case 5: No team context provided → No access
return false;
// Case 5: Records such as daily plans carry a nullable organizationTeamId, so callers cannot
// always supply one. Fall back to "is there a team I manage that this employee belongs to",
// restricted to the record's organization. That organization is the only anchor this branch
// has: without it the check fails closed instead of spanning every organization of the tenant
// (an undefined where key is dropped from the query in this codebase).
if (!organizationId) {
return false;
}
return await this.canManageEmployeeInAnyTeam(targetEmployeeId, organizationId);
}
/**
@@ -346,13 +359,16 @@ export class ManagedEmployeeService {
* Checks if the current employee can manage a target employee in ANY team.
*
* @param targetEmployeeId - The employee ID to check access for
* @param organizationId - Optional organization to restrict the managed teams to
* @returns true if the current employee manages the target employee in at least one team
*/
private async canManageEmployeeInAnyTeam(targetEmployeeId: ID): Promise<boolean> {
private async canManageEmployeeInAnyTeam(targetEmployeeId: ID, organizationId?: ID): Promise<boolean> {
const currentEmployeeId = RequestContext.currentEmployeeId();
const tenantId = RequestContext.currentTenantId();
if (!currentEmployeeId || !tenantId) {
// Fail closed on a missing target as well: an undefined key is dropped from the membership
// query, which would otherwise match any member of a managed team.
if (!currentEmployeeId || !targetEmployeeId || !tenantId) {
return false;
}
@@ -363,7 +379,10 @@ export class ManagedEmployeeService {
isManager: true,
isActive: true,
isArchived: false,
tenantId
tenantId,
// Scoped through the team, whose organizationId is authoritative,
// rather than through the membership row where it may be null.
...(organizationId ? { organizationTeam: { organizationId } } : {})
},
select: {
organizationTeamId: true
@@ -0,0 +1,170 @@
import '../../core/entities/internal';
import { AsyncLocalStorage } from 'node:async_hooks';
import { NotFoundException } from '@nestjs/common';
import { RequestContext } from '../../core/context';
import { MultiORMEnum } from '../../core/utils';
import { DailyPlanService } from './daily-plan.service';
const TENANT_ID = '2a5d5f2e-0c4e-4f2b-9a0b-3d6a4b2f1c11';
const ORGANIZATION_ID = '8b1f0e3a-7c2d-4a5e-b6f7-1d2e3f4a5b6c';
const TEAM_ID = '5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9';
const PLAN_ID = 'd4c3b2a1-f6e5-4d7c-8b9a-0f1e2d3c4b5a';
const TASK_ID = '0f1e2d3c-4b5a-4968-8776-655443322110';
const MANAGER_ID = '6c5b4a39-2817-4f6e-9d8c-7b6a5f4e3d2c';
const MEMBER_ID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d';
const MANAGER_FILTER = { employee: { id: MANAGER_ID }, employeeId: MANAGER_ID };
/** Stands in for nestjs-cls, which stores values per async context rather than per process. */
const requestStorage = new AsyncLocalStorage<Map<string, unknown>>();
const inRequest = <R>(callback: () => Promise<R>): Promise<R> => requestStorage.run(new Map(), callback);
/**
* A team manager without CHANGE_SELECTED_EMPLOYEE acting on a member's daily plan. The service
* must look the plan up by its OWNER (the automatic employee filter would otherwise pin the lookup
* to the caller and the manager path could never run), decide access from the stored plan, and
* only then return or write anything.
*/
describe('DailyPlanService manager access', () => {
const originalClsService = RequestContext['clsService'];
let service: DailyPlanService;
let repository: { metadata: unknown; findOne: jest.Mock; findOneOrFail: jest.Mock; save: jest.Mock };
let managedEmployeeService: { canManageEmployee: jest.Mock };
let taskService: { findOneByIdString: jest.Mock };
let plan: Record<string, unknown>;
beforeEach(() => {
RequestContext['clsService'] = {
get: (key: string) => requestStorage.getStore()?.get(key),
set: (key: string, value: unknown) => requestStorage.getStore()?.set(key, value)
} as any;
plan = {
id: PLAN_ID,
tenantId: TENANT_ID,
organizationId: ORGANIZATION_ID,
employeeId: MEMBER_ID,
organizationTeamId: null,
tasks: []
};
repository = {
metadata: {
tableName: 'daily_plan',
hasColumnWithPropertyPath: (path: string) => ['employeeId', 'tenantId', 'organizationId'].includes(path)
},
findOne: jest.fn(async () => plan),
findOneOrFail: jest.fn(async () => plan),
save: jest.fn(async (entity: unknown) => entity)
};
managedEmployeeService = { canManageEmployee: jest.fn(async () => true) };
taskService = { findOneByIdString: jest.fn(async () => ({ id: TASK_ID })) };
service = new DailyPlanService(
repository as any,
{} as any,
{} as any,
taskService as any,
managedEmployeeService as any
);
// The ORM switch is resolved from DB_ORM at module load; pin it so the suite ignores the local .env.
Object.defineProperty(service, 'ormType', { value: MultiORMEnum.TypeORM });
jest.spyOn(RequestContext, 'hasPermission').mockReturnValue(false);
jest.spyOn(RequestContext, 'currentTenantId').mockReturnValue(TENANT_ID);
jest.spyOn(RequestContext, 'currentEmployeeId').mockReturnValue(MANAGER_ID);
jest.spyOn(RequestContext, 'currentUser').mockReturnValue({
id: 'c0ffee00-0000-4000-8000-000000000001',
tenantId: TENANT_ID,
employeeId: MANAGER_ID
} as any);
});
afterEach(() => {
RequestContext['clsService'] = originalClsService;
jest.restoreAllMocks();
});
const addTask = (input: Record<string, unknown>) => service.addTaskToPlan(PLAN_ID, input as any);
it('looks the plan up by its owner, not the caller, and decides access before returning it', async () => {
await inRequest(async () => {
const result = await addTask({ employeeId: MEMBER_ID, taskId: TASK_ID, organizationId: ORGANIZATION_ID });
expect(repository.findOneOrFail).toHaveBeenCalledTimes(1);
const { where } = repository.findOneOrFail.mock.calls[0][0];
expect(where).toEqual(
expect.objectContaining({
id: PLAN_ID,
employeeId: MEMBER_ID,
tenantId: TENANT_ID,
organizationId: ORGANIZATION_ID
})
);
// The caller's own employee scope must not have been injected into the lookup.
expect(where.employee).toBeUndefined();
expect(managedEmployeeService.canManageEmployee).toHaveBeenCalledWith(MEMBER_ID, null, ORGANIZATION_ID);
expect(repository.save).toHaveBeenCalledTimes(1);
expect(result.tasks).toEqual([{ id: TASK_ID }]);
});
});
it('anchors the manager check on the stored plan rather than the request body', async () => {
plan.organizationTeamId = TEAM_ID;
await inRequest(async () => {
await addTask({ taskId: TASK_ID });
expect(managedEmployeeService.canManageEmployee).toHaveBeenCalledWith(MEMBER_ID, TEAM_ID, ORGANIZATION_ID);
});
});
it('throws NotFound and writes nothing when the caller cannot manage the plan owner', async () => {
managedEmployeeService.canManageEmployee.mockResolvedValue(false);
await inRequest(async () => {
await expect(
addTask({ employeeId: MEMBER_ID, taskId: TASK_ID, organizationId: ORGANIZATION_ID })
).rejects.toBeInstanceOf(NotFoundException);
expect(repository.findOne).not.toHaveBeenCalled();
expect(taskService.findOneByIdString).not.toHaveBeenCalled();
expect(repository.save).not.toHaveBeenCalled();
});
});
it('throws the same NotFound when the plan does not exist, without consulting the manager check', async () => {
repository.findOneOrFail.mockRejectedValue(new Error('EntityNotFound'));
await inRequest(async () => {
await expect(
addTask({ employeeId: MEMBER_ID, taskId: TASK_ID, organizationId: ORGANIZATION_ID })
).rejects.toBeInstanceOf(NotFoundException);
expect(managedEmployeeService.canManageEmployee).not.toHaveBeenCalled();
expect(repository.save).not.toHaveBeenCalled();
});
});
it('restores the employee filter once the lookup is done', async () => {
await inRequest(async () => {
await addTask({ employeeId: MEMBER_ID, taskId: TASK_ID, organizationId: ORGANIZATION_ID });
expect(service['findConditionsWithEmployeeByUser']()).toEqual(MANAGER_FILTER);
});
});
it('skips the manager check for a caller with CHANGE_SELECTED_EMPLOYEE', async () => {
jest.spyOn(RequestContext, 'hasPermission').mockReturnValue(true);
await inRequest(async () => {
await addTask({ employeeId: MEMBER_ID, taskId: TASK_ID, organizationId: ORGANIZATION_ID });
expect(repository.findOneOrFail).not.toHaveBeenCalled();
expect(managedEmployeeService.canManageEmployee).not.toHaveBeenCalled();
expect(repository.save).toHaveBeenCalledTimes(1);
});
});
});
@@ -307,25 +307,38 @@ export class DailyPlanService extends TenantAwareCrudService<DailyPlan> {
relations: { tasks: true }
});
} else {
// User is potentially a manager → Check access first
// Step 1: Fetch minimal data to get organizationTeamId
const planTeamInfo = await this.findOneByOptions({
where: {
id: planId,
employeeId,
tenantId,
organizationId
}
});
// User is potentially a manager → Check access first.
// Step 1: Fetch minimal data to get the plan's owner, team and organization.
// This read must run without the automatic employee filter: for a caller without
// CHANGE_SELECTED_EMPLOYEE that filter overrides `employeeId` with the caller's own id, so a
// plan owned by anyone else would never be found and the manager check below could never run.
// The bypass covers this single read only; access is decided before anything is returned.
const { success, record: planTeamInfo } = await this.withoutEmployeeFilter(() =>
this.findOneOrFailByOptions({
where: {
id: planId,
employeeId,
tenantId,
organizationId
}
})
);
// Step 2: Check if current user can manage this employee in this team
// Step 2: Check if current user can manage the plan's owner in the plan's team.
// The owner and the organization come from the stored plan, not from the request body, so
// the check stays anchored to the record itself.
// Note: We throw the same generic error whether the plan doesn't exist or the user lacks permission
// to avoid leaking information about which plan IDs exist in the system
const canManage =
planTeamInfo &&
(await this._managedEmployeeService.canManageEmployee(employeeId, planTeamInfo.organizationTeamId));
success &&
!!planTeamInfo &&
(await this._managedEmployeeService.canManageEmployee(
planTeamInfo.employeeId ?? employeeId,
planTeamInfo.organizationTeamId,
planTeamInfo.organizationId ?? organizationId
));
if (!planTeamInfo || !canManage) {
if (!canManage) {
throw new NotFoundException('Daily plan not found or you do not have permission to access it');
}
+1
View File
@@ -1,5 +1,6 @@
module.exports = {
displayName: 'mcp-server',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
+2 -58
View File
@@ -4,34 +4,7 @@ import { version } from './common/version';
import { sanitizeErrorMessage } from './common/error-utils';
import { TransportFactory, TransportResult } from './transports';
import { sessionManager } from './session/session-manager';
import { registerTimerTools } from './tools/timer';
import { registerProjectTools } from './tools/projects';
import { registerTaskTools } from './tools/tasks';
import { registerEmployeeTools } from './tools/employees';
import { registerTestTools } from './tools/test-connection';
import { registerDailyPlanTools } from './tools/daily-plan';
import { registerOrganizationContactTools } from './tools/organization-contact';
import { registerAuthTools } from './tools/auth';
import { registerProductTools } from './tools/products';
import { registerProductCategoryTools } from './tools/product-categories';
import { registerInvoiceTools } from './tools/invoices';
import { registerExpenseTools } from './tools/expenses';
import { registerGoalTools } from './tools/goals';
import { registerKeyResultTools } from './tools/key-results';
import { registerDealTools } from './tools/deals';
import { registerCandidateTools } from './tools/candidates';
import { registerPaymentTools } from './tools/payments';
import { registerMerchantTools } from './tools/merchants';
import { registerIncomeTools } from './tools/incomes';
import { registerEquipmentTools } from './tools/equipment';
import { registerCommentTools } from './tools/comments';
import { registerReportTools } from './tools/reports';
import { registerTimeOffTools } from './tools/time-off';
import { registerEmployeeAwardTools } from './tools/employee-awards';
import { registerActivityLogTools } from './tools/activity-logs';
import { registerWarehouseTools } from './tools/warehouses';
import { registerPipelineTools } from './tools/pipelines';
import { registerSkillTools } from './tools/skills';
import { registerAllMcpTools } from './tools/register-all-tools';
const logger = new Logger('McpServer');
@@ -243,36 +216,7 @@ export function createMcpServer(sessionId?: string) {
try {
// Register all available tools (functions that can be called by the LLM)
registerAuthTools(server, sessionId); // Register session-aware authentication tools first
registerTimerTools(server);
registerProjectTools(server);
registerTaskTools(server);
registerEmployeeTools(server);
registerDailyPlanTools(server);
registerOrganizationContactTools(server);
registerTestTools(server);
registerProductTools(server);
registerProductCategoryTools(server);
registerInvoiceTools(server);
registerExpenseTools(server);
registerGoalTools(server);
registerKeyResultTools(server);
registerDealTools(server);
registerCandidateTools(server);
registerPaymentTools(server);
registerMerchantTools(server);
registerIncomeTools(server);
registerEquipmentTools(server);
registerCommentTools(server);
registerReportTools(server);
registerTimeOffTools(server);
registerEmployeeAwardTools(server);
registerActivityLogTools(server);
registerWarehouseTools(server);
registerPipelineTools(server);
registerSkillTools(server);
registerAllMcpTools(server, sessionId);
logger.log('Gauzy MCP Server: All tools registered successfully');
} catch (error) {
+5 -4
View File
@@ -117,14 +117,15 @@ const ActivityLogRelationsSchema = z
// ===== BASE SCHEMAS =====
// Base entity schema with common fields
// API / MCP JSON payloads use ISO datetime strings (JSON Schema cannot represent Date)
const BaseEntitySchema = z.object({
id: z.string().uuid().readonly().optional(),
createdAt: z.date().readonly().optional(),
updatedAt: z.date().readonly().optional(),
deletedAt: z.date().nullable().readonly().optional(),
createdAt: z.string().datetime({ offset: true }).readonly().optional(),
updatedAt: z.string().datetime({ offset: true }).readonly().optional(),
deletedAt: z.string().datetime({ offset: true }).nullable().readonly().optional(),
isActive: z.boolean().optional().default(true),
isArchived: z.boolean().optional().default(false),
archivedAt: z.date().nullable().optional(),
archivedAt: z.string().datetime({ offset: true }).nullable().optional(),
// User action tracking
createdByUserId: z.string().uuid().readonly().optional(),
updatedByUserId: z.string().uuid().readonly().optional(),
@@ -12,7 +12,6 @@ import {
ActivityLogSortByEnum,
ActivityLogRelationsSchema
} from '../input-schemas';
import { ActivityLogSchema } from '../schema';
import { sanitizeErrorMessage, sanitizeForLogging } from '../common/error-utils';
import { registerTool, registerNoArgsTool } from './tool-helper';
@@ -189,11 +188,21 @@ export const registerActivityLogTools = (server: McpServer) => {
'create_activity_log',
"Create a new activity log entry in the authenticated user's organization",
{
log_data: ActivityLogSchema.partial()
.required({
entity: true,
entityId: true,
action: true
// Dedicated MCP input schema (ISO/JSON-safe). Do not reuse ActivityLogSchema:
// nested entity refs still contain z.date() fields that break tools/list.
log_data: z
.object({
entity: ActivityLogEntityEnum,
entityId: z.string().uuid(),
action: ActivityLogActionEnum,
data: z.record(z.string(), z.any()).optional(),
previousValues: z.record(z.string(), z.any()).optional(),
updatedFields: z.array(z.string()).optional(),
actorType: ActorTypeEnum.optional(),
createdByUserId: z.string().uuid().optional(),
employeeId: z.string().uuid().optional(),
// JSON-safe employee payload (do not reuse EmployeeSchema — it contains z.date())
employee: z.record(z.string(), z.any()).optional()
})
.describe('The data for creating the activity log')
},
+29 -40
View File
@@ -10,6 +10,31 @@ import { sanitizeErrorMessage, sanitizeForLogging } from '../common/error-utils'
import { registerTool, registerNoArgsTool } from './tool-helper';
const logger = new Logger('EmployeeTools');
const workingEmployeesForRangeSchema = z
.object({
startDate: z.string().datetime({ offset: true }).optional().describe('Start date in ISO format'),
endDate: z.string().datetime({ offset: true }).optional().describe('End date in ISO format')
})
.optional()
.describe('Date range for filtering');
type WorkingEmployeesForRange = z.infer<typeof workingEmployeesForRangeSchema>;
async function fetchWorkingEmployees(path: '/api/employee/working' | '/api/employee/working/count', forRange?: WorkingEmployeesForRange) {
const defaultParams = validateOrganizationContext();
const data = {
findInput: {
organizationId: defaultParams.organizationId,
...(defaultParams.tenantId && { tenantId: defaultParams.tenantId }),
...(forRange && { forRange })
}
};
return apiClient.get(path, {
params: { data: JSON.stringify(data) }
});
}
/**
* Helper function to convert date fields in employee data to Date objects
*/
@@ -171,29 +196,11 @@ export const registerEmployeeTools = (server: McpServer) => {
'get_working_employees',
"Get all working employees in the authenticated user's organization",
{
forRange: z
.object({
start: z.string().datetime().optional().describe('Start date in ISO format'),
end: z.string().datetime().optional().describe('End date in ISO format')
})
.optional()
.describe('Date range for filtering')
forRange: workingEmployeesForRangeSchema
},
async ({ forRange }) => {
try {
const defaultParams = validateOrganizationContext();
const data = {
findInput: {
organizationId: defaultParams.organizationId,
...(defaultParams.tenantId && { tenantId: defaultParams.tenantId }),
...(forRange && { forRange })
}
};
const response = await apiClient.get('/api/employee/working', {
params: { data: JSON.stringify(data) }
});
const response = await fetchWorkingEmployees('/api/employee/working', forRange);
return {
content: [
@@ -216,29 +223,11 @@ export const registerEmployeeTools = (server: McpServer) => {
'get_working_employees_count',
"Get working employees count in the authenticated user's organization",
{
forRange: z
.object({
start: z.date().optional(),
end: z.date().optional()
})
.optional()
.describe('Date range for filtering')
forRange: workingEmployeesForRangeSchema
},
async ({ forRange }) => {
try {
const defaultParams = validateOrganizationContext();
const data = {
findInput: {
organizationId: defaultParams.organizationId,
...(defaultParams.tenantId && { tenantId: defaultParams.tenantId }),
...(forRange && { forRange })
}
};
const response = await apiClient.get('/api/employee/working/count', {
params: { data: JSON.stringify(data) }
});
const response = await fetchWorkingEmployees('/api/employee/working/count', forRange);
return {
content: [
@@ -19,6 +19,7 @@ export * from './pipelines';
export * from './product-categories';
export * from './products';
export * from './projects';
export * from './register-all-tools';
export * from './reports';
export * from './skills';
export * from './tasks';
@@ -0,0 +1,234 @@
/**
* Regression: MCP tools/list converts every tool inputSchema with the Zod toJSONSchema helper.
* z.date() throws "Date cannot be represented in JSON Schema" and fails the whole list.
*
* Env for mcp-server module load is set in packages/mcp-server/jest.config.ts.
*/
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { registerEmployeeTools } from './employees';
import { registerAllMcpTools } from './register-all-tools';
import { createMcpServer } from '../mcp-server';
/** Floor based on current production tool surface; bump if modules are removed intentionally. */
const MIN_REGISTERED_TOOLS = 300;
const MIN_TOOLS_WITH_INPUT_SCHEMA = 280;
type CapturedTool = {
name: string;
inputSchema?: z.ZodTypeAny;
};
type ForRangeDateFields = {
properties?: {
forRange?: {
properties?: {
startDate?: { type?: string; format?: string };
endDate?: { type?: string; format?: string };
};
};
};
};
type JsonRpcResponse = {
jsonrpc: '2.0';
id?: number | string;
result?: {
tools?: Array<{
name: string;
inputSchema?: ForRangeDateFields;
}>;
serverInfo?: { name?: string };
};
error?: { code: number; message: string; data?: unknown };
};
function isZodShape(value: unknown): value is Record<string, z.ZodTypeAny> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function expectForRangeIsoDateTime(schema: ForRangeDateFields | undefined) {
expect(schema?.properties?.forRange?.properties?.startDate?.type).toBe('string');
expect(schema?.properties?.forRange?.properties?.endDate?.type).toBe('string');
expect(schema?.properties?.forRange?.properties?.startDate?.format).toBe('date-time');
expect(schema?.properties?.forRange?.properties?.endDate?.format).toBe('date-time');
}
function createCapturingServer(): { server: McpServer; tools: CapturedTool[] } {
const tools: CapturedTool[] = [];
const capture = (name: string, inputSchema?: z.ZodTypeAny) => {
tools.push({ name, inputSchema });
};
const server = {
registerTool: (name: string, config: { inputSchema?: z.ZodTypeAny }) => {
capture(name, config?.inputSchema);
},
/**
* Legacy SDK API still used by some modules (e.g. tasks.ts).
* Supported:
* - tool(name, description, callback) — no input schema
* - tool(name, description, paramsShape, callback) — Zod shape object
* Anything else throws so schemas cannot be dropped silently.
*/
tool: (...args: unknown[]) => {
const name = String(args[0] ?? '<unknown>');
const description = args[1];
const third = args[2];
const fourth = args[3];
if (typeof description !== 'string') {
throw new Error(`Unrecognized server.tool() signature for "${name}": expected description string`);
}
if (typeof third === 'function' && fourth === undefined) {
capture(name);
return;
}
if (isZodShape(third) && typeof fourth === 'function') {
capture(name, z.object(third));
return;
}
throw new Error(
`Unrecognized server.tool() signature for "${name}" (argCount=${args.length}). ` +
'Update the capturing mock — do not silently skip schemas.'
);
}
} as unknown as McpServer;
return { server, tools };
}
async function waitForJsonRpcResponse(
transport: InMemoryTransport,
id: number,
// Below Jest's default 5s test timeout (@nx/jest/preset) so this message wins on hang.
timeoutMs = 4000
): Promise<JsonRpcResponse> {
return await new Promise<JsonRpcResponse>((resolve, reject) => {
const previous = transport.onmessage;
const timer = setTimeout(() => {
transport.onmessage = previous;
reject(new Error(`Timed out waiting for JSON-RPC response id=${id}`));
}, timeoutMs);
transport.onmessage = (message, extra) => {
if (typeof previous === 'function') {
previous(message, extra);
}
const response = message as JsonRpcResponse;
if (response?.id === id) {
clearTimeout(timer);
transport.onmessage = previous;
resolve(response);
}
};
});
}
describe('MCP tool input schemas JSON Schema conversion', () => {
it('converts every registered tool inputSchema via z.toJSONSchema (tools/list path)', () => {
const { server, tools } = createCapturingServer();
registerAllMcpTools(server);
expect(tools.length).toBeGreaterThanOrEqual(MIN_REGISTERED_TOOLS);
expect(new Set(tools.map((tool) => tool.name)).size).toBe(tools.length);
const withSchema = tools.filter((tool) => tool.inputSchema);
expect(withSchema.length).toBeGreaterThanOrEqual(MIN_TOOLS_WITH_INPUT_SCHEMA);
const failures: string[] = [];
for (const tool of withSchema) {
try {
z.toJSONSchema(tool.inputSchema!, { io: 'input' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(`${tool.name}: ${message}`);
}
}
expect(failures).toEqual([]);
});
it('converts get_working_employees_count forRange schema (ISO datetime, not Date)', () => {
const { server, tools } = createCapturingServer();
registerEmployeeTools(server);
const countTool = tools.find((t) => t.name === 'get_working_employees_count');
expect(countTool?.inputSchema).toBeDefined();
const jsonSchema = z.toJSONSchema(countTool!.inputSchema!, { io: 'input' }) as ForRangeDateFields;
expectForRangeIsoDateTime(jsonSchema);
const offsetRange = {
forRange: {
startDate: '2026-01-01T00:00:00+05:30',
endDate: '2026-01-31T23:59:59+05:30'
}
};
expect(countTool!.inputSchema!.safeParse(offsetRange).success).toBe(true);
const listTool = tools.find((t) => t.name === 'get_working_employees');
expect(listTool?.inputSchema!.safeParse(offsetRange).success).toBe(true);
});
it('lists tools through the production MCP initialize → tools/list protocol path', async () => {
const { server } = createMcpServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await clientTransport.start();
try {
const initializeWait = waitForJsonRpcResponse(clientTransport, 1);
await clientTransport.send({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'schema-repro', version: '1' }
}
});
const initializeResponse = await initializeWait;
expect(initializeResponse.error).toBeUndefined();
await clientTransport.send({
jsonrpc: '2.0',
method: 'notifications/initialized'
});
const listWait = waitForJsonRpcResponse(clientTransport, 2);
await clientTransport.send({
jsonrpc: '2.0',
id: 2,
method: 'tools/list',
params: {}
});
const listResponse = await listWait;
expect(listResponse.error).toBeUndefined();
expect(listResponse.result?.tools?.length ?? 0).toBeGreaterThanOrEqual(MIN_REGISTERED_TOOLS);
const toolNames = (listResponse.result?.tools ?? []).map((tool) => tool.name);
expect(new Set(toolNames).size).toBe(toolNames.length);
const countTool = (listResponse.result?.tools ?? []).find(
(tool) => tool.name === 'get_working_employees_count'
);
expect(countTool).toBeDefined();
expectForRangeIsoDateTime(countTool?.inputSchema);
} finally {
await clientTransport.close();
await server.close();
}
});
});
@@ -0,0 +1,64 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerAuthTools } from './auth';
import { registerTimerTools } from './timer';
import { registerProjectTools } from './projects';
import { registerTaskTools } from './tasks';
import { registerEmployeeTools } from './employees';
import { registerDailyPlanTools } from './daily-plan';
import { registerOrganizationContactTools } from './organization-contact';
import { registerTestTools } from './test-connection';
import { registerProductTools } from './products';
import { registerProductCategoryTools } from './product-categories';
import { registerInvoiceTools } from './invoices';
import { registerExpenseTools } from './expenses';
import { registerGoalTools } from './goals';
import { registerKeyResultTools } from './key-results';
import { registerDealTools } from './deals';
import { registerCandidateTools } from './candidates';
import { registerPaymentTools } from './payments';
import { registerMerchantTools } from './merchants';
import { registerIncomeTools } from './incomes';
import { registerEquipmentTools } from './equipment';
import { registerCommentTools } from './comments';
import { registerReportTools } from './reports';
import { registerTimeOffTools } from './time-off';
import { registerEmployeeAwardTools } from './employee-awards';
import { registerActivityLogTools } from './activity-logs';
import { registerWarehouseTools } from './warehouses';
import { registerPipelineTools } from './pipelines';
import { registerSkillTools } from './skills';
/**
* Register every MCP tool module on the given server.
* Shared by production server bootstrap and schema regression tests.
*/
export function registerAllMcpTools(server: McpServer, sessionId?: string): void {
registerAuthTools(server, sessionId);
registerTimerTools(server);
registerProjectTools(server);
registerTaskTools(server);
registerEmployeeTools(server);
registerDailyPlanTools(server);
registerOrganizationContactTools(server);
registerTestTools(server);
registerProductTools(server);
registerProductCategoryTools(server);
registerInvoiceTools(server);
registerExpenseTools(server);
registerGoalTools(server);
registerKeyResultTools(server);
registerDealTools(server);
registerCandidateTools(server);
registerPaymentTools(server);
registerMerchantTools(server);
registerIncomeTools(server);
registerEquipmentTools(server);
registerCommentTools(server);
registerReportTools(server);
registerTimeOffTools(server);
registerEmployeeAwardTools(server);
registerActivityLogTools(server);
registerWarehouseTools(server);
registerPipelineTools(server);
registerSkillTools(server);
}
+5
View File
@@ -0,0 +1,5 @@
// Required by mcp-server environment validation when tool modules are imported in tests.
// Kept out of jest.config.ts: the static-checks `typecheck-configs` job compiles every jest config
// without Node types, so a config cannot reference `process`.
process.env.API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000';
process.env.GAUZY_AUTO_LOGIN = process.env.GAUZY_AUTO_LOGIN || 'false';
+1 -1
View File
@@ -21,5 +21,5 @@
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts", "src/lib/schema.ts"]
"exclude": ["jest.config.ts", "src/test-setup.ts", "src/**/*.spec.ts", "src/**/*.test.ts", "src/lib/schema.ts"]
}
+7 -2
View File
@@ -4,7 +4,12 @@
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node16",
"types": ["jest", "node"]
"types": ["jest", "node"],
"esModuleInterop": true,
"noImplicitAny": false,
"strictNullChecks": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
"include": ["jest.config.ts", "src/test-setup.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}
@@ -1,27 +1,20 @@
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
display: block;
min-width: 0;
}
.docs-table {
--gauzy-table-font-size: 0.6875rem;
--gauzy-table-line-height: 1rem;
--gauzy-table-cell-padding-y: 0.1875rem;
--gauzy-table-cell-padding-x: 0.4375rem;
--gauzy-table-header-font-size: 0.75rem;
--gauzy-table-header-line-height: 0.8125rem;
--gauzy-table-header-padding-y: 0.3125rem;
--gauzy-table-header-padding-x: 0.4375rem;
--gauzy-table-filter-padding-y: 0.1875rem;
--gauzy-table-control-height: 1.5rem;
--gauzy-table-badge-height: 1rem;
--gauzy-table-chip-font-size: 0.625rem;
--gauzy-table-chip-line-height: 0.75rem;
--gauzy-table-chip-padding-y: 0;
--gauzy-table-chip-padding-x: 0.3125rem;
--gauzy-table-chip-gap: 0.125rem;
// The app-wide table density, from the one place that defines it, instead of
// the hand-copied list that used to sit here. Every value in that copy already
// matched `density-tokens()` character for character except the chip gap below
// — so the copy bought nothing and was a second place for the scale to drift
// from the list pages this table is meant to read like.
@include ga-hub.density-tokens();
// The one deliberate departure: a docs row stacks its chips tighter than a
// list-page row does.
--gauzy-table-chip-block-gap: 0.1875rem;
--gauzy-people-avatar-size: 1rem;
--gauzy-people-font-size: 0.6875rem;
--docs-updated-cell-width: 9.5rem;
@@ -1,4 +1,5 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
nb-card,
@@ -21,3 +22,14 @@
}
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// Both smart tables on this screen — the repositories list and the issues list
// beside it — on the same footing as every other table in the app: density
// tokens, the table surface, row hover/selection, the tag-chip stack, the status
// pills and the pager.
//
// `$actions: null`: the buttons here are the integration's own full-size
// controls (Reset integration, Sync, the per-row sync buttons), not the compact
// list toolbar the mixin's button block is written for.
@include ga-hub.list-page(null);
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table.scss';
@use 'gauzy/_gauzy-cards.scss';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.hubstaff-container {
min-height: 200px;
@@ -24,3 +25,22 @@
}
}
}
// ── Table density, without the list-page surface ─────────────────────────────
// This table was drawn at the smart table's loose defaults — 0.8125rem cells on
// 0.375rem/0.625rem padding, the fallbacks in the `:root` block of
// `_overrides.scss` — so it read a size larger than every list page in the app.
// `density-tokens()` is what those pages set, and it is what closes the gap.
//
// Only the density and the row treatment, NOT the `list-page()` bundle: this
// template renders a bare `<angular2-smart-table>` with no
// `.table-scroll-container` to paint, no `.pagination-container` to compact and
// no compact list toolbar — the other three parts of that bundle would have
// nothing to match.
:host {
@include ga-hub.density-tokens();
}
:host ::ng-deep angular2-smart-table {
@include ga-hub.rows();
}
@@ -1,3 +1,4 @@
@use 'gauzy/_gauzy-table-hub' as ga-hub;
::ng-deep {
.contracts-table {
.angular2-smart-actions {
@@ -11,3 +12,22 @@
}
}
}
// ── Table density, without the list-page surface ─────────────────────────────
// This table was drawn at the smart table's loose defaults — 0.8125rem cells on
// 0.375rem/0.625rem padding, the fallbacks in the `:root` block of
// `_overrides.scss` — so it read a size larger than every list page in the app.
// `density-tokens()` is what those pages set, and it is what closes the gap.
//
// Only the density and the row treatment, NOT the `list-page()` bundle: this
// template renders a bare `<angular2-smart-table>` with no
// `.table-scroll-container` to paint, no `.pagination-container` to compact and
// no compact list toolbar — the other three parts of that bundle would have
// nothing to match.
:host {
@include ga-hub.density-tokens();
}
:host ::ng-deep angular2-smart-table {
@include ga-hub.rows();
}
@@ -0,0 +1,20 @@
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── Table density, without the list-page surface ─────────────────────────────
// This table was drawn at the smart table's loose defaults — 0.8125rem cells on
// 0.375rem/0.625rem padding, the fallbacks in the `:root` block of
// `_overrides.scss` — so it read a size larger than every list page in the app.
// `density-tokens()` is what those pages set, and it is what closes the gap.
//
// Only the density and the row treatment, NOT the `list-page()` bundle: this
// template renders a bare `<angular2-smart-table>` with no
// `.table-scroll-container` to paint, no `.pagination-container` to compact and
// no compact list toolbar — the other three parts of that bundle would have
// nothing to match.
:host {
@include ga-hub.density-tokens();
}
:host ::ng-deep angular2-smart-table {
@include ga-hub.rows();
}
@@ -167,46 +167,21 @@
@include ga-hub.rows();
// ── Employee cell ──────────────────────────────────────────────────────────
// `ngx-employee-links` pins an 18px avatar, an 8px gap, a 12px/15px name and a
// 5px/14px pill padding — a 28px chip in rows the density tokens build to a
// 1rem line box, and the single biggest reason a row here stood twice as tall
// as the same row on Employees or Payments.
// A block used to sit here shrinking `ngx-employee-links` — a 1rem avatar and
// a 0.6875rem name inside a 2px-inset pill — so that a person cost the row the
// same 1rem line box a glyph does.
//
// Sized off the shared `gauzy-people-*` tokens, so this column matches the
// people cells every other list page renders.
// It is gone, and deliberately not replaced with anything. Left alone the
// renderer draws an 18px `<img>` (a template attribute, not CSS) and a
// 12px/15px name inside a pill padded 5px: a 28px chip, which is to the pixel
// what `ngx-contact-links` costs the Invoices Contact column. This page's
// Employee cell is now exact by construction rather than by a second set of
// numbers kept in step with that one by hand.
//
// `ng-component` is the second selector because the inline editor
// (`EmployeeLinkEditorComponent`) declares no selector of its own — the smart
// table instantiates it dynamically, so its host element is a bare
// `<ng-component>` — while reusing this very sheet. Without it the row jumps
// back to full height the moment it goes into edit.
ngx-employee-links,
ng-component {
a {
@include nb-ltr(padding, 0.125rem 0.375rem 0.125rem 0.125rem);
@include nb-rtl(padding, 0.125rem 0.125rem 0.125rem 0.375rem);
}
img {
width: var(--gauzy-people-avatar-size, 1rem);
height: var(--gauzy-people-avatar-size, 1rem);
@include nb-ltr(margin-right, var(--gauzy-people-gap, 0.375rem));
@include nb-rtl(margin-left, var(--gauzy-people-gap, 0.375rem));
}
// SPECIFICITY: the component nests this under `:host a`, which shims to
// `[_nghost-el] a[_ngcontent-el] .names-wrapper[_ngcontent-el]` — (0,4,1).
// The doubled class is what clears it, the same idiom the Employees and
// Tasks sheets use on their own people cells.
.names-wrapper.names-wrapper {
// Truncates on WIDTH, never on a character count, so a short name renders
// in full and only a genuinely long one picks up an ellipsis.
max-width: var(--gauzy-people-name-max-width, 8rem);
font-size: var(--gauzy-people-font-size, 0.6875rem);
// Matched to the avatar, so the two share one line box.
line-height: var(--gauzy-people-avatar-size, 1rem);
}
}
// The inline editor (`EmployeeLinkEditorComponent`) reuses that same sheet and
// comes back with it; the old block had to name `ng-component` separately to
// keep a row from jumping height on entering edit, and there is now nothing
// for it to jump from.
// ── Job Search Status switch ───────────────────────────────────────────────
// Nebular's stock toggle is 50×30, but `themes.scss` already re-tokenises it
@@ -132,35 +132,13 @@
@include ga-hub.rows();
// ── Employee cell ──────────────────────────────────────────────────────────
// The same renderer, and the same problem, as the Jobs → Employees and
// Proposals tables: `ngx-employee-links` pins an 18px avatar, an 8px gap, a
// 12px/15px name and a 5px/14px pill padding — a 28px chip in rows the density
// tokens build to a 1rem line box.
ngx-employee-links {
a {
@include nb-ltr(padding, 0.125rem 0.375rem 0.125rem 0.125rem);
@include nb-rtl(padding, 0.125rem 0.125rem 0.125rem 0.375rem);
}
// A block used to sit here shrinking `ngx-employee-links` to a 1rem avatar and
// a 0.6875rem name in a 2px-inset pill. It is gone: left alone the renderer
// draws an 18px image and a 12px/15px name in a pill padded 5px — a 28px chip,
// the same one `ngx-contact-links` costs the Invoices Contact column — so this
// column matches that table by construction instead of by a second set of
// hand-matched numbers.
img {
width: var(--gauzy-people-avatar-size, 1rem);
height: var(--gauzy-people-avatar-size, 1rem);
@include nb-ltr(margin-right, var(--gauzy-people-gap, 0.375rem));
@include nb-rtl(margin-left, var(--gauzy-people-gap, 0.375rem));
}
// SPECIFICITY: the component nests this under `:host a`, which shims to
// `[_nghost-el] a[_ngc] .names-wrapper[_ngc]` — (0,4,1). The doubled class
// clears it.
.names-wrapper.names-wrapper {
// Truncates on WIDTH, never on a character count, so a short name renders
// in full and only a genuinely long one picks up an ellipsis.
max-width: var(--gauzy-people-name-max-width, 8rem);
font-size: var(--gauzy-people-font-size, 0.6875rem);
// Matched to the avatar, so the two share one line box.
line-height: var(--gauzy-people-avatar-size, 1rem);
}
}
}
:host ::ng-deep ngx-avatar {
@@ -79,10 +79,6 @@
::ng-deep .table-scroll-container {
flex-grow: 10;
max-height: unset;
angular2-smart-table table tr td {
vertical-align: top !important;
//border-bottom: 20px solid var(--gauzy-border-table);
}
}
}
@@ -177,3 +173,66 @@ nb-tabset {
}
}
}
// ── The rest of the list-page contract ───────────────────────────────────────
// This sheet already compacted the page-header buttons (twice, for the two
// groups that hold them), and stopped there — so Jobs > Browse was the one list
// page in the app with a converted TOOLBAR sitting over an unconverted TABLE:
// a 0.25rem action strip above rows still drawn at the `_overrides.scss`
// fallbacks, 0.8125rem cells on 0.375rem/0.625rem padding, on an unbounded
// scroll container with no ring, and a full-size pager under it.
//
// The six remaining blocks, spelled out rather than taken as `list-page()`,
// because that bundle also emits the toolbar block against a single `$actions`
// selector and this page deliberately names two.
// The tokens sit on `:host`, not on the table: the cell renderers that consume
// them are separate components, and custom properties are the only thing that
// crosses those boundaries.
:host {
@include ga-hub.density-tokens();
}
:host .table-scroll-container {
@include ga-hub.surface();
}
:host ::ng-deep angular2-smart-table {
@include ga-hub.rows();
}
// The one place this page departs from the contract, stated once instead of
// fought over. `rows()` centres body cells, which is right for a table of
// one-line values; this one has two columns at 15 % / 85 %, and the wide one
// renders `JobTitleDescriptionDetailsComponent` — a title, a description and a
// meta line, several times the height of the employee chip beside it. Centred,
// that chip floats halfway down an otherwise empty column.
//
// This used to live at the top of the file as `table tr td { vertical-align: top
// !important }`, which silently beat the mixin: the include read as if the
// density contract governed here and the declaration it emits was dead on
// arrival. Same selector as the mixin's now, emitted after it, so source order
// settles it and the `!important` is gone.
:host ::ng-deep angular2-smart-table tbody tr > td {
vertical-align: top;
}
:host ::ng-deep {
@include ga-hub.tag-stack-spacing();
}
:host ::ng-deep ga-status-badge {
@include ga-hub.status-badge-tints();
}
:host .pagination-container {
@include ga-hub.pager-container();
}
// SPECIFICITY: the pager's own rules nest under its `:host`, so `li span` shims
// to (0,3,2). Naming `.pagination-container` — which lives in THIS component's
// template, so it carries this component's content attribute — before
// `::ng-deep` buys two attributes and a class, taking the block to (0,3,3).
:host .pagination-container ::ng-deep ngx-pagination {
@include ga-hub.pager();
}
@@ -86,8 +86,23 @@ export class NgxRegisterComponent extends NbRegisterComponent implements OnInit
// Filter and ensure that query parameters are present.
filter((params: Params) => !!params),
// Tap into the observable to update the 'user.email' property with the 'email' query parameter.
tap(({ email }: Params) => (this.user.email = email)),
/**
* Carry what the checkout already collected into the form.
*
* Both values arrive from Stripe by way of ever.co/checkout/complete, which reads them off
* the completed Checkout Session. The email is the address the subscription was created
* against, and the template hides its input entirely when the parameter is present - which
* is why assigning it only when it is actually there matters: an absent parameter used to
* write `undefined` over whatever the model already held.
*
* The name is a prefill and stays editable. Stripe collects one full name, which is the
* shape this form wants, but it knows nothing of the length limits configured here, so the
* buyer has to be able to correct it.
*/
tap(({ email, name }: Params) => {
if (email) this.user.email = email;
if (name) this.user.fullName = name;
}),
// Use 'untilDestroyed' to handle component lifecycle and avoid memory leaks.
untilDestroyed(this)
+10
View File
@@ -3376,6 +3376,9 @@
"BROWSE": "تصفح",
"COMMENT": "تعليق",
"COMMENTS": "تعليقات",
"COMMENT_PLACEHOLDER": "اكتب تعليقًا…",
"NO_COMMENTS": "لا توجد تعليقات بعد",
"NO_COMMENTS_HINT": "تظهر التعليقات هنا، الأحدث أولاً.",
"HEADER": "الفواتير",
"INVOICE_NUMBER": "رقم الفاتورة",
"ESTIMATE_NUMBER": "رقم التقدير",
@@ -4418,6 +4421,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "اختر العميل",
"SELECT_PROJECT": "اختر المشروع",
"SELECT_TEAM": "اختر الفريق",
"SELECT_TASK": "اختر المهمة",
"DESCRIPTION": "أضف وصفًا"
},
"IS_BILLABLE": "هل يمكن تفريض رسوم؟",
"STOP_TIMER": "أوقف المؤقت",
"START_TIMER": "بدء المؤقت",
+10
View File
@@ -3449,6 +3449,9 @@
"BROWSE": "Прегледай",
"COMMENT": "Коментар",
"COMMENTS": "Коментари",
"COMMENT_PLACEHOLDER": "Напишете коментар…",
"NO_COMMENTS": "Все още няма коментари",
"NO_COMMENTS_HINT": "Коментарите се появяват тук, най-новите отгоре.",
"HEADER": "Фактури",
"INVOICE_NUMBER": "Номер на фактура",
"ESTIMATE_NUMBER": "Номер на оценка",
@@ -4482,6 +4485,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Изберете клиент",
"SELECT_PROJECT": "Изберете проект",
"SELECT_TEAM": "Изберете отбор",
"SELECT_TASK": "Изберете задача",
"DESCRIPTION": "Добавете описание"
},
"IS_BILLABLE": "Is Billable",
"STOP_TIMER": "Stop Timer",
"START_TIMER": "Страртирай таймер",
+10
View File
@@ -3387,6 +3387,9 @@
"BROWSE": "Suchen",
"COMMENT": "Kommentar",
"COMMENTS": "Kommentare",
"COMMENT_PLACEHOLDER": "Kommentar schreiben…",
"NO_COMMENTS": "Noch keine Kommentare",
"NO_COMMENTS_HINT": "Kommentare erscheinen hier, die neuesten zuerst.",
"HEADER": "Rechnungen",
"INVOICE_NUMBER": "Rechnungsnummer",
"ESTIMATE_NUMBER": "Schätzung Nummer",
@@ -4429,6 +4432,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Kunde auswählen",
"SELECT_PROJECT": "Projekt auswählen",
"SELECT_TEAM": "Team auswählen",
"SELECT_TASK": "Aufgabe auswählen",
"DESCRIPTION": "Beschreibung hinzufügen"
},
"IS_BILLABLE": "Ist abzurechnen.",
"STOP_TIMER": "Stoppuhr",
"START_TIMER": "Starten Sie den Timer",
+10
View File
@@ -3914,6 +3914,9 @@
"BROWSE": "Browse",
"COMMENT": "Comment",
"COMMENTS": "Comments",
"COMMENT_PLACEHOLDER": "Write a comment…",
"NO_COMMENTS": "No comments yet",
"NO_COMMENTS_HINT": "Comments appear here, newest first.",
"HEADER": "Invoices",
"INVOICE_NUMBER": "Invoice Number",
"ESTIMATE_NUMBER": "Estimate Number",
@@ -4998,6 +5001,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Select Client",
"SELECT_PROJECT": "Select Project",
"SELECT_TEAM": "Select Team",
"SELECT_TASK": "Select Task",
"DESCRIPTION": "Add a description"
},
"IS_BILLABLE": "Is Billable",
"STOP_TIMER": "Stop Timer",
"START_TIMER": "Start Timer",
+10
View File
@@ -3393,6 +3393,9 @@
"BROWSE": "Buscar",
"COMMENT": "Comentario",
"COMMENTS": "Comentarios",
"COMMENT_PLACEHOLDER": "Escribe un comentario…",
"NO_COMMENTS": "Aún no hay comentarios",
"NO_COMMENTS_HINT": "Los comentarios aparecen aquí, los más recientes primero.",
"HEADER": "Facturas",
"INVOICE_NUMBER": "Número de factura",
"ESTIMATE_NUMBER": "Número de estimación",
@@ -4435,6 +4438,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Seleccionar cliente",
"SELECT_PROJECT": "Seleccionar proyecto",
"SELECT_TEAM": "Seleccionar equipo",
"SELECT_TASK": "Seleccionar tarea",
"DESCRIPTION": "Añadir una descripción"
},
"IS_BILLABLE": "Es facturable.",
"STOP_TIMER": "Detener temporizador",
"START_TIMER": "Iniciar temporizador",
+10
View File
@@ -3385,6 +3385,9 @@
"BROWSE": "Parcourir",
"COMMENT": "Comme",
"COMMENTS": "Commentaires",
"COMMENT_PLACEHOLDER": "Écrire un commentaire…",
"NO_COMMENTS": "Aucun commentaire pour le moment",
"NO_COMMENTS_HINT": "Les commentaires apparaissent ici, les plus récents en premier.",
"HEADER": "Factures",
"INVOICE_NUMBER": "Numéro de facture",
"ESTIMATE_NUMBER": "Numéro d'estimation",
@@ -4424,6 +4427,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Sélectionner un client",
"SELECT_PROJECT": "Sélectionner un projet",
"SELECT_TEAM": "Sélectionner une équipe",
"SELECT_TASK": "Sélectionner une tâche",
"DESCRIPTION": "Ajouter une description"
},
"IS_BILLABLE": "Est facturable.",
"STOP_TIMER": "Arrêter le minuteur",
"START_TIMER": "Démarrer le chronomètre",
+10
View File
@@ -3414,6 +3414,9 @@
"BROWSE": "Browse",
"COMMENT": "Comment",
"COMMENTS": "Comments",
"COMMENT_PLACEHOLDER": "כתבו תגובה…",
"NO_COMMENTS": "אין עדיין תגובות",
"NO_COMMENTS_HINT": "תגובות יופיעו כאן, החדשות ביותר תחילה.",
"HEADER": "Invoices",
"INVOICE_NUMBER": "Invoice Number",
"ESTIMATE_NUMBER": "Estimate Number",
@@ -4446,6 +4449,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "בחר לקוח",
"SELECT_PROJECT": "בחר פרויקט",
"SELECT_TEAM": "בחר קבוצה",
"SELECT_TASK": "בחר משימה",
"DESCRIPTION": "הוסף תיאור"
},
"IS_BILLABLE": "ניתן לחיוב",
"STOP_TIMER": "עצור טיימר",
"START_TIMER": "התחל טיימר",
+10
View File
@@ -3391,6 +3391,9 @@
"BROWSE": "Sfoglia",
"COMMENT": "Commento",
"COMMENTS": "Commenti",
"COMMENT_PLACEHOLDER": "Scrivi un commento…",
"NO_COMMENTS": "Nessun commento finora",
"NO_COMMENTS_HINT": "I commenti compaiono qui, dal più recente.",
"HEADER": "Fatture",
"INVOICE_NUMBER": "Numero di fattura",
"ESTIMATE_NUMBER": "Numero di stima",
@@ -4433,6 +4436,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Seleziona cliente",
"SELECT_PROJECT": "Seleziona progetto",
"SELECT_TEAM": "Seleziona squadra",
"SELECT_TASK": "Seleziona attività",
"DESCRIPTION": "Aggiungi una descrizione"
},
"IS_BILLABLE": "È fatturabile",
"STOP_TIMER": "Fermare il timer",
"START_TIMER": "Avvia cronometro",
+10
View File
@@ -3391,6 +3391,9 @@
"BROWSE": "Bladeren",
"COMMENT": "Opmerking",
"COMMENTS": "Reacties",
"COMMENT_PLACEHOLDER": "Schrijf een opmerking…",
"NO_COMMENTS": "Nog geen opmerkingen",
"NO_COMMENTS_HINT": "Opmerkingen verschijnen hier, de nieuwste eerst.",
"HEADER": "Facturen",
"INVOICE_NUMBER": "Factuurnummer",
"ESTIMATE_NUMBER": "Schattingnummer",
@@ -4433,6 +4436,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Selecteer klant",
"SELECT_PROJECT": "Selecteer project",
"SELECT_TEAM": "Selecteer team",
"SELECT_TASK": "Selecteer taak",
"DESCRIPTION": "Voeg een beschrijving toe"
},
"IS_BILLABLE": "Is factureerbaar",
"STOP_TIMER": "Stop Timer",
"START_TIMER": "Start Timer",
+10
View File
@@ -3390,6 +3390,9 @@
"BROWSE": "Przeglądaj",
"COMMENT": "Komentarz",
"COMMENTS": "Komentarze",
"COMMENT_PLACEHOLDER": "Napisz komentarz…",
"NO_COMMENTS": "Brak komentarzy",
"NO_COMMENTS_HINT": "Komentarze pojawiają się tutaj, najnowsze są na górze.",
"HEADER": "Faktury",
"INVOICE_NUMBER": "Numer faktury",
"ESTIMATE_NUMBER": "Numer szacunku",
@@ -4432,6 +4435,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Wybierz klienta",
"SELECT_PROJECT": "Wybierz projekt",
"SELECT_TEAM": "Wybierz zespół",
"SELECT_TASK": "Wybierz zadanie",
"DESCRIPTION": "Dodaj opis"
},
"IS_BILLABLE": "Jest płatne",
"STOP_TIMER": "Zatrzymaj czasomierz",
"START_TIMER": "Rozpocznij odliczanie czasu",
+10
View File
@@ -3391,6 +3391,9 @@
"BROWSE": "Navegar",
"COMMENT": "Comentário",
"COMMENTS": "Comentários",
"COMMENT_PLACEHOLDER": "Escreva um comentário…",
"NO_COMMENTS": "Ainda sem comentários",
"NO_COMMENTS_HINT": "Os comentários aparecem aqui, os mais recentes primeiro.",
"HEADER": "Faturas",
"INVOICE_NUMBER": "Número da fatura",
"ESTIMATE_NUMBER": "Número de estimativa",
@@ -4433,6 +4436,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Selecionar cliente",
"SELECT_PROJECT": "Selecionar projeto",
"SELECT_TEAM": "Selecionar equipe",
"SELECT_TASK": "Selecionar tarefa",
"DESCRIPTION": "Adicionar uma descrição"
},
"IS_BILLABLE": "É cobrável.",
"STOP_TIMER": "Parar cronômetro",
"START_TIMER": "Iniciar cronômetro",
+10
View File
@@ -3420,6 +3420,9 @@
"BROWSE": "Обзор",
"COMMENT": "Комментарий",
"COMMENTS": "Комментарии",
"COMMENT_PLACEHOLDER": "Напишите комментарий…",
"NO_COMMENTS": "Комментариев пока нет",
"NO_COMMENTS_HINT": "Комментарии появляются здесь, начиная с новых.",
"HEADER": "Счета",
"INVOICE_NUMBER": "Номер счёта",
"ESTIMATE_NUMBER": "Расчетный номер",
@@ -4452,6 +4455,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "Выберите клиента",
"SELECT_PROJECT": "Выберите проект",
"SELECT_TEAM": "Выберите команду",
"SELECT_TASK": "Выберите задачу",
"DESCRIPTION": "Добавьте описание"
},
"IS_BILLABLE": "Оплачивается",
"STOP_TIMER": "Остановить таймер",
"START_TIMER": "Запустить таймер",
+10
View File
@@ -3391,6 +3391,9 @@
"BROWSE": "浏览",
"COMMENT": "评论",
"COMMENTS": "评论",
"COMMENT_PLACEHOLDER": "写下评论…",
"NO_COMMENTS": "暂无评论",
"NO_COMMENTS_HINT": "评论将显示在这里,最新的在前。",
"HEADER": "发票",
"INVOICE_NUMBER": "发票号码",
"ESTIMATE_NUMBER": "估计数字",
@@ -4433,6 +4436,13 @@
}
},
"TIMER_TRACKER": {
"PLACEHOLDERS": {
"SELECT_CLIENT": "选择客户",
"SELECT_PROJECT": "选择项目",
"SELECT_TEAM": "选择团队",
"SELECT_TASK": "选择任务",
"DESCRIPTION": "添加描述"
},
"IS_BILLABLE": "可收费",
"STOP_TIMER": "停止计时器",
"START_TIMER": "开始计时器",
@@ -1,4 +1,22 @@
@use 'themes' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// The card layout is not a table row, so the table density contract stops here.
//
// The list pages that render both layouts declare `density-tokens()` on their
// own `:host` (see `list-page()`), because the renderers that read them are
// separate components and custom properties are the only thing that reaches
// across. The card grid sits under that same `:host` and instantiates the SAME
// `renderComponent` the table column does, so a `ContactWithTagsComponent` in a
// card was being sized for a table row it is not in — chip text a step down,
// its vertical inset gone.
//
// This hands every one of those tokens back to the app-wide `$gauzy-density`
// value, so a renderer inside a card reads exactly what it reads on a page that
// never included `list-page()` at all.
:host {
@include ga-hub.reset-density-tokens();
}
:host .card-layout {
display: grid;
@@ -21,11 +21,23 @@ export class LayoutSelectorComponent implements OnInit {
public readonly componentLayoutStyle = signal<ComponentLayoutStyleEnum | undefined>(undefined);
ngOnInit() {
this.store.componentLayoutMap$
const componentName = this.componentName();
if (!componentName) {
return;
}
// `componentLayout$`, not the raw map: the map only holds a key once the
// user has explicitly toggled THIS page, so reading it directly left both
// buttons inactive on every page nobody had ever switched — and on any
// page whose `ComponentEnum` key changed, which strands the old entry.
// Meanwhile the page itself renders the effective layout (per-component
// override, then the user's preferred layout, then `SYSTEM_DEFAULT_LAYOUT`),
// so the two disagreed. This is the same stream the pages subscribe to,
// which is what keeps the highlight on whatever is actually on screen.
this.store
.componentLayout$(componentName)
.pipe(untilDestroyed(this))
.subscribe((componentLayoutMap: Map<string, ComponentLayoutStyleEnum>) => {
const dataLayout = componentLayoutMap.get(this.componentName());
this.componentLayoutStyle.set(dataLayout);
.subscribe((componentLayout: ComponentLayoutStyleEnum) => {
this.componentLayoutStyle.set(componentLayout);
});
}
@@ -16,7 +16,7 @@
[items]="contacts"
appendTo="body"
[(ngModel)]="contactId"
[placeholder]="'TIMER_TRACKER.SELECT_CLIENT' | translate"
[placeholder]="placeholder ?? ('TIMER_TRACKER.SELECT_CLIENT' | translate)"
bindValue="id"
bindLabel="name"
></ng-select>
@@ -30,6 +30,13 @@ export class ContactSelectorComponent implements AfterViewInit, OnInit, OnDestro
@Input() disabled = false;
@Input() multiple = false;
/**
* Prompt shown while nothing is selected. Left null so the template keeps
* falling back to the generic "Client", for the call sites that render the
* selector without a label of its own.
*/
@Input() placeholder: string | null = null;
private _employeeId: IEmployee['id'];
public get employeeId(): IEmployee['id'] {
return this._employeeId;
@@ -1,33 +1,61 @@
@use 'var' as *;
// ── One row of a ruled list, not a card ──────────────────────────────────────
// This component used to draw each expense as a CARD — its own `gauzy-card-1`
// fill, its own radius, an 8px bottom margin and a flat 84px height for four
// short values, about three table rows' worth. Both pages that render it
// (Employees > Recurring Expenses and Organization > Recurring Expenses, which
// share one stylesheet) put a band of column labels above the list and a bounded
// surface around the pair, so a stack of floating cards was a second set of
// surfaces drawn on top of the first.
//
// Every metric below now comes from the shared table-density tokens, which those
// pages set on their own `:host` and which INHERIT into this component — the
// same way `ngx-people-list` and `ngx-created-by-user` size themselves, and the
// reason nothing here has to be reached with `::ng-deep`. Literals are the
// CSS-var fallbacks, for any future caller that sets no tokens.
//
// The three page-level keys (`--gauzy-recurring-row-inset` and the two
// `--gauzy-recurring-col*`) are declared in
// `recurring-expense-employee.component.scss`. They exist because the column
// LABELS live in that template and the VALUES live in this one, and a literal
// would otherwise have to be written twice, in two files, and kept in step by
// hand — which is exactly how 42px, 11rem and 17.5rem came to be spelled out in
// both places.
:host .setting-block {
border-radius: nb-theme(border-radius);
margin-bottom: 8px;
// Fallbacks on every `--gauzy-*` here: material-light and material-dark do
// not register these keys, and a `var()` that resolves to nothing invalidates
// the whole declaration, which left these rows transparent on those themes.
background-color: var(--gauzy-card-1, var(--card-background-color)) !important;
// Selection rail. This was `rgba(0, 0, 0, 0.15)` — 15% black, drawn against an
// already near-black card on the four dark themes, so a selected recurring
// expense had no rail at all. A selection state has to read in every theme, so
// it takes the primary accent. The rail sits on the leading edge, so it flips
// with the writing direction.
&.block {
box-shadow: -6px 0 0 0 nb-theme(color-primary-default);
@include nb-rtl(box-shadow, 6px 0 0 0 nb-theme(color-primary-default));
}
border-radius: 0;
margin-bottom: 0;
// Transparent, not `gauzy-card-1`: that is the page surface's own fill, and
// painting it again per row is what made each expense read as a separate card.
//
// `!important` STAYS, and not for the reason the old `gauzy-card-1` carried it.
// `nb-overrides()` paints `.setting-block` `background-basic-color-2` with
// `!important` of its own (_overrides.scss), and that rule is global — no
// encapsulation attribute to out-rank, and `!important` beats any specificity
// this component can reach from inside. Drop it here and every row goes back to
// being a filled card, which is the whole thing this block undoes.
background-color: transparent !important;
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 84px;
background-color: var(--gauzy-card-1, var(--card-background-color));
border-radius: nb-theme(border-radius);
@include nb-ltr(padding, 0.75rem 0 0.75rem 0.75rem);
@include nb-rtl(padding, 0.75rem 0.75rem 0.75rem 0);
// Was a flat `height: 84px`. The row is as tall as its content plus the cell
// padding every other list row in the app uses, and no taller.
padding-block: var(--gauzy-table-cell-padding-y, 0.1875rem);
padding-inline: var(--gauzy-table-cell-padding-x, 0.4375rem);
background-color: transparent;
border-radius: 0;
font-size: var(--gauzy-table-font-size, 0.6875rem);
line-height: var(--gauzy-table-line-height, 1rem);
// The rule between rows, drawn INSIDE the row's box so nothing moves — the
// list needs one now that the cards and the gaps between them are gone.
box-shadow: inset 0 -1px 0 0 var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
// The selection rail, reserved at rest so selecting a row cannot shift it.
// It was a 6px shadow hung OUTSIDE the card's leading edge, which on a row
// sitting flush inside a surface would paint over that surface's own ring.
border-inline-start: 3px solid transparent;
// `:hover` and `.active` used to share one fill, so pointing at any row made
// it look exactly as selected as the row that really was. The two shared
@@ -39,6 +67,10 @@
&.active {
background-color: var(--gauzy-active-tint, rgba(126, 126, 143, 0.2));
// The rail that used to hang off `.setting-block.block`, moved onto the
// edge reserved above. Both classes are driven by the same `selected`
// input, so this marks exactly the rows the old rule did.
border-inline-start-color: nb-theme(color-primary-default);
}
}
@@ -89,8 +121,11 @@
justify-content: space-between;
align-items: center;
width: fit-content;
@include nb-ltr(padding-left, 42px);
@include nb-rtl(padding-right, 42px);
// Was 42px — room for a selection rail and for the settings fly-out further
// down, which are 3px and `display: none` respectively. Shared with the
// column labels above the list, which is why it is a page-level property.
@include nb-ltr(padding-left, var(--gauzy-recurring-row-inset, 0.5rem));
@include nb-rtl(padding-right, var(--gauzy-recurring-row-inset, 0.5rem));
.expense {
display: flex;
@@ -98,15 +133,18 @@
align-items: center;
width: 100%;
// Both widths are page-level properties for the same reason as the inset:
// the labels heading these columns are in the page's template, and cannot
// be allowed to drift from the values under them.
.block-item {
width: 11rem;
width: var(--gauzy-recurring-col, 8rem);
display: flex;
justify-content: flex-start;
align-items: center;
}
.block-item-big {
width: 17.5rem;
width: var(--gauzy-recurring-col-big, 12rem);
display: flex;
justify-content: flex-start;
align-items: center;
@@ -117,20 +155,31 @@
// Fallback: `gauzy-text-color-1` is not registered by material-light /
// material-dark, and without one the declaration is dropped there.
color: var(--gauzy-text-color-1, var(--text-basic-color));
font-size: 14px;
font-size: var(--gauzy-table-font-size, 0.6875rem);
}
.block-amount {
width: 130px;
// The 130px box and the 24px trailing pad both predate the column widths
// above; the amount sits in a `.block-item` already, so between them all
// they did was hold it 24px off that column's trailing edge.
width: auto;
display: flex;
align-items: center;
@include nb-ltr(padding-right, 24px);
@include nb-rtl(padding-left, 24px);
font-size: 14px;
gap: 0.25rem;
font-size: var(--gauzy-table-font-size, 0.6875rem);
font-weight: 400;
line-height: 17px;
line-height: var(--gauzy-table-line-height, 1rem);
letter-spacing: 0em;
cursor: pointer;
// The split-expense marker. `nb-icon` defaults to 1.5rem, half a rem
// taller than the line box this row is built around, so the one glyph
// would have set the height of every row that carries it.
nb-icon {
width: var(--gauzy-table-header-font-size, 0.75rem);
height: var(--gauzy-table-header-font-size, 0.75rem);
font-size: var(--gauzy-table-header-font-size, 0.75rem);
}
}
}
@@ -175,8 +224,31 @@
}
}
@media screen and (max-width: 1438px) {
.setting-block {
width: fit-content;
}
// ── The row inside its scroll port ──────────────────────────────────────────
// The values sit on fixed column widths, so the list can still outrun a narrow
// viewport and scroll sideways. `.table-scroll` on the page is that port — it
// carries `overflow: auto`, which is both axes.
//
// This was `@media (max-width: 1438px) { .setting-block { width: fit-content } }`.
// That was fine while each row was a free-standing card: a card narrower than the
// port simply sat there. It is not fine now that the row carries a hairline, a
// hover fill and a selection rail, because all three stop where the row's box
// does — a row left at the port's width would have its rule and its hover tint
// end at the fold, with the columns you scrolled to reach sitting on nothing.
//
// `max-content` so the row is as wide as its own columns, `min-width: 100%` so a
// short row still spans the port. Unconditional, and the breakpoint goes with it:
// 1438px was a guess at the width these columns need, and this pair measures it
// instead — which also means it keeps holding now that the columns are sized off
// the density tokens rather than at the 57rem the old literals added up to.
//
// On the HOST rather than on `.setting-block`, so there is one width in the
// chain rather than two: the host is a flex item of the page's `.table-scroll`
// column, and an explicit `width` on a flex item is what opts it out of the
// `stretch` that would otherwise pin it to the port. `.setting-block` is then an
// ordinary block child and fills it.
:host {
display: block;
width: max-content;
min-width: 100%;
}
@@ -1,6 +1,7 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-cards' as *;
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
.header-title-with-back {
@@ -17,3 +18,12 @@
@include nb-rtl(margin, 0 20px 0 0);
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// Density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager's `<nav><ul><li>` and the toolbar scale — the same
// block Invoices carries. Markup is the standard list shape
// (`nb-card-header` > `ngx-gauzy-button-action`, a `.table-scroll-container` and
// a `.pagination-container`), so the default `$actions` selector reaches the
// toolbar and the layout selector beside it.
@include ga-hub.list-page();
@@ -1,4 +1,5 @@
@use 'themes' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.table {
margin-top: 20px;
@@ -30,3 +31,23 @@
display: block;
padding: 25px clamp(1rem, 6vw, 100px) 0;
}
// ── Table density, without the list-page surface ─────────────────────────────
// The item grid on an invoice was drawn at the smart table's loose defaults
// (0.8125rem cells on 0.375rem/0.625rem padding — the fallbacks in the `:root`
// block of `_overrides.scss`), so it read a size larger than the Invoices list
// it is opened from and a size larger than the same grid in the Add/Edit form.
//
// Only `density-tokens()` and `rows()`, NOT the `list-page()` bundle every list
// page takes. This component is a DOCUMENT — it renders the invoice body on the
// invoice view page and again behind a public share link — so the two pieces of
// that bundle which say "this is a list" are deliberately left off: the
// `surface()` ring and `gauzy-card-1` fill (the `.table` block above keeps its
// own `gauzy-card-2` panel) and the toolbar scale (there is no toolbar here).
:host {
@include ga-hub.density-tokens();
}
:host ::ng-deep angular2-smart-table {
@include ga-hub.rows();
}
@@ -1,5 +1,26 @@
::ng-deep {
ng-dropdown-panel.currency-selector {
width: auto !important;
}
// The panel is sized by ng-select itself: appended to `<body>`, it writes
// `width: <trigger>px; min-width: <trigger>px` inline, so it lines up with the
// field it belongs to.
//
// This file used to force `width: auto !important` on it, which — together with
// that inline `min-width` — made the panel "at least as wide as the trigger, and
// as wide as the longest currency name", i.e. a popup wider than its own input
// on every form in the app. It was also redundant by the time it broke:
// `_overrides.scss` declares the same `width: auto !important` for every
// appended panel at a far higher specificity, so this rule had long stopped
// being what decided anything.
//
// `currency-selector` is now in that rule's opt-out list (see the note there),
// which is what keeps the inline width — and the alignment — intact. The class
// has to stay on the `<ng-select>` in the template for that to work: ng-select
// copies a select's classes onto the appended panel, and that is the only way a
// body-level selector can tell one panel from another.
//
// Both ends of the control then ellipsize instead of widening. The rows get that
// from ng-select's own sheet; the trigger does not, so it is set here — without
// it a long name is simply cut off mid-word by the container's `overflow: hidden`.
::ng-deep .currency-selector.ng-select-single .ng-select-container .ng-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1,4 +1,5 @@
@forward 'gauzy/_gauzy-table';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host ngx-gauzy-button-action ::ng-deep {
.transition-container span {
@@ -8,3 +9,14 @@
padding-top: 0;
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// A project's modules are the same table as the Projects list this component is
// reached from, so they take the same block: density tokens, the table surface,
// row hover/selection, the tag-chip stack, the status pills and the toolbar
// scale.
//
// `$actions` is the toolbar itself — this component has no `nb-card-header`; its
// root is a plain `.main-wrapper` div. There is no pager on this table, so the
// pager half of the block simply matches nothing.
@include ga-hub.list-page('.gauzy-button-container');
@@ -1,22 +1,64 @@
// The pill is normally the tallest thing in its smart-table cell, so its height
// is what the row pays for it. Sized from the shared table-density tokens
// (`$gauzy-density` in `themes.scss`) so a status column costs a row exactly one
// line box, the same as a plain text column. Literals are CSS-var fallbacks.
// (`$gauzy-density` in `themes.scss`, `density-tokens()` in
// `gauzy/_gauzy-table-hub.scss`) so a status column costs a row about one line
// box, the same as a plain text column. Literals are CSS-var fallbacks, for the
// layouts that render this component outside a table.
div {
// The shared control/row radius (0.375rem), so a status pill, a tag chip and a
// toolbar button are drawn on the same corner. 0.25rem is the scale's step
// BELOW this one and is reserved for the tiny boxes (checkbox).
border-radius: var(--gauzy-radius-sm, 0.375rem);
align-content: center;
// Was `--gauzy-radius-sm` (0.375rem) — the control radius, which is right for
// a 2rem button and much too round for a pill barely half that tall: the
// corners met in the middle of each short edge and "Sent" sat in a lozenge.
// `--gauzy-table-badge-radius` is the small-box step below it.
border-radius: var(--gauzy-table-badge-radius, 0.25rem);
display: flex;
// Was `align-content`, which only does anything on a MULTI-LINE flex
// container (`flex-wrap: wrap`); this one is single-line, so the declaration
// was inert and the text was centred only by the fixed height happening to
// match its line box. `align-items` is the single-line property, and it is
// what actually centres the text now that padding, not height, sets the box.
align-items: center;
justify-content: center;
width: 100%;
height: var(--gauzy-table-badge-height, 1.25rem);
// `height` → `min-height` + real padding. The old fixed height was the reason
// there was no vertical padding to give: with the app-wide `border-box` reset,
// padding inside a pinned height comes OUT of the text's own box rather than
// adding to the pill, so any padding added alongside it would have cropped the
// text instead of framing it. As a floor it still guarantees a uniform pill
// down the column when a status string is unusually short.
min-height: var(--gauzy-table-badge-height, 1.25rem);
// The inset is what the box has LEFT after the text, not a figure of its own —
// and that is the whole point. `--gauzy-table-badge-height` is the contract
// ("a status pill costs a row exactly one line box and no more"), but
// `min-height` is a floor and cannot hold a ceiling: a flat
// `--gauzy-table-badge-padding-y` on top of the line below added up to more
// than the token on every page that sets `density-tokens()` — 0.8125rem of line
// plus 2 × 0.1875rem came to 19px inside a 16px contract, so a status column
// stood ~3px taller than the plain-text columns next to it and set the height
// of the row.
//
// Derived, so the pill lands ON the contract in both worlds instead of near it:
// 16px on a density page, 20px on the app-wide defaults, text centred in either
// by the `align-items` above. Should a caller ever set a line TALLER than the
// box, the difference goes negative and CSS clamps a negative `calc()` on
// `padding` to zero, so the pill simply grows off `min-height` instead.
//
// `--gauzy-table-badge-padding-y` is deliberately NOT read here. It stays for
// the badge-shaped boxes that pin no height of their own (Tasks, Help Center,
// the pipeline stage chips), which is where a free-standing inset still means
// something; this component is the one the height contract is written for.
padding-block: calc(
(var(--gauzy-table-badge-height, 1.25rem) - var(--gauzy-table-header-line-height, 0.9375rem)) / 2
);
// The horizontal inset is the chips' own, so a status pill and a tag chip in
// adjacent columns hold their text at the same distance from the edge. It only
// shows when a cell is narrow enough for the pill to hug its text — the
// `width: 100%` above otherwise stretches it to the column.
padding-inline: var(--gauzy-table-chip-padding-x, 0.3125rem);
font-size: var(--gauzy-table-header-font-size, 0.75rem);
font-weight: 600;
line-height: var(--gauzy-table-header-line-height, 0.9375rem);
letter-spacing: 0em;
text-align: left;
text-align: center;
}
.badge {
@@ -30,6 +30,24 @@
// The avatar is the tallest thing in the group, so the whole cell costs the
// row exactly one line box.
line-height: nb-theme(gauzy-people-avatar-size);
// The same vertical inset the pill renderers carry as a literal 5px
// (`ngx-employee-links`, and `ngx-contact-links` on Invoices). This group
// deliberately draws no pill — no fill, no radius, nothing around a person —
// so it had no inset at all, and its cells came out 8px shorter than the chip
// cells next to them. That is what left Departments and Teams short of the
// Invoices row height while every other column on the page matched.
//
// Padding only: the "no box" treatment this file is built around is
// untouched, and the cell simply costs the row what a chip costs it.
//
// TABLE GROUPS ONLY. The inset exists to make a cell cost a table row exactly
// what a chip cell beside it costs — there is no such row to match in a member
// CARD, where `[wrap]="true"` puts this group on several lines inside content
// that is already padded, and the 0.5rem would only be blank space added to
// the card.
&:not(.is-wrapped) {
padding-block: var(--gauzy-people-chip-padding-y, 0.25rem);
}
// Cards have room for several lines; grid cells do not.
&.is-wrapped {
@@ -82,9 +100,33 @@
height: nb-theme(gauzy-people-avatar-size);
border-radius: 50%;
overflow: hidden;
// Only the fallback bubble is tinted; a real photo covers it completely.
background-color: nb-theme(background-basic-color-3);
color: nb-theme(text-hint-color);
// ── The initials fallback, which has to read as an avatar ────────────────
// Only this bubble is tinted; a real photo covers it completely.
//
// It was `background-basic-color-3` on `text-hint-color`, and that pair is
// invisible at BOTH ends of the theme range: the token is `rgba(9, 9, 11, 1)`
// on the dark themes — a near-black disc on a dark-grey card — and `#f9f9f9`
// on the light ones, i.e. near-white on a white card. Either way an initials
// person rendered as bare floating letters while the person beside them, who
// happened to have a photo, rendered as a solid disc. In a Members or
// Managers column that alternates between the two down every row, that is the
// whole reason those cells read as ragged rather than as a list of people.
//
// The primary tint instead — the same soft fill the single-person avatar chip
// uses on Invoices, which is the look these columns are being matched to — so
// a photo, an initials bubble and the `+N` chip below all carry comparable
// weight. It is a registered token in every theme and adapts with each.
background-color: nb-theme(color-primary-transparent-100);
// The LETTERS are basic text, not `text-primary-color`. The fill is the brand
// colour at 8-10% alpha, so a brand-coloured glyph on it is that colour on
// very nearly the card behind it: in material-dark that is #e91d63 on #1f1f1f,
// about 3.8:1, and these initials are the smallest type in the component
// (`gauzy-people-initials-font-size`, 9px) — the one place in the cell that
// can least afford it. `text-basic-color` is the highest-contrast text token
// every theme registers and is what the names beside these bubbles already
// use, so it clears 4.5:1 at both ends of the range. The tint still does the
// identifying; it never had to do the reading as well.
color: nb-theme(text-basic-color);
text-decoration: none;
img {
@@ -123,8 +165,15 @@
padding: 0 0.25rem;
// A pill: any radius at or above half the height reads as fully round.
border-radius: nb-theme(gauzy-people-avatar-size);
// One step up from the initials bubble above rather than, as before, the only
// filled thing in the cell — which made "+1", the least informative item in
// the group, the loudest. Still distinguishable, because it is a count and
// not a person.
background-color: nb-theme(color-primary-transparent-200);
color: nb-theme(text-primary-color);
// Basic text for the same reason as the initials above, and at the same 9px:
// one step more fill does not buy a brand-coloured glyph the contrast it is
// short of. What separates this chip from a person is still the stronger tint.
color: nb-theme(text-basic-color);
font-size: nb-theme(gauzy-people-initials-font-size);
font-weight: 600;
cursor: default;
@@ -18,9 +18,9 @@
[placeholder]="'MENU.TAGS' | translate"
>
<ng-template ng-option-tmp let-tag="item" let-tag$="item$">
<div class="d-flex align-items-center">
<div class="tag-option">
@if (multiple) {
<input type="checkbox" [ngModel]="tag$.selected" />
<input type="checkbox" class="tag-option-check" [ngModel]="tag$.selected" />
}
<nb-badge
[style.background]="background(tag.color)"
@@ -30,7 +30,13 @@
</div>
</ng-template>
<ng-template ng-multi-label-tmp let-tags="selectedTags" let-clear="clear">
@if (!!noOfTagsFits) {
<!--
`selectedTagsOverflow`, not `noOfTagsFits`. The count is 0 both before the
first measurement and when nothing fits, and those two want opposite
branches: unmeasured means "render them all", nothing-fits means "render
one and say how many are hidden". Only the overflow flag tells them apart.
-->
@if (selectedTagsOverflow) {
@for (
item of selectedTags
| slice : 0 : noOfTagsFits || selectedTags.length
@@ -43,7 +49,13 @@
}
@if (selectedTags && selectedTags.length > noOfTagsFits) {
<div class="ng-value">
<span class="ng-value-label">...</span>
<!--
The overflow indicator. It was a literal "…", which says only that
something is hidden; the count says how much, which is the one thing
the row cannot show. `noOfTagsFits` is the number rendered above it,
so the difference is exactly what is not on screen.
-->
<span class="ng-value-label tag-overflow">+{{ selectedTags.length - noOfTagsFits }}</span>
</div>
}
} @else {

Some files were not shown because too many files have changed in this diff Show More