Merge pull request #10182 from ever-co/fix/polish-all-table-pages

Fix/polish all table pages
This commit is contained in:
joel kalema
2026-09-15 14:59:40 +02:00
committed by GitHub
parent c96183ab8a
commit 8c5c24396e
75 changed files with 3223 additions and 643 deletions
@@ -2,12 +2,14 @@
// `@forward` re-exports to importers but does NOT bring members into this file,
// so `nb-theme()` needs its own `@use`.
@use 'themes' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
nb-card-body {
height: calc(100vh - 17.5rem) !important;
}
}
// REMOVED: `:host nb-card-body { height: calc(100vh - 17.5rem) !important }`.
// A hand-tuned viewport constant that out-ranked the flex column `_pg-card`
// gives every other list page, so this table stood at a different height from
// its siblings and had back the problem the shared partial exists to solve — a
// gap under a short table, a clipped long one. The same override was taken off
// Expenses for the same reason.
// Approve / Refuse are plain `[nbButton] status="success|danger"` buttons and
// now take their colour from the theme like every other action in the app (see
@@ -27,3 +29,28 @@ ngx-request-approval-action {
gap: 10px;
padding: 0 5px;
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale.
//
// The default `$actions` selector (`nb-card-header`) is the right one here
// precisely because this page puts a SECOND button beside the title — "Approval
// policy" — outside `.gauzy-button-container`. Naming the container alone would
// have left that one at full size next to a compacted toolbar.
@include ga-hub.list-page();
// ── The Name cell ───────────────────────────────────────────────────────
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, which pins
// a 20px image inside a pill padded 3px: 26px of chip, 2px short of the 28px
// `ngx-contact-links` costs the Invoices Contact column, and so 2px short of the
// Invoices row. Only that inset is restated — the image and the name stay the
// renderer's own 20px/12px — which takes the chip to the same 28px and the row
// to the same height.
//
// Table-scoped: `ga-card-grid` instantiates the same `renderComponent` for the
// card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
@@ -74,9 +74,11 @@
></angular2-smart-table>
</div>
</div>
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
<div class="pagination-container">
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
</div>
} @else {
<ga-card-grid
[loading]="loading"
@@ -1,6 +1,7 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-cards' as *;
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.manage-btn {
display: flex;
@@ -41,3 +42,29 @@
}
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale.
//
// The pager needed the `.pagination-container` wrapper every sibling list page
// already had — this template rendered a bare `<ngx-pagination>` straight into
// the card body, so it took neither the shared right-alignment nor the compact
// `<nav><ul><li>` sizing, and sat left-aligned at full size under a table that
// had just been brought down to the shared density.
@include ga-hub.list-page();
// ── The Full Name cell ───────────────────────────────────────────────────────
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, which pins
// a 20px image inside a pill padded 3px: 26px of chip, 2px short of the 28px
// `ngx-contact-links` costs the Invoices Contact column, and so 2px short of the
// Invoices row. Only that inset is restated — the image and the name stay the
// renderer's own 20px/12px — which takes the chip to the same 28px and the row
// to the same height.
//
// Table-scoped: `ga-card-grid` instantiates the same `renderComponent` for the
// card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-dialogs' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.education-form {
width: 100%;
@@ -56,3 +57,20 @@ form {
@include input-appearance(2rem, var(--gauzy-sidebar-background-4));
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// A tab of the candidate profile, brought onto the same footing as the
// Candidates list it is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager.
//
// The default `$actions` (`nb-card-header`), where this used to pass `null` on
// the grounds that the tab had no compact toolbar. It has one: the header holds
// `ngx-gauzy-button-action` unconditionally — Add, the layout selector, and the
// Edit / Delete group in table mode — every button of it `size="small"`, which is
// the exact row the mixin's button block is written for. `null` left that row at
// full size over rows that had just been compacted.
//
// The add FORM is in this header too, but its Save and Cancel take no `size`, so
// they are medium; `page-header-buttons()` only redefines the small-button and
// icon-button-small tokens, and reaches neither.
@include ga-hub.list-page();
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-dialogs' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.experience-form {
width: 100%;
@@ -54,3 +55,21 @@ form {
@include input-appearance(2rem, var(--gauzy-sidebar-background-4));
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// A tab of the candidate profile, brought onto the same footing as the
// Candidates list it is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager.
//
// The default `$actions` (`nb-card-header`), where this used to pass `null` on
// the grounds that the tab had no compact toolbar. It has one: the header holds
// `ngx-gauzy-button-action` unconditionally — Add, the layout selector, and the
// Edit / Delete group in table mode — every button of it `size="small"`, which is
// the exact row the mixin's button block is written for. `null` left that row at
// full size over rows that had just been compacted.
//
// The add form's Save and Cancel sit in this header too and ARE `size="small"`,
// so they come down with it. That is the point rather than a side effect: they
// are the same size class as the toolbar beside them, and leaving them out would
// put two different small-button scales on one header line.
@include ga-hub.list-page();
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-dialogs' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.form {
padding: 0;
@@ -288,3 +289,23 @@
max-height: calc(100vh - 28rem);
@include input-appearance(2rem, var(--gauzy-sidebar-background-4));
}
// ── The canonical list-page block ────────────────────────────────────────────
// A tab of the candidate profile, brought onto the same footing as the
// Candidates list it is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager.
//
// `$actions: '.main-btn'`, not `null`. This tab puts TWO different things in its
// header, on an `@if (!showAddCard)` / `@else`: the list state renders
// `.main-btn`, which is exactly the compact toolbar the mixin's button block is
// written for (`ngx-gauzy-button-action` — Add, plus View / Edit / Delete), and
// the form state renders `.add-card`. `null` left that toolbar at full size above
// a table that had just been compacted.
//
// The default `nb-card-header` would have worked too — `page-header-buttons()`
// only defines the `--button-*-small-*` and `--icon-button-*-small-*` tokens, and
// the form's Save and Cancel take no `size`, so they are medium and no container
// named here reaches them. `.main-btn` is the narrower statement of the same
// thing: it names the row the tokens are FOR, rather than the header that happens
// to contain it.
@include ga-hub.list-page('.main-btn');
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-overrides' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.checkboxes {
display: flex;
@@ -29,3 +30,16 @@
}
@include input-appearance(2rem, var(--gauzy-card-1));
}
// ── The canonical list-page block ────────────────────────────────────────────
// A tab of the candidate profile, brought onto the same footing as the
// Candidates list it is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager.
//
// The default `$actions` (`nb-card-header`), where this used to pass `null` on
// the grounds that the tab had no compact toolbar. It has one, and nothing else:
// the header is a single `ngx-gauzy-button-action` — Add, the layout selector,
// and the Edit / Delete group in table mode — every button of it `size="small"`,
// which is the exact row the mixin's button block is written for. `null` left
// that row at full size over rows that had just been compacted.
@include ga-hub.list-page();
@@ -1,6 +1,7 @@
@use 'gauzy/_gauzy-cards' as *;
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-overrides' as ga-overrides;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host .card {
display: flex;
@@ -91,3 +92,12 @@
@include nb-ltr(padding-right, 0.5rem !important);
@include nb-rtl(padding-left, 0.5rem !important);
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block the Candidates list this page sits beside now carries: density
// tokens, the table surface, row hover/selection, the tag-chip stack, the status
// pills, the pager and the toolbar scale.
//
// `$actions` is `.card-header` rather than `nb-card-header`: this component's
// root IS an `nb-card-body`, and its action row is a plain div inside it.
@include ga-hub.list-page('.card-header');
@@ -1,4 +1,5 @@
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
/*
* REMOVED: the `.contact-list` block (`.member-card`, `.contact-info`,
@@ -52,3 +53,12 @@
flex: 0 0 auto;
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// Customers, Clients, Leads and Visitors all render this component, and all four
// take the same table treatment as Invoices: density tokens, the table surface,
// row hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale. The buttons sit in `nb-card-header`, so the default `$actions`
// selector reaches them.
@include ga-hub.list-page();
@@ -85,6 +85,22 @@ nb-card {
@include ga-hub.rows();
}
// ── The page-header action row ───────────────────────────────────────────────
// The seventh block of the list-page contract, and the one this sheet never
// had: the six above brought the TABLE down to the shared density while "+ Add",
// the selection strip, "Manage invites" and the grid-vs-list selector all stayed
// at their full 0.375rem/0.875rem box around a 1rem icon. A toolbar a size and a
// half larger than the rows beneath it is the most visible half of the
// inconsistency, so this page read as unconverted even though most of it was.
//
// `nb-card-header` rather than `.gauzy-button-container`, because this header
// holds a second group: "Manage invites" is a `<button nbButton class="action"
// size="small">` in `.card-header-title`, outside the toolbar container. Naming
// only the container would have left it behind — see the note in the mixin.
:host nb-card-header {
@include ga-hub.page-header-buttons();
}
// ── Status pills ─────────────────────────────────────────────────────────────
// Every other list page routes its statuses through `ga-status-badge`, which is
// what the canonical block points `status-badge-tints()` at. This page does not:
@@ -107,53 +123,21 @@ nb-card {
}
// ── Full Name cell ───────────────────────────────────────────────────────────
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, and that
// context in `avatar.component.scss` pins a 20px image, a 12px name and an 8px
// gap inside a padded tinted pill — 26px of chip in rows the density tokens
// build to a 1rem line box, which made Full Name both the widest-reading and
// the tallest cell in the table and left every row taller than the equivalent
// row on Payments.
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, and this
// sheet used to shrink it: a 1rem image, a 0.6875rem name and a 2px-inset pill,
// about 20px of chip, so that a person cost the row the same 1rem line box a
// glyph does.
//
// Sized off the `gauzy-people-*` tokens, so the chip matches the scale the rest
// of the row already follows. Table-scoped, not page-wide: `ga-card-grid`
// instantiates the same `renderComponent` and its cards have room for the full
// treatment.
// That block is gone — it is what made this table read a size smaller than the
// Invoices table it is meant to match. The image and the name are the renderer's
// own 20px/12px again; only the pill's vertical inset is restated, which takes a
// 20px image to the same 28px that the 18px-plus-5px `ngx-contact-links` chip
// costs the Invoices Contact column. See `avatar-chip()` for the arithmetic.
//
// SPECIFICITY: `:host-context(.report-table) X` shims to
// `.report-table[_nghost] X[_ngcontent]` — (0,4,0) for `.inner-wrapper`,
// `.link-text` and `.image-container` — while `:host ::ng-deep` buys only the
// one `[_nghost]` attribute. Naming `.report-table` (it sits on the host
// element) plus one doubled class clears those. The nested `img` is the
// exception: it shims to `.report-table[_nghost] .image-container[_ngc]
// img[_ngc]`, (0,5,1), which no reachable combination of doubled classes here
// beats without reading as line noise, so those two declarations are
// `!important` — the same register that rule already uses for its own
// `border-radius`.
:host ::ng-deep angular2-smart-table ngx-avatar.report-table {
.inner-wrapper.inner-wrapper {
gap: var(--gauzy-people-gap, 0.375rem);
// Keeps the original asymmetry — a hair of inset around the image, more
// breathing room after the name — just scaled to the smaller chip. Not zero
// on the image side: `.inner-wrapper` is a stadium with `overflow: hidden`,
// so a flush image would be clipped by the left-hand curve.
@include nb-ltr(padding, 0.125rem 0.375rem 0.125rem 0.125rem);
@include nb-rtl(padding, 0.125rem 0.125rem 0.125rem 0.375rem);
}
.link-text.link-text {
font-size: var(--gauzy-people-font-size, 0.6875rem);
line-height: var(--gauzy-people-avatar-size, 1rem);
}
.image-container.image-container {
width: var(--gauzy-people-avatar-size, 1rem);
height: var(--gauzy-people-avatar-size, 1rem);
img {
width: var(--gauzy-people-avatar-size, 1rem) !important;
height: var(--gauzy-people-avatar-size, 1rem) !important;
}
}
// Table-scoped, as the old block was: `ga-card-grid` instantiates the same
// `renderComponent` for the card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
// ── Screen Capture cell ──────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
@use 'gauzy/_gauzy-cards' as *;
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-overrides' as ga-overrides;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
nb-card-body {
background: nb-theme(gauzy-card-2);
@@ -31,6 +32,20 @@ nb-card-body {
.table-scroll-container {
flex-grow: 10;
max-height: unset;
// Against `card-list-page()`'s `min-height: 100%`. That default is for the
// pages whose container sits in a card body that is NOT a flex column,
// where nothing else would make a short list fill the card. This body IS
// one, and it holds the toolbar above the table: 100% of the body plus the
// toolbar's own height is more than the body has, so the surface ran past
// the bottom of the card. `flex-grow: 10` above already fills whatever the
// toolbar leaves, and `min-height: 0` is what lets a flex item shrink back
// to that — the default `auto` floors it at its content.
//
// Here rather than after the include: this selector is (0,5,3) against the
// mixin's (0,3,1), so it holds no matter where the include lands — which
// matters because the four sheets that `@forward` this file emit their own
// `card-list-page()` call after everything in it.
min-height: 0;
}
.grid {
flex-grow: 10;
@@ -118,3 +133,34 @@ nb-card-body {
nb-card {
margin-bottom: 0.5rem;
}
// ── The list-page contract, for a table built by hand ────────────────────────
//
// Expense categories is the ORIGIN of the `nb-card > nb-card-body.custom-table`
// row pattern — Vendors and Employment types `@forward` this file, and
// Positions, Employee levels and Documents reach it through Vendors — and it was
// the one page of the six still drawn the old way: every row its own card
// (`gauzy-card-1`, a radius, a 0.5rem bottom margin) floating in an unbounded
// scroll container, and a 6px rail that only exists while a row is selected, so
// clicking one shifted it sideways.
//
// `card-list-page()` is the part of the list-page contract that does not depend
// on `angular2-smart-table`: the density tokens, the toolbar scale, the bounded
// table surface, and rows flattened onto it with one hairline between them and a
// rail reserved at rest. Placed at the END of the file, after the `.custom-table`
// paint above, because the mixin deliberately writes at that same specificity and
// relies on source order to settle it.
//
// The five sheets that forward this one call `card-list-page()` themselves with
// their own arguments; those calls are emitted after this one and still win, so
// Vendors keeps its 1.125rem row gutter and Employment types keeps its card-body
// toolbar. The cost is one duplicated block in each of those sheets — the same
// declarations, immediately overridden — which is the price of the row treatment
// finally reaching the page that defines the pattern.
//
// `$actions: '.gauzy-button-container'`, not the default `nb-card-header`: this
// page's header holds the back arrow and the title and nothing else — the
// toolbar is the first child of the card BODY, above the table. The default
// named a block with no buttons in it, so the one thing the toolbar scale was
// there to compact was the one thing it never reached.
@include ga-hub.card-list-page('.gauzy-button-container');
@@ -22,3 +22,12 @@
// different height from its siblings and had back the problem the shared
// partial exists to solve (a gap under a short table, a clipped long one).
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager's
// `<nav><ul><li>` and the toolbar scale. Markup is the standard list shape, so
// the default `$actions` selector (`nb-card-header`) reaches this page's
// `.gauzy-button-container` and its sibling filter buttons alike.
@include ga-hub.list-page();
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/tabset-actions' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
nb-tabset {
@@ -62,3 +63,14 @@
:host .gauzy-button-container {
@include tabset-action-row(1rem);
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale. One `#tableLayout` template serves all three record tabs (time
// frames, KPIs, goal general settings), so the block reaches every one of them.
//
// `$actions` is the toolbar, not `nb-card-header`: this page's header holds only
// the title, and the action row rides the tab strip inside the card BODY (see
// `tabset-action-row()` above).
@include ga-hub.list-page('.gauzy-button-container');
@@ -1,4 +1,5 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.global {
display: flex;
@@ -235,3 +236,182 @@ h6 {
}
}
}
// ── The list-page contract ───────────────────────────────────────────────────
// The article list is built BY HAND — a `.table-scroll` holding one
// `.custom-table` div per article — so nothing in `list-page()` ever reached it:
// every selector in that bundle is anchored on `angular2-smart-table`. And
// `card-list-page()`, which is the mixin for exactly this shape (Vendors,
// Positions, Employee levels, Documents and Employment types all take it), is
// anchored on `.table-scroll-container` — a class this page deliberately does
// not use, because the global rule for it in `_overrides.scss` would overwrite
// the height chain here (`calc(100vh - 21rem)`, and `calc(100vh - 28.7rem)` once
// the filter row opens). So the same blocks are spelled out below against this
// page's own markup.
//
// What they replace: 14px/17px body text under a 16px article name, in rows
// padded 1rem and drawn as separately floating cards with their own radius and
// an 8px gap to the next, on a container with no ring at all — the last list
// under Organization still drawn the way the whole section was before the
// density pass.
// The tokens sit on `:host` rather than on the list, and that placement is the
// point: `ngx-avatar` and `ngx-no-data-message` are separate components, and
// custom properties are the only thing that crosses those boundaries.
:host {
@include ga-hub.density-tokens();
}
// `nb-card-header`, not `.gauzy-button-container` — this header holds THREE
// button groups and only one of them is in that container: "Add article" sits on
// the category line above it, and "Clear" sits in the filter row below it. Every
// metric the mixin sets is a custom property, so one declaration on the header
// reaches all three, `ngx-gauzy-button-action`'s own template included.
:host nb-card-header {
@include ga-hub.page-header-buttons();
}
// ── One surface, with the rows on it ─────────────────────────────────────────
// `row-gap` and the container's own padding go with the cards: the rows are
// flush now and carry their own inset, the same as every `<tr>` in the app.
:host .table-scroll {
@include ga-hub.surface();
padding: 0;
row-gap: 0;
}
// The row itself, flattened onto that surface. Padding, type scale and the
// hairline are `card-list-page()`'s values, so an article row and a vendor row
// measure the same.
//
// The rule is drawn INSIDE the row's box, so nothing moves; the transparent
// leading edge is there at rest so that selecting a row cannot shift it either.
:host .table-scroll .custom-table {
background: transparent;
border-radius: 0;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
line-height: var(--gauzy-table-line-height, 1.25rem);
box-shadow: inset 0 -1px 0 0 var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
border-inline-start: 3px solid transparent;
}
// The rule under the last article would be a second edge just inside the
// surface's own.
:host .table-scroll .custom-table:last-of-type {
box-shadow: none;
}
:host .table-scroll .custom-table:hover:not(.selected) {
background: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12));
}
// Selection was an 8px box-shadow rail hung outside the row's leading edge, in
// the SAME neutral tint as the fill — so it read as part of the row rather than
// as a marker, and the row jumped 8px sideways on click. The primary accent on
// the edge that is already reserved above, which is what every other card list
// in the app uses; the hairline is restated because this rule replaces the
// `box-shadow` that carried it.
:host .table-scroll .custom-table.selected {
background: var(--gauzy-active-tint, rgba(126, 126, 143, 0.2));
border-inline-start-color: var(--color-primary-default);
box-shadow: inset 0 -1px 0 0 var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
}
// ── Inside the row ───────────────────────────────────────────────────────────
:host .table-scroll .custom-table {
.row-name {
align-items: center;
gap: 0.375rem;
}
// The article name was 16px/19px — a step above the page's own control text
// and two above a table cell, which is what made one article read as a card
// heading rather than as a row.
.name {
margin-bottom: 0;
padding-bottom: 0;
font-size: 0.8125rem;
line-height: var(--gauzy-table-line-height, 1.25rem);
}
// The description and the byline, at the secondary scale the chips use.
> p {
margin-bottom: 0.25rem;
color: var(--gauzy-text-color-2, var(--text-hint-color));
}
// DRAFT and EMPLOYEES: the same box a status pill gets on every other list
// page, from the same three tokens. At 14px/600 in a 12px-radius lozenge they
// were the loudest thing in the row and a good deal taller than the name
// beside them. `margin-inline-start` because the pair sat on a `margin-left`
// that stayed on the left in RTL.
.draft,
.privacy {
min-height: var(--gauzy-table-badge-height, 1rem);
margin: 0;
margin-inline-start: 0.375rem;
padding-block: var(--gauzy-table-badge-padding-y, 0.1875rem);
padding-inline: var(--gauzy-table-chip-padding-x, 0.3125rem);
border-radius: var(--gauzy-table-badge-radius, 0.25rem);
font-size: var(--gauzy-table-chip-font-size, 0.625rem);
line-height: var(--gauzy-table-chip-line-height, 0.75rem);
}
// "Written by …" and "Created at …". `.create-at` carried a flat
// `margin-left: 100px` — a hard offset that neither flipped in RTL nor tracked
// the width of the names beside it; `auto` on the leading edge parks the
// timestamp against the row's trailing edge instead, which is where the
// equivalent column sits on a real table.
.user-info {
align-items: center;
gap: 0.5rem;
font-size: var(--gauzy-table-chip-font-size, 0.625rem);
line-height: var(--gauzy-table-chip-line-height, 0.75rem);
color: var(--gauzy-text-color-2, var(--text-hint-color));
p {
margin-bottom: 0;
}
// The negative pull existed to lift 30px avatars back onto the "Written by"
// line; at the people scale they are on that line already, so it only lifted
// them off it.
.image-wrap {
margin: 0;
gap: 0.125rem;
}
.create-at {
align-items: center;
gap: 0.25rem;
margin: 0;
margin-inline-start: auto;
}
.icon {
margin: 0;
font-size: 0.875rem;
}
}
}
// The author avatars, at the people scale the rest of the app's rows use. Same
// selector as the block higher up in this file (which pinned 24px), so it is
// source order that settles it.
//
// `.image-container` AND the image: the container is what RESERVES the space, and
// avatar.component.scss sizes it on its own (`size="sm"` in the template, so a
// 32px box). Shrinking only the picture left each author sitting in a 32px slot
// holding a 20px image — 12px of empty gutter before the name, on every avatar in
// the row. `!important` for the same reason the image line carries it: the rules
// being overridden nest under the avatar's `:host`, which shims to (0,4,0), and
// this block reaches them from outside at (0,2,1).
:host ::ng-deep ngx-avatar {
.image-container,
img {
height: var(--gauzy-people-avatar-size, 1rem) !important;
width: var(--gauzy-people-avatar-size, 1rem) !important;
}
}
@@ -1 +1,11 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// Density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager's `<nav><ul><li>` and the toolbar scale — the same
// block Invoices carries. This page has the standard list markup
// (`nb-card-header.card-custom-header` > `.gauzy-button-container`, a
// `.table-scroll-container` and a `.pagination-container`), so the default
// `$actions` selector reaches its toolbar unchanged.
@include ga-hub.list-page();
@@ -39,15 +39,17 @@
</div>
</div>
<div class="col-sm-6">
<label for="inputOrganizationContact" class="label"
>{{ 'INVOICES_PAGE.CONTACT' | translate }}
</label>
<ga-contact-select
[addTag]="true"
[placeholder]="'POP_UPS.ALL_CONTACTS' | translate"
formControlName="organizationContact"
(onChanged)="selectOrganizationContact($event)"
></ga-contact-select>
<div class="form-group">
<label for="inputOrganizationContact" class="label">
{{ 'INVOICES_PAGE.CONTACT' | translate }}
</label>
<ga-contact-select
[addTag]="true"
[placeholder]="'POP_UPS.ALL_CONTACTS' | translate"
formControlName="organizationContact"
(onChanged)="selectOrganizationContact($event)"
></ga-contact-select>
</div>
</div>
</div>
<div class="row">
@@ -119,13 +121,14 @@
</div>
</div>
<div class="row">
<div class="col-sm-12 mb-4">
<ga-tags-color-input
[selectedTags]="form.get('tags').value"
(selectedTagsEvent)="selectedTagsEvent($event)"
[isOrgLevel]="true"
>
</ga-tags-color-input>
<div class="col-sm-12">
<div class="form-group">
<ga-tags-color-input
[selectedTags]="form.get('tags').value"
(selectedTagsEvent)="selectedTagsEvent($event)"
[isOrgLevel]="true"
></ga-tags-color-input>
</div>
</div>
</div>
<div class="row">
@@ -183,6 +186,7 @@
placeholder="{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_PROJECTS' | translate }}"
formControlName="project"
id="inputProject"
class="item-selector"
(change)="selectProject($event)"
[multiple]="true"
appendTo="body"
@@ -211,6 +215,7 @@
placeholder="{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_TASKS' | translate }}"
formControlName="task"
id="inputTask"
class="item-selector"
(change)="selectTask($event)"
[multiple]="true"
appendTo="body"
@@ -239,6 +244,7 @@
placeholder="{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_PRODUCTS' | translate }}"
formControlName="product"
id="inputProduct"
class="item-selector"
(change)="selectProduct($event)"
[multiple]="true"
appendTo="body"
@@ -267,6 +273,7 @@
placeholder="{{ 'INVOICES_PAGE.INVOICE_TYPE.SELECT_EXPENSES' | translate }}"
formControlName="expense"
id="inputExpense"
class="item-selector"
(change)="selectExpense($event)"
[multiple]="true"
appendTo="body"
@@ -309,7 +316,6 @@
status="success"
[disabled]="!invoiceType"
nbButton
class="ml-3"
>
{{ 'INVOICES_PAGE.INVOICE_TYPE.GENERATE_FOR_UNINVOICED_EXPENSES' | translate }}
</button>
@@ -489,7 +495,7 @@
</div>
</form>
@if (shouldLoadTable) {
<div>
<div class="items">
<div class="table-scroll-container custom-table">
<angular2-smart-table
[class.ga-table-loading]="loading"
@@ -517,12 +523,12 @@
</div>
}
</nb-card-body>
<nb-card-footer class="text-left">
<button class="mr-3" size="small" status="basic" outline nbButton (click)="cancel()">
<nb-card-footer>
<button size="small" status="basic" outline nbButton (click)="cancel()">
{{ 'BUTTONS.CANCEL' | translate }}
</button>
<button
class="mr-3 gray"
class="gray"
(click)="addInvoice('DRAFT')"
size="small"
status="primary"
@@ -532,7 +538,6 @@
{{ 'BUTTONS.SAVE_AS_DRAFT' | translate }}
</button>
<button
class="mr-3"
(click)="sendToContact()"
status="success"
size="small"
@@ -542,7 +547,6 @@
{{ 'BUTTONS.SAVE_AND_SEND_CONTACT' | translate }}
</button>
<button
class="mr-3"
(click)="sendViaEmail()"
status="success"
size="small"
@@ -7,14 +7,28 @@ $shadow: 0 0 0 nb-theme(button-outline-width)
$alpha: 0.1
),
inset nb-theme(button-outline-focus-inset-shadow-length) transparent;
// "Generate invoice items" — the one action that has to be taken before this
// form has anything to save, so it is the only button in the body and it needs
// to read as one.
//
// It was drawn `background-color: var(--gauzy-card-2)` with a TRANSPARENT
// border: the exact colour of the card body behind it and no edge, so at rest it
// was green text floating on the page with no button around it, and hovering it
// pinned the border transparent again so nothing appeared. Same green, drawn the
// way every other outline button in the app is drawn (see the `.green` block in
// `gauzy/_gauzy-dialogs`): a translucent ring at rest that goes solid on hover.
[nbButton].gen.appearance-outline.status-basic {
background-color: var(--gauzy-card-2);
border-color: transparent;
background-color: transparent;
border-color: rgba(
$color: $green,
$alpha: 0.4
);
box-shadow: $shadow;
border-width: 2px;
color: $green;
&:hover {
border-color: transparent;
border-color: $green;
}
}
[nbButton].gen.appearance-outline:hover {
@@ -365,7 +365,7 @@
</div>
</form>
@if (shouldLoadTable) {
<div>
<div class="items">
<div class="table-scroll-container custom-table">
<angular2-smart-table
[class.ga-table-loading]="loading"
@@ -1,5 +1,6 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-dialogs' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// Row actions of the invoice-item grid. These were painted with literals: the
// add/save pill as `#00d68f` and — the visible one — the cancel/delete pill as a
@@ -62,6 +63,10 @@
border-radius: var(--border-radius);
padding: 0.375rem 0.75rem;
font-size: nb-theme(text-paragraph-font-size);
// Money, so the digits have to line up as the figures change: with the
// proportional default the whole chip reflows every time a quantity is
// edited in the grid above it.
font-variant-numeric: tabular-nums;
}
}
@@ -73,26 +78,221 @@
@include dialog(var(--gauzy-card-2), var(--gauzy-card-1));
}
// ── The title line ───────────────────────────────────────────────────────────
// A bare `d-flex` aligned the back arrow and the heading on their box TOPS, and
// the heading's own 0.5rem bottom margin then tipped it half a step below the
// arrow beside it. Centring the row and dropping that margin is what
// invoice-view's header already does; this puts Add / Edit on the same line.
nb-card-header {
&.d-flex {
align-items: center;
gap: 0.5rem;
}
// invoice-edit wraps the arrow and the title in these (and hangs the Payments
// button off the outer one); invoice-add puts them straight in the header.
.card-header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
h4 {
margin-bottom: 0;
}
}
// ── The form body ────────────────────────────────────────────────────────────
// Two columns: the record's own fields on the left, the Discount and Taxes
// panels on the right.
.form {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
margin-bottom: 36px;
// The step down to the item grid belongs to that block (`.items` below), which
// is the only thing that ever follows the form. As a margin here it applied
// just as much when no grid had been generated yet, leaving 36px of nothing
// between the last field and the card footer.
margin-bottom: 0;
.block-wrap {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
// The columns were two 48% blocks pushed apart by `space-between` on a row
// that could not wrap, so the 4% between them shrank with the window and the
// fields inside kept squeezing with no floor. A wrapping row with a real gap
// and a basis wide enough for a pair of half-width controls puts them side
// by side while there is room for both and stacks them when there is not —
// the same behaviour with no breakpoint to keep in step with the grid.
flex-wrap: wrap;
gap: 1rem 1.5rem;
align-items: flex-start;
.block {
width: 48%;
flex: 1 1 22rem;
// A flex item's `min-width` floor is its own content, so without this a
// long contact name or a wide select pushes its column past its share of
// the row instead of the control handling the overflow itself.
min-width: 0;
// Both columns start on the same line. The left one carries `mt-2` in the
// template and the right one carried nothing, so the first panel sat half
// a step above the first field beside it.
margin-top: 0.5rem;
}
}
// The Terms field. A bare `<textarea>` is two rows tall, which in a column of
// one-line controls reads as a slightly-too-tall input rather than somewhere
// to write a paragraph. Still draggable, but only on the axis that has room.
textarea[nbInput] {
min-height: 5rem;
resize: vertical;
}
}
// ── The item-type selectors ──────────────────────────────────────────────────
// Projects / Tasks / Products / Expenses on the Add form: `ng-select`s in
// multiple mode, whose selected values are chips inside the trigger.
//
// The chips already wrapped — `flex-wrap: wrap` on `.ng-value-container` is
// ng-select's own default — but nothing past the first line was ever visible:
// `includes/_ng-select.scss` pins EVERY `.ng-select-container` in the app to
// `height: 40px`, and ng-select's component sheet gives it `overflow: hidden`.
// Between them, picking a fifth project made the four before it disappear.
//
// So let the trigger grow with its content, cap it at two rows of chips, and
// scroll the VALUE box rather than the whole trigger — that keeps the clear
// button and the arrow on their own axis while the chips move under them.
//
// SPECIFICITY: every selector below is spelled out to its full ng-select depth
// on purpose. `@ng-select/ng-select/themes/default.theme.css` is listed after
// `styles.scss` in the app's `styles` array, so its five-class rules win every
// tie; naming the same chain plus this component's `:host` attribute is what
// carries these past it without `!important`.
// The chip's box, and the cap derived FROM it rather than pinned beside it: a
// magic `max-height` next to the padding and line height that decide a chip's
// real height is a pair that drifts the first time either side is retuned.
$chip-line-height: 1.125rem;
$chip-padding-y: 0.125rem;
$chip-padding-x: 0.5rem;
$chip-gap: 0.25rem;
$value-box-padding-y: 0.25rem;
// How many rows of chips the field shows before it starts scrolling.
$visible-chip-rows: 2;
$chip-height: $chip-line-height + $chip-padding-y * 2;
:host ::ng-deep .item-selector.ng-select-multiple {
.ng-select-container {
height: auto;
// NO `align-items` here. It was `flex-start` — on the reasoning that an arrow
// centred against four rows of chips floats halfway down the field — and that
// was the wrong trade: the field is EMPTY or one line deep almost all of the
// time, and top-pinning threw the placeholder and the arrow off the trigger's
// centre in exactly that case. The arrow wrapper has no height of its own, so
// top-aligning it shrank it to its glyph instead of letting it stretch.
//
// Inheriting the shared `center` keeps one line perfectly centred and puts
// the arrow on the middle of a tall chip stack, which is what every
// component library does with a chips input.
}
.ng-select-container .ng-value-container {
flex-wrap: wrap;
// chip↔chip spacing, so the chips carry no margins of their own. The INLINE
// padding is left to the shared `ng-select-overrides()` block, which sets it
// `!important` so every trigger in the app starts its text on one x.
gap: $chip-gap;
// Symmetric, where ng-select's own theme pads this box `5px` at the top and
// nothing at the bottom — which is what tipped the chips above the trigger's
// centre line even before the wrapping was fixed.
padding-block: $value-box-padding-y;
// Two rows of chips and the gap between them, plus this box's own padding
// (`box-sizing: border-box` app-wide, so the padding is inside the cap).
// Anything past that scrolls.
max-height: $visible-chip-rows * $chip-height + ($visible-chip-rows - 1) * $chip-gap +
$value-box-padding-y * 2;
overflow-y: auto;
// The app's scrollbar — the same one the dropdown panels and card bodies use.
scrollbar-width: thin;
&::-webkit-scrollbar {
width: nb-theme(scrollbar-width);
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--gauzy-scrollbar);
border-radius: 1rem;
}
}
// The typing area. ng-select pads it `0 0 3px 3px`, i.e. 3px along the bottom
// and none along the top — so it is the tallest thing on the line and every
// chip beside it sits that much above the line's centre. The chip↔chip `gap`
// above is what separates the caret from the chips now, so the block half of
// that padding has nothing left to do; the inline 3px stays.
.ng-select-container .ng-value-container .ng-input {
padding-block: 0;
}
// One chip. ng-select's theme paints it `#333` on a pale blue with a 2px
// corner — light-mode literals, so in the four dark themes the chips were a
// foreign pale-blue block with near-black text on them. A neutral tint, the
// shared small radius and the same hairline every other small box on this page
// carries, all of which resolve per theme.
.ng-select-container .ng-value-container .ng-value {
// `margin: 0` because the row's spacing is the container's `gap` now.
// ng-select's own theme puts `margin-bottom: 5px` on every chip, which on a
// wrapped row is 5px of dead space under the LAST line as well — the other
// half of why the chips never sat on the centre line.
margin: 0;
padding: $chip-padding-y $chip-padding-x;
background-color: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12));
box-shadow: $action-hairline;
border-radius: var(--gauzy-radius-sm, 0.375rem);
color: var(--gauzy-text-color-1);
font-size: nb-theme(text-caption-font-size);
// Pinned, not left to `normal`: the chip is what sets the height of its row,
// so an unresolved line box would make the row's height depend on the font
// that happened to load — and the two-row cap above is derived from it.
line-height: $chip-line-height;
// The chip is a flex item and a block box; centre its own text in it so a
// chip and the typing caret beside it share one centre line.
display: flex;
align-items: center;
max-width: 100%;
}
// The chip's label comes from this form's own `ng-label-tmp`, which wraps the
// name in a `.selector-template`. A long project name ellipsizes inside the
// chip instead of pushing it past the edge of the field.
.ng-select-container .ng-value-container .ng-value .selector-template {
display: block;
// The chip above is a flex box, and a flex item will not shrink below its own
// content unless it is told it may — without this `text-overflow` never
// engages and a long project name widens the chip instead of picking up an
// ellipsis.
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// The generated item grid, its pager and the totals — one block, stepped away
// from the form above it.
.items {
margin-top: 1.5rem;
}
.footer-block-wrap {
width: 100%;
display: flex;
@@ -109,18 +309,54 @@
width: 98%;
}
// ── The Discount and Taxes panels ────────────────────────────────────────────
// Each one is a Bootstrap `.row` as well as a `.group`, and a `.row` bleeds 15px
// past its container on both sides with negative margins — so the panel was
// drawn wider than the column it belongs to, hanging into the gutter between the
// two columns on one side and off the block on the other. The asymmetric
// `9px 18px 2px 12px` padding was what that bleed had been tuned against.
//
// Zero the bleed, pad the panel evenly, and give it the radius and the hairline
// every other surface on the page already carries. The fill stays where the
// page's other surfaces are painted, on `.content .row.group` below.
.group {
border-radius: 0.6rem;
margin-bottom: 20px;
padding: 9px 18px 2px 12px;
margin: 0 0 1rem;
padding: 1rem;
border-radius: var(--border-radius);
box-shadow: $action-hairline;
.label-group {
font-size: 14px;
display: block;
width: 100%;
// A section title, not a field label: one step above the 0.75rem that
// `input-appearance` gives every `.label`, and in the primary text colour
// rather than the muted one the fields under it use. It was pinned at 14px,
// so it did not follow the root font size either.
font-size: nb-theme(text-subtitle-2-font-size);
font-weight: 600;
line-height: 17px;
line-height: 1.25;
letter-spacing: -0.01em;
text-align: left;
margin-bottom: 18px;
color: var(--gauzy-text-color-1);
margin-bottom: 0.75rem;
}
// The `.row`s INSIDE the panel bleed the same 15px, which put the first field
// flush against the panel's padding edge and the second one past it. A 0.5rem
// gutter keeps a pair of half-width fields inside the panel with a real gap
// between them.
> .row {
margin-inline: -0.5rem;
> [class*='col-'] {
padding-inline: 0.5rem;
}
}
// The panel's own bottom padding is the space under its last row of fields, so
// the field's `.form-group` margin would otherwise double it.
> .row:last-child > [class*='col-'] > .form-group:last-child {
margin-bottom: 0;
}
}
@@ -129,15 +365,40 @@
flex-direction: row;
justify-content: flex-start;
align-items: center;
// Was an `ml-3` utility on the second button. A gap spaces however many
// buttons the selected invoice type puts on the line, and lets them wrap
// rather than overflow the column.
flex-wrap: wrap;
gap: 0.75rem;
}
// Was `20px -1rem`: a negative inline margin that pulled the checkbox out past
// both edges of its column, so it lined up with neither the panels above it nor
// the fields inside them.
.discountCheckbox {
margin: 20px -1rem;
margin: 0 0 1rem;
}
nb-card {
background-color: var(--gauzy-card-2);
}
// The action row under the card body. Its buttons spaced themselves with `mr-3`,
// a Bootstrap utility — so the spacing was `!important`, it had to be repeated
// on every button, and the last one left a trailing 1rem inside the footer. One
// flex line with a gap says it once; the cancel below is for invoice-edit, whose
// template still carries the utility.
nb-card-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
[nbButton] {
margin-right: 0 !important;
}
}
div.button-action {
background-color: var(--gauzy-card-2);
padding: 6px 8px;
@@ -152,8 +413,14 @@ div.button-action {
.custom-table {
display: flex;
flex-direction: column;
background-color: var(--gauzy-card-2);
padding: 12px;
// `gauzy-card-1`, not `-2`: this element also carries
// `.table-scroll-container`, which `list-page()` at the bottom of this file
// paints with the shared table `surface()` — card-1, a hairline and a radius.
// Painting it card-2 here left that ring and radius drawn around a block the
// exact colour of the card body behind it, so the item grid read as a line
// on the page rather than as the bounded table surface every list page has.
background-color: var(--gauzy-card-1);
padding: 0.75rem;
.table {
margin: 0;
}
@@ -173,17 +440,43 @@ div.button-action {
margin-top: 1rem;
}
// Save as draft: the neutral step between the outline Cancel and the two success
// buttons beside it, painted with the grey `_gauzy-dialogs` already shares with
// the rest of the app rather than with a literal repeated here.
//
// The block this replaces set the fill on the button but hung its hover state
// off a `[nbButton]` DESCENDANT of it — markup that does not exist — so the
// button stayed flat under the pointer while every other button in the row
// responded.
:host button.gray {
background-color: rgba(126, 126, 143, 1);
color: nb-theme(text-control-color);
border-color: var(--button-filled-basic-border-color);
[nbButton].appearance-filled.status-basic {
background-color: nb-theme(button-filled-basic-background-color);
border-color: nb-theme(button-filled-basic-border-color);
color: nb-theme(button-filled-basic-text-color);
:hover {
color: nb-theme(text-basic-color);
background-color: rgba($color: rgba(126, 126, 143), $alpha: 0.1);
&[nbButton].appearance-filled {
background-color: $gray;
border-color: $gray;
color: nb-theme(text-control-color);
&:hover:not([disabled]) {
background-color: rgba($color: $gray, $alpha: 0.85);
border-color: rgba($color: $gray, $alpha: 0.85);
}
&:focus:not(:hover):not(:active) {
box-shadow: 0 0 0 nb-theme(button-outline-width) rgba($color: $gray, $alpha: 0.2);
}
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// The invoice-items grid, brought onto the same footing as the Invoices list
// this form is opened from: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the pager. Emitted
// into invoice-add's sheet too, through the `@forward` at the top of that file —
// Add and Edit render the same grid.
//
// The density tokens only reach cell type and padding and the FILTER row's
// controls (see `--gauzy-table-control-height` in `_overrides.scss`); the inline
// row editors this grid is built on keep their own control sizing.
//
// `$actions: null`: there is no page-header action row here. This form's buttons
// are full-size `nb-card-header` form actions (Save, Send, …), not the compact
// list toolbar the mixin's button block is written for.
@include ga-hub.list-page(null);
@@ -1,4 +1,5 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
.date-and-value {
display: flex;
@@ -71,3 +72,14 @@
display: flex;
justify-content: space-between;
}
// ── The canonical list-page block ────────────────────────────────────────────
// An invoice's payments are the same table as `/pages/accounting/payments`, one
// click in, so they take the same block: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills and the toolbar scale.
//
// `$actions` is `.custom-container` — the panel below the invoice summary that
// holds both the toolbar and the table; this page's `nb-card-header` carries only
// the invoice's title. There is no pager on this table, so the pager half of the
// block simply matches nothing.
@include ga-hub.list-page('.custom-container');
@@ -1 +1,9 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// Received invoices and received estimates render this component, and both are
// the same table as `/pages/accounting/invoices` — so they take the same block
// that page does: density tokens, the table surface, row hover/selection, the
// tag-chip stack, the status pills, the pager and the toolbar scale.
@include ga-hub.list-page();
@@ -75,8 +75,8 @@
</nb-accordion-item-header>
<nb-accordion-item-body>
<form [formGroup]="searchForm" (ngSubmit)="search()">
<div class="row w-100">
<div class="col-sm-4">
<div class="row w-100 search-filters">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputInvoiceNumber" class="label">
<span>
@@ -103,7 +103,7 @@
/>
</div>
</div>
<div class="col-sm-3">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputInvoiceDate" class="label">
<span>
@@ -137,7 +137,7 @@
</div>
</div>
</div>
<div class="col-sm-3">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputDueDate" class="label">{{
'INVOICES_PAGE.DUE_DATE' | translate
@@ -157,9 +157,7 @@
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-3">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputOrganizationContact" class="label">
<span>{{ 'INVOICES_PAGE.CONTACT' | translate }}</span>
@@ -171,7 +169,7 @@
></ga-contact-select>
</div>
</div>
<div class="col-sm-2">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputTotalValue" class="label">
{{ 'INVOICES_PAGE.TOTAL_VALUE' | translate }}
@@ -186,7 +184,7 @@
/>
</div>
</div>
<div class="col-sm-2">
<div class="col search-filter-col">
<div class="form-group">
<ga-currency
formControlName="currency"
@@ -196,15 +194,17 @@
</ga-currency>
</div>
</div>
<div class="col-sm-2">
<ga-tags-color-input
[selectedTags]="searchForm.get('tags').value"
(selectedTagsEvent)="selectedTagsEvent($event)"
[isOrgLevel]="true"
>
</ga-tags-color-input>
<div class="col search-filter-col">
<div class="form-group">
<ga-tags-color-input
[selectedTags]="searchForm.get('tags').value"
(selectedTagsEvent)="selectedTagsEvent($event)"
[isOrgLevel]="true"
>
</ga-tags-color-input>
</div>
</div>
<div class="col-sm-2">
<div class="col search-filter-col">
<div class="form-group">
<label for="inputStatus" class="label">
{{ 'INVOICES_PAGE.STATUS' | translate }}
@@ -240,30 +240,20 @@
</div>
</div>
</div>
<div class="row">
<div>
<button
type="submit"
status="success"
class="ml-3"
size="small"
nbButton
>
{{ 'BUTTONS.SEARCH' | translate }}
</button>
</div>
<div>
<button
type="reset"
status="basic"
outline
size="small"
nbButton
(click)="reset()"
>
{{ 'BUTTONS.RESET' | translate }}
</button>
</div>
<div class="row search-actions">
<button type="submit" status="success" size="small" nbButton>
{{ 'BUTTONS.SEARCH' | translate }}
</button>
<button
type="reset"
status="basic"
outline
size="small"
nbButton
(click)="reset()"
>
{{ 'BUTTONS.RESET' | translate }}
</button>
</div>
</form>
</nb-accordion-item-body>
@@ -303,80 +293,167 @@
</div>
</nb-accordion-item-header>
<nb-accordion-item-body>
<div class="row comments-container">
<div class="col-6">
<div class="comments-container">
<section class="comment-panel comment-composer">
<header class="comment-panel-header">
<nb-icon icon="edit-2-outline" pack="eva"></nb-icon>
<span class="comment-panel-title">
{{ 'BUTTONS.ADD_COMMENT' | translate }}
</span>
</header>
@if (selectedInvoice) {
{{ 'INVOICES_PAGE.COMMENT' | translate }}
<form
class="comment-form"
[formGroup]="historyForm"
#historyFormDirective="ngForm"
(ngSubmit)="addComment(historyFormDirective)"
>
<div class="col-8 mt-3 px-0">
<input
nbInput
type="text"
placeholder="{{ 'INVOICES_PAGE.TITLE' | translate }}"
fullWidth
formControlName="title"
/>
</div>
<div class="col px-0 mt-3">
<textarea
nbInput
placeholder="{{ 'INVOICES_PAGE.COMMENT' | translate }}"
fullWidth
formControlName="comment"
></textarea>
</div>
<div class="mt-3">
<button
type="submit"
class="mx-0"
nbButton
status="info"
[disabled]="historyForm.invalid"
>
{{ 'BUTTONS.ADD_COMMENT' | translate }}
</button>
<div class="comment-form-row">
<span class="comment-avatar" aria-hidden="true">
@if (currentUser?.imageUrl) {
<img [src]="currentUser.imageUrl" alt="" />
} @else {
{{ authorInitials(currentUser?.name) }}
}
</span>
<div class="comment-form-fields">
<input
nbInput
type="text"
fullWidth
fieldSize="small"
class="comment-form-title"
formControlName="title"
placeholder="{{
'FORM.PLACEHOLDERS.TITLE' | translate
}}"
/>
<div class="comment-form-box">
<textarea
nbInput
fullWidth
fieldSize="small"
rows="3"
formControlName="comment"
placeholder="{{
'INVOICES_PAGE.COMMENT_PLACEHOLDER' | translate
}}"
></textarea>
<div class="comment-form-actions">
<button
type="button"
nbButton
ghost
size="tiny"
status="basic"
[disabled]="historyForm.pristine"
(click)="resetComment(historyFormDirective)"
>
{{ 'BUTTONS.RESET' | translate }}
</button>
<button
type="submit"
nbButton
size="tiny"
status="primary"
[disabled]="historyForm.invalid"
>
<nb-icon
icon="paper-plane-outline"
pack="eva"
></nb-icon>
{{ 'BUTTONS.ADD_COMMENT' | translate }}
</button>
</div>
</div>
</div>
</div>
</form>
}
</div>
@if (!disableButton) {
<div class="col-6">
{{ 'INVOICES_PAGE.COMMENTS' | translate }}
<div class="comments w-100">
<nb-list class="history-list">
@for (history of histories; track history) {
<nb-list-item
class="history-list-item"
style="align-items: baseline"
>
<div class="d-flex">
<div>
<ngx-avatar
[src]="history.user.imageUrl"
[name]="history.user.name"
[value]="history.user"
></ngx-avatar>
<div class="history-date history-item">
{{ history.createdAt | dateTimeFormat }}
</div>
<div class="history-item history-title">
<span>{{ history.title }}</span>
</div>
<div class="history-item history-comment">
<span>{{ history.action }}</span>
</div>
</div>
</div>
</nb-list-item>
}
</nb-list>
} @else {
<div class="comment-empty">
<nb-icon icon="file-text-outline" pack="eva"></nb-icon>
<span>
{{
(!isEstimate
? 'INVOICES_PAGE.SELECT_INVOICE_TO_VIEW_HISTORY'
: 'INVOICES_PAGE.ESTIMATES.SELECT_ESTIMATE_TO_VIEW_HISTORY'
) | translate
}}
</span>
</div>
}
</section>
<section class="comment-panel comment-thread">
<header class="comment-panel-header">
<nb-icon icon="message-square-outline" pack="eva"></nb-icon>
<span class="comment-panel-title">
{{ 'INVOICES_PAGE.COMMENTS' | translate }}
</span>
@if (selectedInvoice && histories.length) {
<span class="comment-count">{{ histories.length }}</span>
}
</header>
<div class="comments">
@if (selectedInvoice && histories.length) {
<ul class="comment-list">
@for (history of histories; track history.id) {
<li class="comment-item">
<span class="comment-avatar" aria-hidden="true">
@if (history.user?.imageUrl) {
<img [src]="history.user.imageUrl" alt="" />
} @else {
{{ authorInitials(history.user?.name) }}
}
</span>
<div class="comment-body">
<div class="comment-byline">
@if (canOpenUserProfile && history.user?.id) {
<a
class="comment-author"
[routerLink]="[
'/pages/users/edit',
history.user.id
]"
>
{{ history.user?.name }}
</a>
} @else {
<span class="comment-author">
{{ history.user?.name }}
</span>
}
<span
class="comment-time"
[nbTooltip]="
history.createdAt | dateTimeFormat
"
>
{{ commentTimeAgo(history.createdAt) }}
</span>
</div>
@if (history.title) {
<div class="comment-subject">
{{ history.title }}
</div>
}
<p class="comment-text">{{ history.action }}</p>
</div>
</li>
}
</ul>
} @else {
<div class="comment-empty">
<nb-icon icon="message-square-outline" pack="eva"></nb-icon>
<span class="comment-empty-title">
{{ 'INVOICES_PAGE.NO_COMMENTS' | translate }}
</span>
<span class="comment-empty-hint">
{{ 'INVOICES_PAGE.NO_COMMENTS_HINT' | translate }}
</span>
</div>
}
</div>
}
</section>
</div>
</nb-accordion-item-body>
</nb-accordion-item>
@@ -445,7 +522,7 @@
<nb-select
filled
status="basic"
class="action select-nb mr-2 ml-1"
class="action select-nb"
[placeholder]="'INVOICES_PAGE.SET_STATUS' | translate | titlecase"
[(selected)]="status"
[disabled]="disableButton"
@@ -463,7 +540,7 @@
<nb-select
filled
status="basic"
class="action select-nb mr-2"
class="action select-nb"
[placeholder]="'INVOICES_PAGE.SET_STATUS' | translate | titlecase"
[(selected)]="status"
[disabled]="disableButton"
@@ -95,33 +95,12 @@
margin-bottom: 20px;
}
.history-list {
margin: 20px 0;
}
.history-list-item {
display: flex;
flex-direction: column;
}
.history-list-select {
font-weight: bold;
// margin: 20px 0;
width: 50%;
}
.history-action {
font-weight: bold;
}
.history-date {
font-size: 12px;
}
.history-item {
align-self: flex-start;
}
.btn-group {
display: flex;
align-items: center;
@@ -259,23 +238,27 @@ ga-pagination {
width: 100%;
}
.comments {
// `scroll` reserved a scrollbar gutter on the History tab even when an invoice
// has one comment or none, so the empty panel showed a dead scrollbar track.
overflow-y: auto;
height: 15rem;
}
textarea {
height: 85px;
resize: none;
}
// Same hairline ring as the shared `.action` block in `gauzy/_gauzy-table.scss`
// — this file carries a near-verbatim copy of it (with the drop shadow spelled
// out as a literal rather than read from `--gauzy-shadow`).
.action {
box-shadow: inset 0 0 0 1px var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
// The one line the copy had dropped, and the one that matters on the dark
// theme: Nebular draws a REAL 1px border on every filled button, and for
// `status="basic"` it colours it `color-basic-default-border` →
// `color-basic-300` → #edf1f7. Neither `dark` nor `gauzy-dark` restates the
// `color-basic-*` ramp, so that near-white is what the whole toolbar strip
// beside the tabs was ringed with — and the hover/active/focus steps are
// whiter still (#f7f9fc / #e4e9f2 / #c5cee0). Only the FILL was overridden
// below, which left every button outlined in white on a near-black page.
//
// Dropping the border rather than recolouring it, because the ring above is
// already this button's edge: it is the same hairline the cards, overlays and
// table use, it self-adjusts on the light themes, and it costs no layout box
// — so the strip also stops standing 2px taller than the identical strip on
// every other list page, all of which take the shared block and its
// `border: none` with it.
border: none;
&[nbButton].appearance-filled.status-basic {
background-color: var(--gauzy-card-2);
@@ -303,14 +286,134 @@ textarea {
}
}
&.select-nb ::ng-deep {
// ── The "Set status" trigger, rebuilt as a View button ──────────────
// Matching the select's metrics one by one got the numbers right and still
// left it reading as a form field, because its BOX MODEL is a field's: the
// label is an inline `<span>` in a block button, and the chevron is taken out
// of the flow and parked in a gutter the padding reserves for it — hence a
// wide control with the label jammed left and the arrow far away.
//
// So the box is rebuilt the way `[nbButton]` builds one: inline-flex, centred,
// with the chevron an ordinary sibling of the label separated by the same
// `button-small-icon-offset` that sits between View's eye icon and its text.
// Nothing is pinned after that — the padding, line box and icon size are the
// button's own tokens, so the height comes out of the same arithmetic rather
// than being hard-coded to match.
&.select-nb {
// The ring belongs on the inner <button>, which is the box that is painted.
box-shadow: none;
// `button { margin: 5px }` near the top of this file spaces every button on
// the strip, and it cannot reach `.select-button`: that <button> lives in
// nb-select's template and carries THAT component's content attribute, not
// this one. The trigger wore `mr-2 ml-1` instead — 0.5rem / 0.25rem and
// nothing vertical. Those are Bootstrap utilities, i.e. `!important`, so
// they could not be overridden from here and came off the template; the
// host now takes the same 5px the buttons do.
margin: 5px;
.select-button {
box-shadow: inset 0 0 0 1px var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
::ng-deep .select-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: nb-theme(button-small-icon-offset);
// Nebular's stock `select-min-width` is 13rem, and the app does not take
// it: of the eight themes `themes.scss` registers, six restate the token
// as 6rem, and the two that do not — `gauzy-light` and `gauzy-dark`, the
// two the app ships on — extend `default` and `dark`, which are among the
// six. `nb-register-theme` flattens a parent's map into the child at
// registration, so both inherit 6rem rather than falling back to the stock
// value. Either way this trigger takes none of it: the box hugs its label,
// the way every button on the strip does.
min-width: 0;
width: auto;
padding: nb-theme(button-filled-small-padding);
// View computes to 1.5rem: a 1rem line box inside 0.25rem of padding,
// with no border since the rule above drops it. The padding and the line
// box below reproduce that arithmetic, and the height is stated outright
// as well — a derived height is only ever as tall as the TALLEST thing in
// the flex line, so anything that resolves larger than the line box (the
// chevron, or a line-height that fails to resolve) would silently push
// the box past the buttons again.
height: 1.5rem;
// Nebular stacks `select-icon-offset` (2rem) on top of that shorthand
// from a `[dir=ltr]`-qualified rule, to clear the absolutely positioned
// chevron. There is no gutter to reserve now that the chevron is back in
// the flow, so the inline-end side is restated the same way — it is the
// only way to out-rank it.
@include nb-ltr(padding-right, 0.625rem);
@include nb-rtl(padding-left, 0.625rem);
line-height: nb-theme(button-small-text-line-height);
font-size: nb-theme(button-small-text-font-size);
font-weight: nb-theme(button-text-font-weight);
// `select-filled-basic-text-color` is `color-basic-800` (#222b45), a navy
// that all but vanishes on the near-black panel. This is what `.secondary`
// paints View with.
color: var(--text-hint-color);
background: var(--gauzy-card-2);
height: 2rem;
// `select-filled-basic-border-color` resolves down the same chain as the
// buttons' — `color-basic-default-border` → `color-basic-300` → #edf1f7 —
// so the one control here that is not a button was ringed in white
// alongside them. `border-color` alone would not do it:
// `nb-select.appearance-filled .select-button` sets the style and the
// width too, and this selector out-ranks it, so the whole shorthand goes.
border: none;
border-radius: nb-theme(button-rectangle-border-radius);
box-shadow: inset 0 0 0 1px var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
// Nebular draws a select's PLACEHOLDER from its own pair of tokens —
// 0.875rem at weight 400, against the buttons' 0.75rem at 700 — and "Set
// Status" IS the placeholder, i.e. the state on screen until a row is
// picked.
&.placeholder {
font-size: nb-theme(button-small-text-font-size);
font-weight: nb-theme(button-text-font-weight);
color: var(--text-hint-color);
}
// Nebular repaints a filled select on hover and focus out of the
// `color-basic-*` ramp — the same near-whites the border rule above got
// rid of. The buttons never move: this page's `.action` fill out-ranks
// Nebular's hover on an `[nbButton]`, and these restore that standoff.
&:hover,
&:focus,
&:focus:hover {
background: var(--gauzy-card-2);
}
// Disabled — which is how the strip starts out, before a row is picked.
// A button dims its LABEL and keeps its fill; a select swaps its fill for
// an opaque `background-basic-color-2` instead. Last of the three, so it
// settles the tie with `.placeholder` above.
&[disabled] {
background: var(--gauzy-card-2);
color: var(--text-disabled-color);
}
nb-icon {
// Back into the flow, which is the whole point of the block above:
// `position: absolute` + `top: 50%` + `translateY(-50%)` is what put
// the chevron in a gutter instead of beside the label.
position: static;
transform: none;
// View's icon is a 0.875rem BOX — `[nbButton].size-#{size} nb-icon` sets
// an explicit width and height from `--button-small-icon-size`, which
// `page-header-buttons()` compacts. A select's chevron gets neither: it
// is sized `1.5em` and then taken out of the flow, so its box never had
// to be right. Back in the flow it is a flex item whose height counts
// toward the button's, and `nb-icon`'s only other sizing is the global
// `width/height: 1em` — so both are spelled out here exactly as the
// button spells them.
font-size: nb-theme(button-small-icon-size);
width: nb-theme(button-small-icon-size);
height: nb-theme(button-small-icon-size);
color: var(--text-hint-color);
}
}
// The open-state flip, which Nebular writes as a SECOND transform including
// the centring translate it no longer needs.
&.open ::ng-deep .select-button nb-icon {
transform: rotate(180deg);
}
}
}
@@ -440,60 +543,411 @@ nb-tab {
@include ga-overrides.dialog(var(--gauzy-card-1), var(--gauzy-card-1));
}
// ── History tab: comment thread ──────────────────────────────────────────────
// A composer on the left and the thread on the right, rather than the pair of bare
// Bootstrap `col-6`s this used to be. A grid instead of a `.row`: with neither
// column drawing a box, the space between them is the ONLY thing holding them
// apart, and a `.row` cannot give a real gutter (it pads each child inwards, then
// pulls the whole thing back out with negative margins). This gap plus each
// panel's own side padding is what that space adds up to. One breakpoint is enough
// to stack them on a narrow window.
//
// The height is pinned for the same reason the old `.comments` carried a fixed
// 15rem: this accordion shares `.custom-content-body` (`calc(100vh - 19.5rem)`)
// with the table below it, so a thread that grew with its content would eat the
// table's rows one comment at a time. The thread scrolls inside that budget; the
// composer keeps its natural size and its column absorbs the rest.
.comments-container {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
gap: 0.75rem;
height: 15rem;
margin-inline: 0;
& nb-list {
@media (max-width: 991px) {
grid-template-columns: minmax(0, 1fr);
height: auto;
}
}
// Flat: no fill and no ring of its own. Each panel is a REGION of the accordion
// body, not a card sitting on it, and boxing the two of them inside a body that is
// already a bordered surface put three edges between a comment and the page. What
// separates them now is the column gap and the rule under each panel's own title.
.comment-panel {
display: flex;
flex-direction: column;
min-height: 0;
padding: 0.5rem 0.75rem 0.75rem;
color: var(--gauzy-text-color-2);
}
.comment-panel-header {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.375rem;
padding-bottom: 0.625rem;
margin-bottom: 0.875rem;
border-bottom: thin solid var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
nb-icon {
width: 0.875rem;
height: 0.875rem;
font-size: 0.875rem;
color: var(--gauzy-text-color-2);
}
}
.comment-panel-title {
font-size: 0.75rem;
font-weight: 600;
line-height: 1rem;
letter-spacing: -0.01em;
color: var(--gauzy-text-color-1);
}
// Comment count, on the far end of the thread's header line.
.comment-count {
@include nb-ltr(margin-left, auto);
@include nb-rtl(margin-right, auto);
min-width: 1.25rem;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--gauzy-hover-tint, rgba(126, 126, 143, 0.12));
font-size: 0.6875rem;
font-weight: 600;
line-height: 1rem;
text-align: center;
color: var(--gauzy-text-color-2);
}
// The composer's avatar column: the avatar itself and the gap between it and the
// fields beside it. Declared once so the avatar's own box, the row's gap and the
// centring nudge below cannot drift apart the next time the avatar is resized.
$composer-avatar: 1.5rem;
$composer-gap: 0.5rem;
// One avatar treatment for the composer and for every comment. The shared
// `ngx-avatar` is deliberately NOT used here: it renders nothing at all when its
// `src` is empty, so every author who never uploaded a photo left a hole where the
// avatar belongs. `authorInitials()` in the component covers that case, and this
// is the box both a photo and the initials are drawn in.
.comment-avatar {
flex: 0 0 auto;
display: grid;
place-items: center;
width: $composer-avatar;
height: $composer-avatar;
overflow: hidden;
border-radius: 50%;
background: var(--gauzy-active-tint, rgba(126, 126, 143, 0.2));
font-size: 0.6875rem;
font-weight: 600;
line-height: 1;
color: var(--gauzy-text-color-1);
user-select: none;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.comment-form {
display: flex;
flex-direction: column;
min-height: 0;
}
// The composer is a face beside a stack of fields — one flex row holding the
// avatar and a flex COLUMN holding the subject and the comment box. That nesting
// is what makes both fields start on the same line as each other, past the avatar,
// without either of them having to carry a margin that guesses the avatar's width.
.comment-form-row {
display: flex;
align-items: flex-start;
gap: $composer-gap;
}
.comment-form-fields {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: 0.375rem;
}
// Centres the avatar on the subject field it opens beside — half the difference
// between the two heights.
.comment-form-row .comment-avatar {
margin-top: calc((#{ga-overrides.$compact-control-height} - #{$composer-avatar}) / 2);
}
// The subject is a field of its own beside the avatar, NOT a second row inside the
// comment box: sharing that box's edge made the two read as one input that had
// somehow split in two. So it gets the ordinary treatment — the same borderless,
// hairline-ringed field the Search tab's controls use, at the same height.
//
// `input-appearance()` — pulled in by the `:host nb-accordion` dialog include
// above — paints every field in this accordion `--gauzy-card-1`, borderless, on a
// 42px floor, all as `!important`, so both the fill and the floor have to be
// re-stated at that weight to be reachable at all.
.comment-form-title[nbInput] {
// `fullWidth` on the element resolves to Nebular's `.input-full-width` (0,1,0),
// which this out-ranks: as a column child the field is stretched to the column's
// width already, and a second 100% on top of that is what it does not need.
width: auto;
min-width: 0;
min-height: ga-overrides.$compact-control-height !important;
height: ga-overrides.$compact-control-height;
padding-block: 0;
background-color: var(--gauzy-card-2) !important;
border: none;
box-shadow: ga-overrides.$default-box-shadow-inset;
font-size: 0.75rem;
// The hairline is the field's only edge, so focus has to move it rather than add
// to it — with the border gone, Nebular's own focus state has nothing to colour.
&:focus {
box-shadow: inset 0 0 0 1px nb-theme(color-primary-500);
}
}
// The comment box is ONE control, the way a chat input is: the box owns the
// border, the fill and the focus ring, and the body and its buttons live inside
// it. Previously the field drew its own outline and the buttons sat on the panel
// below it, which read as two separate widgets stacked up.
.comment-form-box {
display: flex;
flex-direction: column;
padding: 0.375rem 0.5rem;
border-radius: var(--border-radius);
background: var(--gauzy-card-2);
box-shadow: ga-overrides.$default-box-shadow-inset;
// Focus lands on a field inside; the ring belongs on the box that draws the edge.
&:focus-within {
box-shadow: inset 0 0 0 1px nb-theme(color-primary-500);
}
// The textarea has to disappear into the box: no fill, no edge of its own, no
// inset padding (the box already provides it). `border: none` because Nebular
// draws one on `[nbInput]` by default — inside the box that would be a second
// line weight against the box's own.
//
// Replaces the file-wide `textarea { height: 85px; resize: none }` this page
// used to carry for this one control.
textarea[nbInput] {
min-height: 3.5rem !important;
height: 3.5rem;
padding-block: 0.125rem;
padding-inline: 0;
background-color: transparent !important;
border: none;
box-shadow: none;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.45;
resize: none;
}
}
// The toolbar row inside the box, along its bottom edge.
.comment-form-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
gap: 0.25rem;
margin-top: 0.25rem;
// Qualified with `[nbButton]` so these out-rank Nebular's own `.size-tiny`
// paddings (0,2,0) rather than tying with them; the file-wide
// `button { margin: 5px }` is the only thing that would otherwise space them,
// and the flex `gap` owns that here.
button[nbButton] {
margin: 0;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
font-size: 0.6875rem;
line-height: 1rem;
}
& .col-6:nth-child(1) {
padding-top: 15px;
color: var(--gauzy-text-color-2);
// Nebular gives an icon beside a label its own margin; the `gap` above is the
// spacing, so that margin has to go.
button[nbButton] nb-icon {
width: 0.75rem;
height: 0.75rem;
margin: 0;
font-size: 0.75rem;
}
}
& textarea {
font-weight: 400;
}
.comment-thread .comments {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
// `scroll` reserved a scrollbar gutter on the History tab even when an invoice
// has one comment or none, so the empty panel showed a dead scrollbar track.
overflow-y: auto;
// A trackpad flick that reaches the end of the thread should not then scroll the
// page behind it.
overscroll-behavior-y: contain;
// Keeps the last character of a comment off the scrollbar.
@include nb-ltr(padding-right, 0.25rem);
@include nb-rtl(padding-left, 0.25rem);
}
.comment-list {
display: flex;
flex-direction: column;
gap: 0.625rem;
margin: 0;
padding: 0;
list-style: none;
}
// One rule between comments and no fill behind them: the thread is a list of
// entries on the panel, not a stack of cards, and a hairline separates two of them
// with far less weight than a filled block each. The last entry drops its rule so
// the list does not end on a line with nothing under it.
.comment-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding-bottom: 0.625rem;
border-bottom: thin solid var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
&:last-child {
padding-bottom: 0;
border-bottom: none;
}
}
// Smaller here than on the composer: in the thread the avatar identifies a line of
// text, while on the composer it sits against a full-height input.
.comment-item .comment-avatar {
width: 1.25rem;
height: 1.25rem;
font-size: 0.5625rem;
}
.comment-body {
flex: 1 1 auto;
min-width: 0;
}
.comment-byline {
display: flex;
align-items: baseline;
gap: 0.375rem;
}
// The name, and where it is a link, a link that does not announce itself as one
// until you reach for it — an underline on every name would turn the thread into a
// column of blue rules.
.comment-author {
overflow: hidden;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.125rem;
color: var(--gauzy-text-color-1);
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
a.comment-author {
cursor: pointer;
&:hover,
&:focus-visible {
color: nb-theme(color-primary-500);
text-decoration: underline;
}
}
// Relative age ("3 hours ago"), with the exact timestamp on the tooltip — the
// trade every comment thread makes, and what lets the byline sit on one line
// instead of the three stacked rows this used to be.
.comment-time {
flex: 0 0 auto;
@include nb-ltr(margin-left, auto);
@include nb-rtl(margin-right, auto);
font-size: 0.6875rem;
line-height: 1.125rem;
color: var(--text-hint-color);
cursor: default;
}
// The line under the name, the way a thread puts an author's subtitle there: one
// step down in size and weight from the name, and muted, so the eye goes name →
// comment and picks this up only on the way past. It carried the name's own weight
// before, which made every entry read as two headings stacked.
.comment-subject {
font-size: 0.6875rem;
font-weight: 400;
line-height: 1.125rem;
color: var(--text-hint-color);
overflow-wrap: anywhere;
&::first-letter {
text-transform: uppercase;
}
}
// `pre-wrap` so a comment keeps the line breaks its author typed — the old single
// `<span>` collapsed them into one run — and `overflow-wrap` so an unbroken URL or
// reference number cannot push the entry wider than its column.
.comment-text {
margin: 0.3125rem 0 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--gauzy-text-color-1);
white-space: pre-wrap;
overflow-wrap: anywhere;
&::first-letter {
text-transform: uppercase;
}
}
// Both panels' resting state: no invoice picked yet (composer) and no comments on
// the one that is (thread). The old markup rendered nothing at all in either case,
// so the History tab opened as two empty boxes with nothing to say which.
.comment-empty {
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 1rem 0.75rem;
text-align: center;
color: var(--text-hint-color);
nb-icon {
width: 1.25rem;
height: 1.25rem;
margin-bottom: 0.125rem;
font-size: 1.25rem;
opacity: 0.55;
}
& .col-6:nth-child(2) {
background: var(--gauzy-card-1);
border-radius: var(--border-radius);
padding: 15px 6px 0 15px;
span {
max-width: 32ch;
font-size: 0.6875rem;
line-height: 1.125rem;
}
.comment-empty-title {
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.25rem;
color: var(--gauzy-text-color-2);
& nb-list-item {
padding-left: 0;
position: relative;
& .history-item.history-date {
color: var(--text-hint-color);
margin-top: 3px;
}
& .history-item.history-title {
margin-top: 10px;
text-transform: capitalize;
font-weight: bold;
}
& .history-item.history-comment {
margin-top: 6px;
font-size: 0.85rem;
&.history-comment::first-letter {
text-transform: capitalize;
}
}
}
// border bottom
& nb-list-item::after {
content: '';
display: block;
width: 100%;
border-bottom: thin solid var(--accordion-header-border-color);
position: absolute;
bottom: 0;
}
}
}
@@ -659,12 +1113,45 @@ nb-tab {
font-size: 0.75rem;
}
// Submit / reset sit in a bare `.row`; the file-wide `button { margin: 5px }`
// is the only thing spacing them.
button {
// The file-wide `button { margin: 5px }` near the top was the only thing
// spacing anything in this form.
margin: 0.25rem;
font-size: 0.75rem;
}
// ── Submit / reset ───────────────────────────────────────────────────────
// The two used to be `<div>`-wrapped items in a bare `.row`, left-aligned
// under the first filter with their leading gap coming from an `ml-3` on the
// submit button — so they read as a third row of fields rather than as the
// form's actions. The wrappers are gone (the buttons are the flex items now)
// and the pair closes the form off against the same edge the filter row ends
// on.
.search-actions {
justify-content: flex-end;
align-items: center;
// `.row`'s own negative gutters, which would hang the buttons past the
// accordion's padding on the right.
margin-inline: 0;
// Clear of the filter row above — with the fields' own 0.25rem underneath
// them that comes to 0.75rem: enough to read as the form's footer without
// spending height the table below is short of.
margin-top: 0.5rem;
gap: 0.375rem;
// The compact button scale the page header already runs on this page (see
// the `page-header-buttons()` includes further up), instead of Nebular's
// stock `small` box — that is what takes the size down, and it puts the
// form's actions on the same scale as the toolbar above the table rather
// than a step larger than everything around them.
@include ga-hub.page-header-buttons();
button {
// The row's `gap` spaces them now, so neither the file-wide 5px nor the
// 0.25rem above it should still be adding to the ends of the row.
margin: 0;
}
}
}
}
@@ -738,13 +1225,167 @@ nb-tab {
padding: 0.375rem 0.5rem;
}
// ── Search form: controls sized to their content ─────────────────────────────
// The fields are laid out on a Bootstrap grid (`col-sm-4` / `-3` / `-2`) and every
// control carries `fullWidth`, so each one stretched to a third or a quarter of
// the card — a number field nearly 500px wide for a 6-digit estimate number.
// Capping the COLUMN rather than the control keeps the grid's wrapping and order
// intact (the cap only bites where the percentage is the larger of the two) and
// carries the label above the field with it.
:host .search-tab nb-accordion .row > [class*='col-'] {
max-width: 14rem;
// ── Search form: all eight filters on one line ───────────────────────────────
// The fields used to sit on two Bootstrap rows (`col-sm-4` / `-3` / `-2`) with
// every control carrying `fullWidth`, so each stretched to a third or a quarter
// of the card — a number field nearly 500px wide for a 6-digit estimate number —
// and the form stood two field-rows tall above a table that is already short on
// height. They are now one `.search-filters` row: equal-width flex columns that
// are allowed to shrink, which is what fits all eight on a single line instead of
// letting Bootstrap's percentage widths wrap them.
:host .search-tab nb-accordion .search-filters {
flex-wrap: nowrap;
// Tops aligned, so the labels — and therefore the controls under them — share
// one baseline across the row.
align-items: flex-start;
margin-inline: 0;
// Replaces the columns' own 0.75rem gutters, halved: eight fields on one line
// are better off spending that width on the controls.
gap: 0.5rem;
> .search-filter-col {
// `flex-basis: 0`, not `auto`, so the eight share the line evenly rather than
// each claiming its content width.
flex: 1 1 0;
// A flex item's floor is `min-content` by default, and for a number input or
// a select that is wider than an eighth of the card — without this the row
// overflows the accordion instead of shrinking.
min-width: 0;
max-width: none;
padding-inline: 0;
}
// ── The one control that did not obey that ─────────────────────────────────
// Nebular's own `nb-select-theme()` floors every trigger at
// `nb-theme(select-min-width)`, which resolves to 6rem in all eight registered
// themes (see the note on `.select-nb` higher up for why that includes the two
// the app ships on). Eight of those side by side need 8 x 6rem + 7 x 0.5rem =
// 824px of ROW before a column is wide enough to hold one — well past the point
// where the card itself is 824px wide, and far past the 768px at which this row
// is allowed to wrap. Between the two, the Status trigger kept a floor its
// column could not give it and hung out past the accordion, taking the fields
// beside it with it.
//
// Unset rather than lowered: the premise of the row is that the eight columns
// divide the line evenly and shrink together, and a floor on one of them is the
// one thing that cannot hold. This is the same treatment `_overrides.scss`
// applies to selects inside `.card-scroll`. `!important` because that same file
// restates the floor with one of its own under `.nb-theme-default`; under every
// other theme the specificity here would have been enough on its own.
::ng-deep nb-select .select-button {
min-width: 0 !important;
// Nebular gives the button `text-overflow: ellipsis`, but `[nbButton]` is an
// `inline-flex` container, so that applies to nothing — the label is a child
// `<span>`, and a flex item will not shrink below its content unless it is
// told it may. Without the pair, a status name at an eighth of the card is
// cut off mid-glyph by the button's own `overflow: hidden`. Same treatment
// the `ng-select` triggers in this row get below.
> span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
// ── The labels, so the controls under them start at one height ─────────────
// Every label in the row is pinned to the same type size, line box and bottom
// margin. Two of the eight are drawn by child components (`ga-currency`,
// `ga-tags-color-input`), which the `.label` rule higher up in this file —
// scoped, no `::ng-deep` — never reached: those two kept Nebular's default
// 0.5rem gap under the label while the six from this template had 0.1875rem,
// so their inputs sat several pixels lower than the rest. That, and not the
// grid, is what stopped the fields lining up.
//
// The single-line clamp is the other half: the labels differ a lot in length
// ("Currency" against "Organization contact"), and at an eighth of the card the
// long ones wrapped to a second line and pushed their input a whole line down.
::ng-deep .label {
display: block;
margin-bottom: 0.1875rem;
font-size: nb-theme(text-label-font-size);
line-height: 0.8125rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// Same reach: `ga-currency` renders its own `.form-group` wrapper.
::ng-deep .form-group {
margin-bottom: 0.25rem;
}
// ── One type size across the row ───────────────────────────────────────────
// The eight filters are four different widgets — a plain `nbInput`, two
// datepicker inputs, three `ng-select`s (contact, currency, tags) and the
// `nb-select` for status — and each arrives with its own size: `ng-select`
// reads `select-medium-text-font-size` from `ng-select-overrides`, a selected
// tag is an `nb-badge` at `text-caption-font-size`, and a bare input inherits
// the paragraph size. On two rows that was easy to miss; on one line it reads
// as four different fields.
//
// Status is the one that was already right (`#inputStatus .select-button`
// higher up in this file), so its 0.75rem is the row's size. Set on each text
// surface rather than left to inherit, because the library sheets put a size
// on the descendants themselves.
::ng-deep {
input,
input::placeholder,
.ng-select,
.ng-select .ng-select-container,
.ng-select .ng-value-container,
.ng-select .ng-value,
.ng-select .ng-value-label,
.ng-select .ng-placeholder,
.ng-select .ng-input > input,
nb-select .select-button,
nb-select .select-button.placeholder,
nb-tag-list input,
.tag-label,
.tag-overflow {
font-size: 0.75rem;
}
// At an eighth of the card a contact or currency name is routinely longer
// than its trigger. `ng-select`'s own container clips it, which cuts the text
// mid-glyph; a flex item will not shrink below its content unless told it
// may, so both halves are needed for an ellipsis to engage instead.
.ng-select.ng-select-single .ng-select-container .ng-value-container {
min-width: 0;
}
.ng-select.ng-select-single .ng-select-container .ng-value {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
// The two date fields park a calendar glyph inside the control (`.input-icon
// .icon`). At full width the placeholder never reached it; at an eighth of the
// card it runs underneath, so the glyph is pulled in tight against the edge and
// the text gets the room that buys.
.input-date {
padding-inline-end: 1.5rem;
}
.input-icon .icon {
@include nb-ltr(right, 0.5rem);
@include nb-rtl(left, 0.5rem);
}
}
// One line is the point of the row, so it holds for every desktop width — the
// columns shrink instead of wrapping. Below the tablet breakpoint eight controls
// on a line stop being controls at all, so there the row wraps in the same order,
// a few filters per line.
@media (max-width: 767.98px) {
:host .search-tab nb-accordion .search-filters {
flex-wrap: wrap;
> .search-filter-col {
flex: 1 1 9rem;
}
}
}
@@ -35,6 +35,7 @@ import {
PermissionsEnum,
ICurrency,
IInvoiceItemCreateInput,
IUser,
InvoiceTabsEnum,
DiscountTaxTypeEnum,
IDateRangePicker
@@ -796,6 +797,80 @@ export class InvoicesComponent extends PaginationFilterBaseComponent implements
}
}
/**
* The user the History tab composes comments as. Read through a getter rather
* than a field so the avatar follows a mid-session user switch, and so the
* template does not have to reach into the private `store`.
*/
get currentUser(): IUser {
return this.store.user;
}
/**
* Whether a comment author's name should be a link to their profile.
*
* `/pages/users/edit/:id` is guarded by `ORG_USERS_EDIT`, and its guard
* redirects to the dashboard rather than refusing — so without this check a
* viewer who lacks the permission would click a name and silently land on a
* different page. They get plain text instead.
*/
get canOpenUserProfile(): boolean {
return this.ngxPermissionsService.getPermission(PermissionsEnum.ORG_USERS_EDIT) != null;
}
/**
* Initials fallback for a comment author with no `imageUrl`. The shared
* `ngx-avatar` renders nothing at all when its `src` is empty, which left a
* hole where the avatar should be for every user who never uploaded a photo —
* the common case on a fresh workspace.
*
* @param name - the author's display name
* @returns up to two upper-cased initials, or `?` when there is no name
*/
authorInitials(name: string): string {
const initials = (name || '')
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part: string) => part.charAt(0))
.join('');
return initials ? initials.toUpperCase() : '?';
}
/**
* Relative age of a comment ("3 hours ago"), the way every comment thread
* dates its entries. The exact timestamp stays available on the tooltip, so
* nothing is lost by not printing it inline.
*
* `createdAt` arrives here as the `Date.toString()` slice built in
* `selectInvoice`, not as an ISO string, so it goes through `new Date` first
* rather than moment's (deprecated) free-form string parser.
*
* A record with no usable date renders nothing rather than moment's literal
* "Invalid date": the line is a subtitle beside the author's name, and a blank
* one reads as "no timestamp" while that string reads as a broken comment. The
* tooltip beside it is already empty in the same case.
*
* @param createdAt - the history record's creation date
* @returns a humanized, locale-aware distance from now, or an empty string
*/
commentTimeAgo(createdAt: string | Date): string {
const parsed = new Date(createdAt);
return isNaN(parsed.getTime()) ? '' : moment(parsed).fromNow();
}
/**
* Clears the comment composer without submitting it.
*
* @param historyFormDirective - the composer's `ngForm`, reset alongside the
* form group so the controls drop their touched/dirty state too
*/
resetComment(historyFormDirective): void {
historyFormDirective.resetForm();
this.historyForm.reset();
}
async addComment(historyFormDirective) {
if (this.historyForm.invalid) {
return;
@@ -806,8 +881,7 @@ export class InvoicesComponent extends PaginationFilterBaseComponent implements
const action = comment;
await this.createInvoiceHistory(action, title);
historyFormDirective.resetForm();
this.historyForm.reset();
this.resetComment(historyFormDirective);
const invoice = await this.invoicesService.getById(invoiceId, [
'invoiceItems',
@@ -901,6 +975,11 @@ export class InvoicesComponent extends PaginationFilterBaseComponent implements
return +new Date(b.createdAt) - +new Date(a.createdAt);
});
this.histories = histories;
} else {
// Deselecting used to leave the previous invoice's comments standing in
// the History tab. The thread is now guarded on `selectedInvoice`, but
// dropping the records keeps the count badge honest either way.
this.histories = [];
}
}
@@ -1 +1,10 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// Density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager's `<nav><ul><li>` and the toolbar scale — the same
// block Invoices carries. The action row here is `ngx-gauzy-button-action`
// inside `nb-card-header`, which the default `$actions` selector covers: the
// tokens are custom properties, so they cross into that component on their own.
@include ga-hub.list-page();
@@ -1,4 +1,5 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
.card-header-title {
@@ -15,3 +16,10 @@
overflow: auto;
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block the Pipelines list this page drills into already carries:
// density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager and the toolbar scale. Without it a pipeline's
// deals were drawn a size larger than the pipeline list one click behind them.
@include ga-hub.list-page();
@@ -185,8 +185,15 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements
* Sets up the initial view configuration and subscribes to changes in the component layout.
*/
setView() {
// Set the default view component name to PROPOSALS
this.viewComponentName = ComponentEnum.PROPOSALS;
// `PIPELINES`, not `PROPOSALS`. This page registered itself under the
// Proposals key, and `job-proposal-ui`'s own proposal page registers the
// SAME key — so the two shared one stored layout preference. Setting
// Proposals to the card view silently switched Pipelines to `ga-card-grid`
// as well, which is why this page could render as cards while every style
// written for its table was correct and simply never applied; toggling the
// layout on either page also moved the other. `ComponentEnum.PIPELINES` was
// declared for this page and, until now, referenced nowhere.
this.viewComponentName = ComponentEnum.PIPELINES;
// Subscribe to changes in the component layout
this.store
@@ -1,6 +1,17 @@
@for (stage of pipelineStages; track stage) {
<div>
<div>{{stage.name}}</div>
<div>{{stage.description}}</div>
<!--
Only when there is one, and `trim()` because a blank string is not one. Most
stages carry no description, and an element emitted unconditionally is an
empty flex item holding the chip's gap open on its trailing edge — a chip
wider than its own text. A whitespace-only value looks identical on screen
and is truthy, so a bare `@if` would let exactly that case back through.
Dropping the node is what the stylesheet's `:nth-child(2)` description rule
expects; see the note there.
-->
@if (stage.description?.trim()) {
<div>{{stage.description}}</div>
}
</div>
}
@@ -1,18 +1,81 @@
div > div {
&:first-child {
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 15px;
letter-spacing: 0em;
text-align: left;
}
&:last-child {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 15px;
letter-spacing: 0em;
text-align: left;
}
// ── A pipeline's stages, as chips on one line box ────────────────────────────
//
// WHAT THIS REPLACES: a bare `div > div` block setting 12px/15px on each of the
// two lines the template emits per stage — a name line and a description line —
// with the stages themselves left as plain blocks, stacked. So the cell cost the
// row 30px PER STAGE and nothing capped it: a pipeline with five stages made a
// 150px row, in a table whose other three columns are a string, a string and a
// 1rem status pill. It was the one cell on this page that read no density tokens
// at all, and the only reason a Pipelines row did not measure what an Invoices
// row does.
//
// The treatment is the app's existing answer to "several small labels in one
// cell" — the tag chips `ga-notes-with-tags` renders — rather than anything new:
// the same `--gauzy-table-chip-*` scale, the same `--gauzy-table-badge-*` box,
// so a stage chip and the status pill in the very next column are drawn alike.
//
// Chips WRAP rather than truncating to one line with an overflow count (the
// `ngx-people-list` treatment). A pipeline's stages are the payload of this
// column — the thing the row is read for — so a tall-ish cell on a pipeline with
// many stages is the honest outcome; what is fixed is that each stage now costs
// one chip instead of two full-size text lines.
//
// Plain custom properties with literal fallbacks, no `@use 'themes'`: this sheet
// had no imports and needs none, and the tokens reach it by inheritance from the
// page's `:host` the same way every other cell renderer here is sized.
:host {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gauzy-table-chip-gap, 0.125rem);
min-width: 0;
}
// One stage. `inline-flex` with a baseline cross-axis so the name and its
// description sit on the same line inside the chip instead of stacking.
:host > div {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
max-width: 100%;
min-width: 0;
min-height: var(--gauzy-table-badge-height, 1rem);
padding-block: var(--gauzy-table-badge-padding-y, 0.1875rem);
padding-inline: var(--gauzy-table-chip-padding-x, 0.3125rem);
border-radius: var(--gauzy-table-badge-radius, 0.25rem);
background: var(--gauzy-sidebar-background-3, rgba(126, 126, 143, 0.1));
}
// The stage name, and the description after it. Both truncate on WIDTH, so a
// short one renders in full and only a genuinely long one picks up an ellipsis —
// a stage name is short and a description is not, and it is the description that
// would otherwise push the chip past the column.
:host > div > div {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--gauzy-table-chip-font-size, 0.625rem);
line-height: var(--gauzy-table-chip-line-height, 0.75rem);
letter-spacing: 0em;
text-align: left;
}
:host > div > div:first-child {
flex: 0 1 auto;
font-weight: 600;
}
// `:nth-child(2)`, not `:last-child`. The template now emits the description
// element only when the stage has a non-blank one (a `display: none` on `:empty`
// used to do that job, which leant on a zero-length interpolation text node still
// counting as empty and could not catch a whitespace-only description at all).
// With the node genuinely absent, the NAME is both first and last child, and a
// `:last-child` rule at this same specificity would come after the `:first-child`
// one above and paint every description-less stage muted and unbolded.
:host > div > div:nth-child(2) {
flex: 1 1 auto;
max-width: 12rem;
font-weight: 400;
color: var(--gauzy-text-color-2, var(--text-hint-color));
}
@@ -38,25 +38,40 @@
// attributes and a class before anything else, which clears that outright — no
// `!important` anywhere below.
:host .table-scroll-container ::ng-deep angular2-smart-table {
// ── Project cell ───────────────────────────────────────────────────────────
// `gauzy-project-organization` draws a 28px logo beside a 14px/17px name and an
// 11px/13px caption, i.e. a 28px chip in rows the density tokens build to a
// 1rem line box — the single biggest reason a row here stood taller than the
// same row on Employees or Payments. Sized off the shared `gauzy-people-*`
// tokens, so the project chip matches the people cells beside it.
// ── Project and Contact cells ──────────────────────────────────────────────
// Two blocks used to sit here, shrinking `gauzy-project-organization`'s logo
// to 1rem and `ngx-contact-links`' avatar and pill to about 20px of chip, so
// that each cost the row a single 1rem line box.
//
// Both are gone. `ngx-contact-links` is the very renderer the Invoices Contact
// column draws — an 18px avatar and a 12px/15px name in a pill padded 5px, a
// 28px chip — so overriding it here was this page setting itself BELOW the
// table it is meant to match, using the identical component. Left alone it is
// now exact by construction.
//
// The project logo comes back to the `gauzy-people-*` scale rather than to its
// own 28px: those tokens are the restored 1.25rem/0.75rem, which puts the logo
// and the name on the same line box as the people chip in the next column.
.project-render {
gap: var(--gauzy-people-gap, 0.375rem);
gap: var(--gauzy-people-gap, 0.5rem);
img {
width: var(--gauzy-people-avatar-size, 1rem);
height: var(--gauzy-people-avatar-size, 1rem);
// A 35 %-black drop shadow under a 16px thumbnail is a smudge, not a lift.
width: var(--gauzy-people-avatar-size, 1.25rem);
height: var(--gauzy-people-avatar-size, 1.25rem);
// A 35 %-black drop shadow under a small thumbnail is a smudge, not a lift.
box-shadow: none;
}
.name {
font-size: var(--gauzy-people-font-size, 0.6875rem);
line-height: var(--gauzy-people-avatar-size, 1rem);
font-size: var(--gauzy-people-font-size, 0.75rem);
// The TABLE line box, not the avatar size. The name is one line of a stack,
// not a line box that has to clear an avatar beside it: a project with a
// member count puts this over `.member`, so a 1.25rem name and a 0.75rem
// caption add up to 2rem of cell — taller than the 28px `ngx-contact-links`
// chip in the next column, which is the row height this page is being
// brought to. At 1rem the pair comes to exactly that 28px, and the logo
// beside them still gets its own 1.25rem box.
line-height: var(--gauzy-table-line-height, 1rem);
}
span .member {
@@ -65,38 +80,6 @@
}
}
// ── Contact cell ───────────────────────────────────────────────────────────
// `ngx-contact-links` draws an 18px avatar and 12px/15px text inside a pill
// padded 5px/14px — a 28px control in a 1rem row. Same scale-down as the
// people chips, and the pill keeps its tint so the cell still reads as a link
// rather than as plain text.
ngx-contact-links .inner-wrapper {
gap: var(--gauzy-people-gap, 0.375rem);
@include nb-ltr(padding, 0.125rem 0.375rem 0.125rem 0.125rem);
@include nb-rtl(padding, 0.125rem 0.125rem 0.125rem 0.375rem);
.names-wrapper {
max-width: var(--gauzy-people-name-max-width, 8rem);
}
.link-text {
font-size: var(--gauzy-people-font-size, 0.6875rem);
line-height: var(--gauzy-people-avatar-size, 1rem);
}
.avatar img,
.prefix {
width: var(--gauzy-people-avatar-size, 1rem);
height: var(--gauzy-people-avatar-size, 1rem);
}
.prefix {
padding: 0;
font-size: var(--gauzy-people-initials-font-size, 0.5rem);
line-height: var(--gauzy-people-avatar-size, 1rem);
}
}
// ── Public / Private cell ──────────────────────────────────────────────────
// The cell is an `nb-toggle`, which Nebular sizes entirely from theme tokens:
// a 50x30 switch with a 28px knob and a 0.8125rem label on a 1.5rem line — the
@@ -1,4 +1,5 @@
@use 'gauzy/_gauzy-table' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// Removed `.setting-name` (font-size 24px / bold) and `.body-header` (flex row,
// margin-bottom 35px). This stylesheet is emitted for exactly two templates —
@@ -103,8 +104,10 @@
// `.setting-name` and `.body-header`, matched nothing in either template that
// uses this stylesheet.
//
// The rows are laid out with fixed column widths (~57rem), so this really does
// need to scroll sideways on a narrow viewport; what it does NOT need is
// The rows are laid out with fixed column widths — about 41rem for the employee
// list and 28rem for the organization one, off the `--gauzy-recurring-col*`
// properties at the foot of this file — so this can still need to scroll
// sideways on a narrow viewport; what it does NOT need is
// `overflow-x: scroll`, which reserves a scrollbar gutter unconditionally. With
// `auto` the bar only exists when there is something to scroll, and the two
// hide-the-bar declarations now cover Firefox as well as WebKit rather than
@@ -130,3 +133,147 @@
align-items: center;
gap: 8px;
}
// ── The list-page contract ───────────────────────────────────────────────────
// Both Recurring Expenses pages take this block: Employees > Recurring Expenses
// directly, and Organization > Recurring Expenses through the `@forward` in
// `expense-recurring.component.scss`. The two templates differ by one column and
// are otherwise the same markup.
//
// That markup is a list built BY HAND — a `.sub-header` band of column labels
// over a `.table-scroll` of `ga-recurring-expense-block` rows — so neither
// `list-page()` (anchored on `angular2-smart-table`) nor `card-list-page()`
// (anchored on `.table-scroll-container`) ever reached it. What that left: 84px
// rows, each a separately floating card with its own fill, radius and 8px bottom
// margin, 14px values under a 0.75rem-padded `gauzy-card-3` label slab, and a
// toolbar at full size — the last two lists in these sections still drawn the
// way the whole app was before the density pass.
//
// The row component is styled from its own sheet rather than reached with
// `::ng-deep` from here: `ga-recurring-expense-block` has exactly two consumers
// and they are the two pages this file serves, so there is no third caller for a
// page-scoped override to protect. It reads the density tokens below, which
// inherit into it — the same way `ngx-people-list` and `ngx-created-by-user`
// already size themselves.
:host {
@include ga-hub.density-tokens();
// ── The three metrics the label band and the rows MUST agree on ────────────
// They are custom properties, and on `:host`, for one reason: the labels are
// in this component's template and the values are in
// `recurring-expense-block.component.scss`, so a literal would have to be
// written twice in two files and kept in step by hand. That is exactly how the
// page arrived here — `42px`, `11rem` and `17.5rem` were each spelled out in
// both places.
//
// The inset was 42px, room for a selection rail and a settings fly-out; the
// rail is 3px now (see below) and the fly-out is `display: none`.
--gauzy-recurring-row-inset: 0.5rem;
--gauzy-recurring-col: 8rem;
--gauzy-recurring-col-big: 12rem;
}
:host nb-card-header {
@include ga-hub.page-header-buttons();
}
// ── One surface, around the labels AND the rows ──────────────────────────────
// On `.container` rather than on `.table-scroll`, which is the only interesting
// choice here: the label band is a `thead` in everything but markup, and a
// surface that started below it would have left the header outside the frame it
// labels. `.container` is the one element that wraps both.
//
// `.table-scroll` inside it keeps the scrolling, in both axes, exactly as before;
// the ring is on the box around it, so it stays put rather than scrolling with
// the rows.
:host .container {
@include ga-hub.surface();
// A flex column, so the list below can take exactly the room this surface has
// rather than guessing at it — see the note on `.table-scroll`.
display: flex;
flex-direction: column;
padding: 0;
}
// ── The label band, as the table header it already is ────────────────────────
// It was a `gauzy-card-3` slab with a radius and 0.75rem of padding, i.e. a
// second surface sitting on the first, with labels at the body size. Now: the
// header type scale, the header padding, and the hairline that separates a
// `thead` from the rows under it.
//
// The transparent 3px leading edge matches the selection rail the rows reserve,
// so a label and the value under it start at the same x — the alignment the old
// pair of hand-matched 18px / 42px insets was trying to hold.
:host .sub-header.header-content {
// The fixed part of the column: a `thead` is not what should give way when the
// card is short.
flex: 0 0 auto;
margin: 0;
padding: var(--gauzy-table-header-padding-y, 0.3125rem) var(--gauzy-table-header-padding-x, 0.4375rem);
border-inline-start: 3px solid transparent;
border-radius: 0;
background-color: transparent;
box-shadow: inset 0 -1px 0 0 var(--gauzy-overlay-border-color, rgba(126, 126, 143, 0.18));
font-size: var(--gauzy-table-header-font-size, 0.75rem);
font-weight: 600;
line-height: var(--gauzy-table-header-line-height, 0.8125rem);
color: var(--gauzy-text-color-2, var(--text-hint-color, rgba(113, 113, 122, 1)));
}
// SPECIFICITY: the rules these replace are `[dir=ltr]` / `[dir=rtl]` pairs at
// (0,3,0), so naming both classes on the band is what carries these past them.
:host .sub-header.header-content .header-info {
@include nb-ltr(padding-left, var(--gauzy-recurring-row-inset));
@include nb-rtl(padding-right, var(--gauzy-recurring-row-inset));
}
:host .sub-header.header-content .block .block-item {
width: var(--gauzy-recurring-col);
}
:host .sub-header.header-content .block .block-item-big {
width: var(--gauzy-recurring-col-big);
}
// ── The scroll port ──────────────────────────────────────────────────────────
// The 18px leading pad was there to line the cards up under the label band's own
// 18px margin; both are gone, and the rows now carry their inset themselves.
//
// `height: calc(100vh - 23.5rem)` goes with them. That number was an estimate of
// the chrome above this list, and the label band is a good deal shorter than it
// was when the estimate was taken — the list would now stop several rem short of
// the surface's bottom edge, leaving a band of empty ring under the last row. As
// a flex child it takes exactly the room the surface has, which is both accurate
// and immune to the next change above it; `min-height: 0` because a flex child
// that is itself a scroll port defaults to `min-height: auto` and would
// otherwise refuse to shrink below its content.
:host .table-scroll {
flex: 1 1 auto;
min-height: 0;
height: auto;
padding: 0;
}
// The Employee column's avatar chip, at the Invoices scale. `ngx-avatar` pins a
// 20px image inside a pill padded 3px — 26px of chip, 2px short of the 28px the
// Invoices Contact column costs its row — so only that inset is restated; the
// image and the name are the renderer's own.
//
// Reached through `.table-scroll` for the specificity: the avatar nests its
// rules under `:host-context(.report-table)`, which shims to (0,4,0), and that
// wrapper — this component's own element, so it carries this component's content
// attribute — is what takes the block past it without `!important`.
:host .table-scroll ::ng-deep {
@include ga-hub.avatar-chip();
}
// The empty state stands in for the list INSIDE that frame now, rather than
// floating on the card body, so it must not be taller than the box framing it.
// `max-height: 100%` bounds it wherever the parent has a definite height and
// resolves to `none` where it does not — the same treatment `.card-scroll` gets
// in `styles/_overrides.scss`.
:host .container .no-data {
max-height: 100%;
}
@@ -134,12 +134,22 @@ nb-card-body {
// attribute. The doubled classes make up the difference, the same idiom the
// Invoices and Pipelines sheets use on `.tab-link.tab-link`.
gauzy-task-badge-view .badge-color.badge-color {
height: var(--gauzy-table-badge-height, 1rem);
padding: var(--gauzy-table-chip-padding-y, 0) var(--gauzy-table-chip-padding-x, 0.3125rem);
// Same box as `ga-status-badge` now draws, from the same two tokens: the
// pills in the Status column and the pills every other list page shows were
// otherwise a different height and a different corner from each other.
//
// `min-height` rather than `height`, and `padding-block` rather than a
// padding shorthand with 0 in the block axis. The old pair pinned a 1rem box
// and then spent nothing on vertical padding — which it could not have done
// anyway, since under `border-box` padding inside a pinned height comes out
// of the text's box rather than adding to the pill.
min-height: var(--gauzy-table-badge-height, 1rem);
padding-block: var(--gauzy-table-badge-padding-y, 0.1875rem);
padding-inline: var(--gauzy-table-chip-padding-x, 0.3125rem);
gap: var(--gauzy-table-chip-gap, 0.125rem);
font-size: var(--gauzy-table-header-font-size, 0.75rem);
line-height: var(--gauzy-table-header-line-height, 0.8125rem);
border-radius: var(--gauzy-radius-sm, 0.375rem);
border-radius: var(--gauzy-table-badge-radius, 0.25rem);
.badge-img.badge-img {
width: var(--gauzy-table-header-font-size, 0.75rem);
@@ -199,6 +209,19 @@ nb-card-body {
// The page-header action row: "+ Add", the selection strip and the grid-vs-list
// selector, compacted to the table's scale (see the mixin).
:host .gauzy-button-container {
//
// `nb-card-header`, not `.gauzy-button-container` — which is what this was, and
// is the same mistake the mixin's own doc comment warns about. This header holds
// TWO button groups: the toolbar in `.tasks-component__settings >
// .gauzy-button-container`, and the project-settings gear, which is a bare
// `<button nbButton size="small">` sitting in `.card-header-title` beside the
// heading. Naming only the container left that gear at its full 0.5625rem box
// next to a toolbar that had shrunk to 0.25rem, so the one control the eye lands
// on first was the one still drawn oversized.
//
// Every metric the row needs is a CUSTOM PROPERTY, so one declaration on the
// header reaches both groups — `ngx-gauzy-button-action`'s own template and
// `ga-layout-selector` included, across their component boundaries.
:host nb-card-header {
@include ga-hub.page-header-buttons();
}
@@ -9,41 +9,19 @@
// one still drawn a size larger than its neighbours.
@include ga-hub.list-page();
// ── Row breathing room ───────────────────────────────────────────────────────
// ── No step up from the shared cell box ──────────────────────────────────────
//
// `density-tokens()` sizes a row for a line of TEXT: 1rem of line box plus
// 0.1875rem above and below. Text is the right thing to size that box around
// because a glyph only ever fills about two thirds of its line box, so the row
// still reads as padded while sitting at the floor.
// This page used to raise `--gauzy-table-cell-padding-*` to 0.4375rem/0.625rem
// and the header pair with it, because half its columns are people and, at the
// time, a person was pinned to a 1rem avatar — a disc that came within 3px of
// the rule above and the rule below it in ten consecutive rows.
//
// Half of this table's columns are people — Managers and Members, the two
// widest — and a person renders as an AVATAR, which fills its line box edge to
// edge: 1rem of disc in a 1rem box. At the shared floor those discs came within
// 3px of the rule above and the rule below them, in ten consecutive rows, which
// is what made a page of people read as one solid block with no rhythm for the
// eye to track along.
//
// 0.4375rem instead, which is the same ~30px row the rest of the section lands
// on once its text cells are padded (Tags runs 0.375rem around a 1rem line box)
// — the extra 1px a side here is the avatar being taller than the text it sits
// beside, not this page choosing a different scale.
//
// The gutter comes up to the app-wide 0.625rem with it: at 0.4375rem the Name
// column's first character sat against the surface's own ring, and the Tags
// column — the one the header's filter funnel hangs over — had less clear space
// than the chips inside it.
//
// Scoped to this page rather than pushed into `density-tokens()`: the shared
// values are what two dozen other tables are drawn at, and the avatar columns
// are what justifies the step.
:host {
--gauzy-table-cell-padding-y: 0.4375rem;
--gauzy-table-cell-padding-x: 0.625rem;
// The heading row with them, or the header ends up shorter than the rows it
// heads.
--gauzy-table-header-padding-y: 0.4375rem;
--gauzy-table-header-padding-x: 0.625rem;
}
// The height is in the chip now rather than in the cell: `ngx-people-list` is
// back at a 1.25rem avatar with `--gauzy-people-chip-padding-y` around it, which
// is the same 28px the Invoices Contact chip costs its row. So a `<td>` here is
// the Invoices `<td>` — 0.1875rem/0.4375rem around it — and the row lands at the
// same height from the same direction, instead of this one page holding a
// padding value of its own to make up the difference.
// ── The Tags cell ────────────────────────────────────────────────────────────
//
@@ -32,9 +32,11 @@
(userRowSelect)="selectTimeOffPolicy($event)"
></angular2-smart-table>
</div>
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
<div class="pagination-container">
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
</div>
}
<!-- Card Grid View -->
@case (componentLayoutStyleEnum.CARDS_GRID) {
@@ -1 +1,12 @@
@forward '@shared/_pg-card';
@use 'gauzy/_gauzy-table-hub' as ga-hub;
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale.
//
// The pager needed the `.pagination-container` wrapper the sibling list pages
// already had — the template rendered a bare `<ngx-pagination>` into the card
// body, so it was neither right-aligned nor compacted.
@include ga-hub.list-page();
@@ -89,9 +89,12 @@
#timeOffTable
></angular2-smart-table>
</div>
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
} }
<div class="pagination-container">
@if (smartTableSource) {
<ngx-pagination [source]="smartTableSource"></ngx-pagination>
}
</div>
}
<!-- Grid Card View -->
@case (componentLayoutStyleEnum.CARDS_GRID) {
<div class="custom-grid">
@@ -1,4 +1,5 @@
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
nb-radio-group {
display: flex;
@@ -46,3 +47,29 @@ nb-radio-group {
margin-right: 16px;
border-radius: nb-theme(border-radius);
}
// ── The canonical list-page block ────────────────────────────────────────────
// The same block Invoices carries: density tokens, the table surface, row
// hover/selection, the tag-chip stack, the status pills, the pager and the
// toolbar scale. The header holds `ngx-gauzy-button-action` plus this page's own
// filter strip and archive toggle, and the default `$actions` selector takes all
// of them together.
//
// The pager needed the `.pagination-container` wrapper the sibling list pages
// already had — the template rendered a bare `<ngx-pagination>` into the card
// body, so it was neither right-aligned nor compacted.
@include ga-hub.list-page();
// ── The Employee cell ───────────────────────────────────────────────────────
// `ga-picture-name-tags` renders `<ngx-avatar class="report-table">`, which pins
// a 20px image inside a pill padded 3px: 26px of chip, 2px short of the 28px
// `ngx-contact-links` costs the Invoices Contact column, and so 2px short of the
// Invoices row. Only that inset is restated — the image and the name stay the
// renderer's own 20px/12px — which takes the chip to the same 28px and the row
// to the same height.
//
// Table-scoped: `ga-card-grid` instantiates the same `renderComponent` for the
// card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
@@ -1,4 +1,5 @@
@use '@shared/_pg-card' as *;
@use 'gauzy/_gauzy-table-hub' as ga-hub;
:host {
nb-card-body {
@@ -23,3 +24,25 @@
width: 100%;
}
}
// ── The canonical list-page block ────────────────────────────────────────────
// Density tokens, the table surface, row hover/selection, the tag-chip stack,
// the status pills, the pager's `<nav><ul><li>` and the toolbar scale — the same
// block Invoices carries. The toolbar is `ngx-gauzy-button-action` in
// `nb-card-header`, which the default `$actions` selector covers.
@include ga-hub.list-page();
// The one renderer `list-page()` does not reach: the People column renders
// `PictureNameTagsComponent`, i.e. `<ngx-avatar class="report-table">`, whose own
// sheet pins a 20px image inside a pill padded 3px — 26px of chip, 2px short of
// the 28px `ngx-contact-links` costs the Invoices Contact column, and so 2px
// short of the Invoices row. `avatar-chip()` restates only that inset; the image
// and the name stay the renderer's own. Every other people table in the app
// (Employees, Candidates, Approvals, Time Off, Recurring Expenses) carries this
// include — Users was the table left 2px shorter than the contract it shares.
//
// Table-scoped: `ga-card-grid` instantiates the same `renderComponent` for the
// card layout, which is not paying for a table row.
:host .table-scroll-container ::ng-deep angular2-smart-table {
@include ga-hub.avatar-chip();
}
+13 -19
View File
@@ -176,28 +176,22 @@
// lays its row out in columns. A bare `.row` here would have redesigned three
// pages that were not asked about.
//
// THE CELL GUTTER, and why it is declared here rather than in the template.
//
// The cells used to carry Bootstrap's `p-0`. Every Bootstrap utility is
// `!important` — `.p-0 { padding: 0 !important }` — so no gutter could be added
// to these columns from a stylesheet at ANY specificity, and the grid had none:
// THE CELL GUTTER is Bootstrap's own. The cells used to carry `p-0`, and every
// Bootstrap utility is `!important` — `.p-0 { padding: 0 !important }` — so no
// gutter could be added to these columns from a stylesheet at ANY specificity:
// the website's ellipsis ended one pixel before the first tag chip began, and
// "Phone" and "Email" were two headings with nothing between them. The template
// drops `p-0`; this replaces it, which is what makes a per-edge value possible.
// drops `p-0`, which is what lets the grid's own COLUMN PADDING apply: on
// Bootstrap 4.6 (this app's version) a `.col-*` carries a fixed 15px either side
// and the `.row` pulls the outer pair back with a -15px margin. There is no
// gutter custom property to reach for here — `--bs-gutter-x` is Bootstrap 5 — so
// the value is the framework's and is changed by overriding the padding, not by
// setting a variable.
//
// Zero leading, 1rem trailing. A Bootstrap `.row` bleeds 15px past its container
// through negative margins, and the row's own 1.125rem gutter is sized to cover
// exactly that (see the `card-list-page()` call above) — so a leading pad would
// push the first cell off the edge that establishes, and the last column needs
// no trailing edge of its own. 1rem matches what a smart-table cell gets from
// `--gauzy-table-cell-padding-x` on either side of its border.
:host .vendor-row > [class*='col-'] {
padding-inline: 0 1rem;
}
:host .vendor-row > [class*='col-']:last-child {
padding-inline-end: 0;
}
// A hand-written `padding-inline: 0 1rem` pair used to sit here in its place. It
// is gone: a literal that had to be kept in step by eye with the row's own
// gutter and with the cell padding it was copied from, for a spacing the grid
// already provides.
// The name is the row's identity: it is the only value every vendor has, the
// one the list is scanned by, and the one the row is selected by. At the same