mirror of
https://github.com/ever-co/ever-gauzy.git
synced 2026-10-02 01:54:50 +08:00
Merge remote-tracking branch 'origin/develop' into fix/ghsa-44pv-org-scope-residuals
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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&utm_medium=badge&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>
|
||||
|
||||

|
||||
|
||||
## 💻 Demo, Downloads, Testing and Production
|
||||
|
||||
### Demo
|
||||
@@ -296,6 +292,10 @@ Notes:
|
||||
|
||||
### 🚗 Other self-hosting options
|
||||
|
||||
#### Deploy on Hostinger
|
||||
|
||||
[](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 [](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)
|
||||
|
||||
[](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
|
||||
|
||||

|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](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();
|
||||
}
|
||||
|
||||
+18
@@ -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();
|
||||
|
||||
+19
@@ -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();
|
||||
|
||||
+21
@@ -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');
|
||||
|
||||
+14
@@ -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();
|
||||
|
||||
+10
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
+46
@@ -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
|
||||
|
||||
+149
-2
@@ -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 ────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
+5
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
// }
|
||||
// ]
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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": "/"
|
||||
}
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
module.exports = {
|
||||
displayName: 'mcp-server',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
preset: '../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+20
@@ -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();
|
||||
}
|
||||
|
||||
+20
@@ -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();
|
||||
}
|
||||
|
||||
+20
@@ -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();
|
||||
}
|
||||
|
||||
+13
-38
@@ -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
|
||||
|
||||
+6
-28
@@ -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 {
|
||||
|
||||
+63
-4
@@ -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)
|
||||
|
||||
@@ -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": "بدء المؤقت",
|
||||
|
||||
@@ -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": "Страртирай таймер",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "התחל טיימר",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Запустить таймер",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+16
-4
@@ -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;
|
||||
|
||||
+108
-36
@@ -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();
|
||||
|
||||
+21
@@ -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;
|
||||
}
|
||||
|
||||
+12
@@ -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 {
|
||||
|
||||
+53
-4
@@ -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;
|
||||
|
||||
+16
-4
@@ -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
Reference in New Issue
Block a user