mirror of
https://github.com/ever-co/ever-gauzy.git
synced 2026-10-02 01:54:50 +08:00
Feat/hr dashboard redesign (#10125)
* feat(theme): add validated chart series tokens The employee statistics charts painted with Nebular's raw status colours, which are tuned for text rather than for large fills. Adds gauzy-chart-* to the shared token maps, so every registered theme carries its own steps for the four series, the negative state, the grid and the tick labels. Merged through $gauzy-shared-light/dark, the same path the action accents already take into all eight themes. Every value was snapped to a step that clears all six checks of the categorical palette validator against the surface the charts sit on: the OKLCH lightness band for the mode, the chroma floor, all-pairs CVD separation, the normal-vision floor and 3:1 contrast. Danger is reserved for the negative state and so cannot double as the fourth categorical hue; fuchsia is the step that clears blue under deuteranopia, where violet and indigo both fail outright. * refactor(dashboard): resolve chart palette from theme tokens Reads the gauzy-chart-* custom properties off a live element instead of Nebular's JS theme variables, so the charts follow the same steps the rest of the page paints with and pick up a theme switch for free. Falls back to the previous status colours when a token is absent or when there is no host element, so nothing regresses outside a browser. Also exposes the surface, border and primary ink the tooltips need to render as a card on the theme's own ground. * feat(dashboard): add shared employee chart options The three employee statistics charts each spelled out their own legend, tooltip, scales and mark geometry, which is how they drifted apart. Collects that chrome in one module: a legend of small dots, a tooltip painted as a card on the theme surface, a hairline grid on the value axis only, and thin marks with rounded ends separated by a gap in the surface colour rather than by an outline. Axis ticks are abbreviated so a money axis stays on one line without rotating its labels, and the chart's own animation honours prefers-reduced-motion, which no CSS media query can reach. The tooltip takes an optional value resolver because the number a mark plots is not always the number it should report. * chore(dashboard): export shared chart options from barrel * style(dashboard): restyle employee horizontal bar chart Adopts the shared chrome: grid on the value axis only, no axis borders or tick spurs, and thin rounded bars with a surface gap in place of the 2px border each bar used to carry. The container no longer pins itself to 20rem, a height that took no account of the legend above the plot or the tick band below it. It now fills the box the panel gives it. * fix(dashboard): migrate stacked bar chart to Chart.js v4 options The chart configured itself with v2 option names - legend, tooltips, elements.rectangle and labels.fontColor - which Chart.js 4 silently ignores. It therefore rendered with library defaults and never followed the active theme at all. Moves those onto plugins.* and the shared chrome, and formats tooltip values as currency the way the sibling charts do. The tooltip reports the unscaled figure rather than the plotted one: this chart normalises each month's segments so the stack sums to that month's income, so the plotted value is a share and printing it as money would state something untrue. Also drops dead code. selectedDate and proportion were declared as fields and never assigned, so three dataset label ternaries had always taken their else branch; proportion is now a local in the loop that computes it. * fix(dashboard): rework employee doughnut chart layout The slice labels were the full "Revenue: $12,450.00" strings, and a doughnut builds its legend from those, so four long entries wrapped across the top of the chart and squeezed the ring. The tooltip then echoed the same string back, overriding the currency formatting. Moves the legend to the right, where a circle in a wide box has room to spare, and caps its width so a long amount cannot push the ring into a corner. Slice labels are names only; the amount is appended by the legend itself and the tooltip formats its own. Types the chart configuration as 'doughnut'. Bare ChartConfiguration resolves to the union over every registered chart type, whose options are only those common to all of them, so arc-only settings such as cutout and hoverOffset are not known properties there. Removes the tooltip helper that only echoed the slice label, along with four now-unused locals and a commented-out span. * refactor(dashboard): make employee charts fill their host panel The component rendered its own chart-type switcher right-aligned above the canvas, which on the HR dashboard produced a second header-ish row directly beneath the panel's real header with nothing to anchor it to. selectedChart becomes an input, defaulted so the component still renders standalone, and the host owns the control. Also fixes the sizing. align-items: center on a flex column is the cross axis, so the chart shrank to its intrinsic width and floated in the middle of the panel instead of spanning it. * refactor(dashboard): rework HR dashboard component state Formats every money figure once, on load, instead of repeating a ternary plus two pipes per figure in the template. Fixes averageBonus, which the header has always had a row for but which was declared and never assigned, so the row could not render. It is the period total over the number of months the API returned. The page also rendered nothing until the debounced selectors resolved. loading now starts true and is released on the early return as well, so the first paint is a spinner rather than a false empty state. Adds the chart type the statistics panel draws, the initials shown when an employee has no avatar, and the bonus formula the Total Bonus tile puts on its tooltip - resolved here because a tooltip is an attribute binding and cannot host control flow. * feat(dashboard): restructure HR dashboard template The page was a two-column split whose left column was one flat stack of label/value rows, so the totals carried no more weight than their own components and there was nothing to scan. It now reads as identity, then the four totals, then what they are made of beside the same figures over time: an identity bar naming the employee the figures belong to, a KPI row of four tiles, and a Breakdown panel next to the Employee Statistics panel, whose header carries the chart-type switcher on the title's line. A tile carries a label and a number; the arithmetic behind it moved to its tooltip, since four tiles each printing two clamped lines of grey formula read as a wall rather than a summary. Breakdown rows are single line for the same reason. Clickable figures are real buttons rather than click handlers on divs, so they are reachable by keyboard and announced as actions, and each names itself with its label and value. Adds empty states for no employee selected, and decorative icons are hidden from assistive tech. * style(dashboard): rebuild HR dashboard styles Paints entirely with the shared gauzy-* tokens, so all eight themes and both canvases follow without a per-theme branch. Type weight is a three step scale, and only three. Nearly every element on the page was 600 - the employee name, both panel titles, the group headings, the KPI values, every figure - and when everything is semibold nothing reads as emphasis. Hierarchy comes from size and the muted or strong ink tokens instead, and weight is spent on the four headline numbers alone. The panel header's min-height is derived from the chart switcher it has to hold, so the Breakdown and Employee Statistics headers line up and their bodies start on the same line. The chart's height is set by the panel rather than by the canvas, so plot, legend and axis labels live in one measured area. Money figures use tabular numerals, breakdown chevrons sit out of flow so values align in a column whether or not a row is clickable, and the whole page respects prefers-reduced-motion. * feat(i18n): add HR dashboard breakdown label Names the panel that itemises what each headline total is made of. * test(e2e): retarget HR dashboard selectors The HR dashboard no longer renders ga-info-block, so the selectors that found its cards matched nothing and every card assertion and click in the suite would have failed. Points them at the KPI tiles and breakdown rows that replaced it, and excludes the Total Bonus tile, which is deliberately static because there is no BONUS history type to open. Records why the text filters are now unambiguous: each queried heading renders in exactly one card, because the formulas that quote other headings live on tooltips, which Playwright's hasText does not see. * test(e2e): update HR dashboard page helper notes Describes where the click handler now sits and why .first() resolves unambiguously, replacing notes that described the removed info-block markup and a DOM ordering the page no longer depends on. * docs(dashboard): correct chart switcher references in widgets Both files described the switcher as something ga-employee-charts puts above its own chart. That control now lives in the HR dashboard's Employee Statistics panel header, and the component only draws the rendering it is handed. * fix(dashboard): size employee charts from a positioned parent With `maintainAspectRatio` disabled, Chart.js measures the canvas against its offset parent. `.chart` was unpositioned in all three employee charts, so the plot sized itself against an ancestor further up the tree and came out small in the middle of the panel. * fix(dashboard): drop amounts from the doughnut legend The amounts were appended to each legend entry, producing four ragged "name + figure" rows whose numbers did not line up — a legend cannot lay out two columns. Every one of those figures is already stated twice on the page, in the KPI tile and again in the Breakdown panel, and the exact value is one hover away on the tooltip. Series names alone need less room, so the legend's `maxWidth` cap comes down from 200 to 160 and the ring keeps the space. * fix(styles): raise chart label contrast to the AA floor Axis ticks and legend entries are small text, so they owe 4.5:1. On light they inherited the `gauzy-text-color-2` step, which measures 3.99:1 on white; the replacement measures 6.08:1. On dark they were a 55% white step that read as grey against the canvas, now full white. * style(dashboard): rebuild the HR KPI tiles on flex The tiles were a three-column grid with the glyph and chevron each spanning both text rows. Replaced by one flex row — glyph, a label-and- value block, chevron — which is what the layout always was. The new `.kpi-text` wrapper carries `min-width: 0`, so a long figure shrinks in place instead of pushing the chevron off the tile, and the chevron pins itself to the trailing edge with `margin-inline-start: auto`. Accents now read the `gauzy-chart-*` tokens the charts paint with, so a tile and its slice are the same colour by construction. They were not: `--info` and `--accent` both resolved to the info step, making Profit and Bonus identical on the tiles while the chart drew Bonus in fuchsia. The tint fill behind each glyph goes with them — the tile already has its own surface and hairline to sit on. Also flattens the page's type scale to 0.75rem and stretches the panel row so the breakdown and the chart share the taller height rather than leaving a ragged bottom edge. * style(dashboard): align history dialogs with the shared table hub The records and profit dialogs opened from the HR KPI tiles carried their own table metrics, so they read as a different object from every other data table in the app. They now take density, surface, row treatment and pager from `gauzy/_gauzy-table-hub`, the same partial the documents, employees, invoices, payments, pipelines and tasks tables already use. Drops the local 11px gutter on `.table-scroll-container`: the cells carry their own horizontal padding and the card body supplies the outer margin, so that gutter only pushed the surface outline a column-width off the table it outlines. `profit-history.component.scss` @forwards this file, so the profit dialog picks all of it up unchanged. * feat(dashboard): render employee chart legends in HTML Chart.js lays each legend entry out as one run of text, so the doughnut's "name + figure" entries came out ragged: four rows whose numbers each started wherever the name before them happened to end. A canvas legend cannot lay out two columns, and whatever width it claimed was measured out of the same rectangle the chart draws in — which is why the ring needed a maxWidth cap to stop a long currency string pushing it into a corner. The new ga-employee-chart-legend renders the entries as DOM instead. The amount is pushed to the trailing edge so every figure ends on one right margin, and tabular-nums lines up the digits inside it. Being a CSS sibling of the canvas, it divides the panel with the plot rather than competing for it: the doughnut gets a box with the panel's full height and inscribes itself in the shorter side, so the ring comes out as large as the section allows and no larger. The doughnut's amounts are restored to its legend, where they were before the redesign. The stacked chart takes the same treatment for its fit, but keeps names only: its bars are normalised so each month's stack sums to that month's income, so a total printed beside a series name would not be the quantity any bar is showing. The horizontal bar chart still draws its legend on the canvas, so employeeChartLegend stays — minus its position parameter, whose only caller was the doughnut. * fix(dashboard): restore the visible calculation lines on the HR page ga-info-block, the component this page was built from, rendered its `meta` input as visible text under every title. The redesign kept all ten of those strings but moved each one onto a tooltip, so figures that used to be readable at a glance were reachable only by hovering — and one of them, the bonus percentage, appears nowhere else on the page at all. They are body text again: kpi-meta under each tile's figure, stat-row-meta under each breakdown label. The tooltips that carried them are dropped, since a tooltip repeating text already on screen is noise. The identity bar's edit button keeps its own tooltip: that icon has no visible label. The salary row is left without one — it is new to this page and never carried a formula — so its label stays a direct child of the row and takes the flexible width itself. * style(dashboard): drop the KPI tile formulas to 10px Splits the shared meta rule so only the four overview tiles shrink. A tile carries the same sentence in a quarter of a breakdown row's width, so it wraps to three or four lines where a row takes one; at 11px that turned the KPI band into the densest thing on the page. The leading comes down with the size. 1rem of it under a 10px face reads as separate paragraphs rather than one wrapped sentence. * fixed the spell error * fix(dashboard): rebuild the doughnut legend when the organization changes The tooltip formats its amounts in the callback, so it picks up a new organization's currency the moment the store emits. The legend does not: its amounts are strings built the last time the dataset was. Between an organization change and the arrival of that organization's statistics, the two disagreed — the legend still printing the previous currency beside a tooltip already using the new one. Rebuild the dataset in the organization handler so both switch together. * fix(dashboard): reach the HR empty state and track traits by position Two defects on the HR page. `loading` was only ever cleared inside getEmployeeStatistics(), which is reached solely through statistics$ — and that only fires downstream of a filter requiring a truthy employee. With no employee ever selected the spinner ran forever and the "no employee selected" state, gated on `!loading`, could not render. Clear the flag before that filter, on the settled emission that carries no employee. The identity traits are the employee's short description and level, and nothing stops those holding the same string, which gave the @for two identical keys and threw NG0955 instead of rendering the chips. Track them by index. * test(e2e): match HR cards on their label, not their whole body The note on infoBlockCss claimed each card's arithmetic sat on an nbTooltip, where Playwright cannot see it, so every queried heading matched exactly one card. That has not been true since the formulas became body text: the Profit tile prints "Profit (Net Income) = Total Income X - Total Expenses Y", so a whole-card hasText matched it for both the "Total Income" and the "Total Expenses" query. The clicks only landed correctly because .first() happened to return the DOM-first tile. Scope the filter to the label span, where each of the four headings identifies one card, and correct both comments. * chore(cspell): restore alphabetical order in the words list "deutan" sat after "devkit", "Gridlines" after "Guazy", and "uppercasing" among the "un" entries. Move each to its own place. * fix(dashboard): average the HR bonus over the selected range The divisor came from `employeeStatistics.length`, but the aggregation endpoint returns a row only for months that hold records — so a range containing an idle month averaged over its ACTIVE months and overstated "Average Monthly Bonus". Count the calendar months the picker asked for instead, both ends included. * refactor(dashboard): inject the employee chart dependencies The three employee charts took every dependency — including the newly added ElementRef — through constructor parameters, against the repo's Angular rule to prefer `inject()`. TranslateService drops out entirely: TranslationBaseComponent takes it optionally and injects it itself, so `super()` covers it. The empty constructors stay because a component that inherits a parameterised constructor from an undecorated base does not compile. * test(e2e): locate the HR chart switcher by its class The redesigned switcher declares no `placeholder`, so the old [placeholder="Chart"] locator matched nothing and the chart-options scenario timed out. Use the `.chart-switcher` class the panel header gives it — the same nb-select host the attribute sat on, so the click target is unchanged — rather than the translated aria-label, which would tie the locator to the active language.
This commit is contained in:
@@ -278,6 +278,7 @@
|
||||
"Desterrro",
|
||||
"destructures",
|
||||
"desync",
|
||||
"deutan",
|
||||
"devkit",
|
||||
"diffable",
|
||||
"digitaloceanspaces",
|
||||
@@ -394,6 +395,7 @@
|
||||
"graphicsmagick",
|
||||
"greenkeeper",
|
||||
"gridcell",
|
||||
"Gridlines",
|
||||
"grpc",
|
||||
"Guazy",
|
||||
"Harbir",
|
||||
@@ -1054,6 +1056,7 @@
|
||||
"unzipper",
|
||||
"UPDATECHECK",
|
||||
"uploader",
|
||||
"uppercasing",
|
||||
"upserted",
|
||||
"upserting",
|
||||
"upserts",
|
||||
|
||||
@@ -6,19 +6,38 @@ export const HumanResourcesPage = {
|
||||
// The employee name inside a row is rendered by ngx-avatar as `a.link-text`.
|
||||
employeeRowNameCss: '.table-scrollable ngx-avatar a.link-text',
|
||||
// The selected employee's name is read from the PAGE-HEADER employee selector label, not the HR
|
||||
// card's `.employee-name` span. Reason: clicking an accounting row calls accounting.selectEmployee()
|
||||
// card's `.identity-name` span. Reason: clicking an accounting row calls accounting.selectEmployee()
|
||||
// which writes store.selectedEmployee WITHOUT `fullName`; the HR card binds `selectedEmployee.fullName`
|
||||
// (`human-resources.component.html`) so that span renders EMPTY via this path. The header
|
||||
// ga-employee-selector (`ng-select.employee` -> `ng-label-tmp`) instead renders the name from its own
|
||||
// employee list via getShortenedName(firstName,lastName), so it correctly shows e.g. "Default Employee".
|
||||
employeeNameCss: 'ng-select.employee .selector-template span',
|
||||
// HR dashboard cards are ga-info-block components; the title lives in `.info-block .info-text`
|
||||
// (the old `.statistic-component .title` was the accounting aggregate cards, not these per-employee ones).
|
||||
infoTextCss: '.info-block .info-text',
|
||||
// The clickable card element carrying the (click)="handleClick()" handler that opens the popup.
|
||||
infoBlockCss: '.info-block',
|
||||
// Placeholder lost its "Select " prefix along with every other combobox placeholder.
|
||||
chartDropdownCss: '[placeholder="Chart"]',
|
||||
// The HR dashboard no longer renders ga-info-block. Its figures now live in two kinds of element:
|
||||
// * `.kpi` — the four headline tiles (Total Income, Total Expenses, Profit, Total Bonus)
|
||||
// * `.stat-row` — the component rows in the Breakdown panel (Income, Direct Income,
|
||||
// Total Expense without salary, Salary, and the bonus components)
|
||||
// Titles are in the label span of each, never mixed with the `.kpi-meta` / `.stat-row-meta`
|
||||
// formula text — so a text filter here matches a heading and not the formula that quotes it.
|
||||
infoTextCss: '.kpi-label, .stat-row-label',
|
||||
// The clickable elements carrying the (click) handler that opens a history popup. Both are real
|
||||
// <button>s now (the old .info-block was a click handler on a div).
|
||||
//
|
||||
// A card's rendered text is NOT just its own heading and figure: each one also prints its
|
||||
// arithmetic as body text in `.kpi-meta` / `.stat-row-meta`, and that arithmetic quotes OTHER
|
||||
// cards' headings — PROFIT_CALC is "Profit (Net Income) = Total Income {x} - Total Expenses {y}".
|
||||
// Since Playwright's `hasText` is a CASE-INSENSITIVE SUBSTRING match over an element's whole
|
||||
// subtree, filtering these cards by "Total Income" or by "Total Expenses" matches the Profit tile
|
||||
// as well as the intended one. `clickCardByHeaderText` therefore scopes its text match to
|
||||
// `infoTextCss` (the label span) via `filter({ has: ... })` rather than matching the card whole —
|
||||
// otherwise which card got clicked would come down to which one `.first()` found in DOM order.
|
||||
// The Bonus tile is deliberately excluded: it is `.kpi--static` (a div) because there is no BONUS
|
||||
// history type to open.
|
||||
infoBlockCss: '.kpi:not(.kpi--static), .stat-row:not(.is-static)',
|
||||
// The redesigned switcher carries no `placeholder` — the panel header already names the control,
|
||||
// and what it does expose is a TRANSLATED `aria-label`, which would tie this locator to the active
|
||||
// language. `.chart-switcher` is the class the header gives it, and it sits on the same `nb-select`
|
||||
// host the placeholder attribute used to, so the click target is unchanged.
|
||||
chartDropdownCss: 'nb-select.chart-switcher',
|
||||
// nb-select options render into `.option-list nb-option`.
|
||||
dropdownOptionCss: '.option-list nb-option',
|
||||
// Records-history popup is `nb-card.records` with `<h5 class="title">` inside its header.
|
||||
|
||||
@@ -80,10 +80,15 @@ export const verifyChartOptionText = async (text: string) => {
|
||||
};
|
||||
|
||||
export const clickCardByHeaderText = async (text: string) => {
|
||||
// The (click)="handleClick()" handler sits on `.info-block`; clicking it emits openInfo, whose parent
|
||||
// handler (openHistoryDialog/openProfitDialog) fetches records then opens an nb-dialog popup.
|
||||
// .first() lands on the intended block (DOM order: Income, Expense-w/o-salary, Expenses, Profit;
|
||||
// the Profit meta mentions the other titles but appears last).
|
||||
// The (click) handler sits on the `.kpi` tile / `.stat-row` row itself (see `infoBlockCss`), calling
|
||||
// openHistoryDialog/openProfitDialog, which fetch records then open an nb-dialog popup.
|
||||
//
|
||||
// The text match is scoped to the card's LABEL span (`infoTextCss`), not run over the card whole:
|
||||
// every card also prints its arithmetic as body text, and that arithmetic quotes other cards'
|
||||
// headings — the Profit tile's line contains both "Total Income" and "Total Expenses", so a
|
||||
// whole-card `hasText` matches it for two of the four queried headings and `.first()` would be
|
||||
// picking between them on DOM order alone. Scoped to the label, each heading identifies one card.
|
||||
// See the note on `infoBlockCss` in HumanResourcesPageObject.ts.
|
||||
//
|
||||
// Round 4: a single dispatchEvent('click') was NOT reliably opening the dialog (failure DOM showed a
|
||||
// clean HR dashboard, no popup), so open the dialog defensively: settle, then loop — try a real click
|
||||
@@ -92,7 +97,10 @@ export const clickCardByHeaderText = async (text: string) => {
|
||||
// popup to attach. Retry the click if it didn't open instead of failing on the first miss.
|
||||
await waitForSpinnerGone();
|
||||
await getPage().waitForLoadState('networkidle').catch(() => {});
|
||||
const block = getPage().locator(HumanResourcesPage.infoBlockCss).filter({ hasText: text }).first();
|
||||
const block = getPage()
|
||||
.locator(HumanResourcesPage.infoBlockCss)
|
||||
.filter({ has: getPage().locator(HumanResourcesPage.infoTextCss, { hasText: text }) })
|
||||
.first();
|
||||
await block.waitFor({ state: 'visible', timeout: 24_000 });
|
||||
|
||||
const popup = getPage().locator(HumanResourcesPage.popupAnyCss).first();
|
||||
@@ -133,7 +141,7 @@ export const verifyPopupTableHeaderText = async (text: string) => {
|
||||
export const clickCardBody = async () => {
|
||||
// Close the just-verified history popup before opening the next card. Press Escape (NbDialog
|
||||
// closeOnEsc defaults to true) — more reliable than a backdrop coordinate click and it can't land on
|
||||
// an info-block. Fall back to clicking outside, then wait for the popup AND its cdk-overlay backdrop
|
||||
// another card. Fall back to clicking outside, then wait for the popup AND its cdk-overlay backdrop
|
||||
// to detach so the next clickCardByHeaderText isn't intercepted by a fading backdrop.
|
||||
await getPage().keyboard.press('Escape');
|
||||
if (await getPage().locator(HumanResourcesPage.popupAnyCss).first().isVisible().catch(() => false)) {
|
||||
|
||||
@@ -59,6 +59,7 @@ import { AccountingComponent } from './accounting/accounting.component';
|
||||
import { ProjectManagementComponent } from './project-management/project-management.component';
|
||||
import { ProjectManagementDetailsComponent } from './project-management/project-management-details/project-management-details.component';
|
||||
import {
|
||||
EmployeeChartLegendComponent,
|
||||
EmployeeChartsComponent,
|
||||
EmployeeDoughnutChartComponent,
|
||||
EmployeeHorizontalBarChartComponent,
|
||||
@@ -116,6 +117,7 @@ const COMPONENTS = [
|
||||
ProjectManagementComponent,
|
||||
ProjectManagementDetailsComponent,
|
||||
EmployeeChartsComponent,
|
||||
EmployeeChartLegendComponent,
|
||||
EmployeeHorizontalBarChartComponent,
|
||||
EmployeeStackedBarChartComponent,
|
||||
EmployeeDoughnutChartComponent,
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
/** One entry in {@link EmployeeChartLegendComponent}. */
|
||||
export interface IEmployeeChartLegendItem {
|
||||
/** Series name, already translated. */
|
||||
label: string;
|
||||
/** Swatch colour — the same value the chart paints that series with. */
|
||||
color: string;
|
||||
/**
|
||||
* Pre-formatted figure, shown in its own trailing column.
|
||||
*
|
||||
* Optional because not every chart has a number that belongs beside the
|
||||
* series name: the stacked chart plots normalised shares, so a total printed
|
||||
* there would not be the quantity the bar is showing.
|
||||
*/
|
||||
amount?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The legend for the employee-statistics charts, drawn in HTML rather than on
|
||||
* the canvas.
|
||||
*
|
||||
* Chart.js lays each legend entry out as a single run of text, so a
|
||||
* "name + figure" entry comes out ragged: four rows whose numbers each start
|
||||
* wherever the name before them happened to end. Money wants a column. Pushing
|
||||
* the figure to the trailing edge gives one — every amount ends on the same
|
||||
* right margin — and `tabular-nums` lines up the digits inside it.
|
||||
*
|
||||
* Taking the legend off the canvas also hands the plot its box back. A canvas
|
||||
* legend is measured out of the same rectangle the chart draws in, which is why
|
||||
* the doughnut needed a `maxWidth` cap to stop a long currency string pushing
|
||||
* the ring into a corner. Here the legend is a sibling of the canvas, so the two
|
||||
* divide the panel in CSS and neither can crowd the other off it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ga-employee-chart-legend',
|
||||
template: `
|
||||
<ul class="legend" [class.legend--row]="orientation === 'row'">
|
||||
@for (item of items; track item.label) {
|
||||
<li class="legend-item">
|
||||
<span class="legend-swatch" [style.background-color]="item.color" aria-hidden="true"></span>
|
||||
<span class="legend-name">{{ item.label }}</span>
|
||||
@if (item.amount) {
|
||||
<span class="legend-amount">{{ item.amount }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
}
|
||||
|
||||
/* Above a chart rather than beside it: names run inline and wrap. */
|
||||
.legend--row {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 1rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
flex: 0 0 auto;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ellipsis rather than a wrap: a name that folded onto a second line
|
||||
* would put its figure on a row of its own and break the column the
|
||||
* amounts are lining up in.
|
||||
*/
|
||||
.legend-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-amount {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
padding-inline-start: 0.75rem;
|
||||
color: var(--gauzy-text-color-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* A row legend has no column to hold: the figure just follows its name. */
|
||||
.legend--row .legend-amount {
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
`
|
||||
],
|
||||
standalone: false
|
||||
})
|
||||
export class EmployeeChartLegendComponent {
|
||||
/** The series to name, in the order the chart plots them. */
|
||||
@Input() items: IEmployeeChartLegendItem[] = [];
|
||||
|
||||
/** 'column' beside a chart (the doughnut), 'row' above one (the bars). */
|
||||
@Input() orientation: 'column' | 'row' = 'column';
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { ChartOptions, ScaleOptions, TooltipItem } from 'chart.js';
|
||||
import { IEmployeeChartPalette } from './employee-chart-palette';
|
||||
|
||||
/**
|
||||
* Shared chrome for the three employee-statistics charts.
|
||||
*
|
||||
* The three used to each spell out their own legend, tooltip and scales, which
|
||||
* is how they drifted apart — and how the stacked chart ended up still carrying
|
||||
* Chart.js v2 option names (`legend`, `tooltips`, `elements.rectangle`,
|
||||
* `labels.fontColor`) that v4 silently ignores, so it rendered with library
|
||||
* defaults and never followed the theme at all.
|
||||
*
|
||||
* Everything here is deliberately recessive: hairline grid on the value axis
|
||||
* only, no axis borders, thin marks with rounded data-ends, and a legend of
|
||||
* small dots. The data is the only loud thing on the canvas.
|
||||
*/
|
||||
|
||||
/** Type size for ticks and legend entries. Below the 0.75rem body caption. */
|
||||
const CHART_FONT_SIZE = 11;
|
||||
|
||||
/** Radius on the data-end of a bar, and the surface gap between adjacent bars. */
|
||||
const BAR_RADIUS = 4;
|
||||
const BAR_GAP = 2;
|
||||
|
||||
/**
|
||||
* Legend: small circles, generous padding, text in the muted ink token.
|
||||
*
|
||||
* Always present, and never clickable-to-hide here — these series are a fixed
|
||||
* set. Its entries name every series in text beside its swatch, so identity
|
||||
* never rests on hue alone; that is the secondary encoding which lets the
|
||||
* palette's blue↔fuchsia pair sit in the validator's 6–8 CVD floor band. Do not
|
||||
* drop the legend without re-checking that palette.
|
||||
*
|
||||
* Only the horizontal bar chart still draws its legend on the canvas, with each
|
||||
* series' total appended to the dataset label. The other two render
|
||||
* `ga-employee-chart-legend` in HTML instead — same swatch-plus-name contract, so
|
||||
* the CVD note above still holds — because Chart.js cannot lay an entry out as
|
||||
* two columns and a canvas legend takes its room out of the plot's own box.
|
||||
*/
|
||||
export function employeeChartLegend(palette: IEmployeeChartPalette): ChartOptions<any>['plugins']['legend'] {
|
||||
return {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'start',
|
||||
labels: {
|
||||
color: palette.textColor,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 16,
|
||||
font: { size: CHART_FONT_SIZE }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip painted as a small card on the theme's own surface, rather than
|
||||
* Chart.js's default black slab.
|
||||
*
|
||||
* @param formatValue - Formats a raw number as the organization's currency.
|
||||
*/
|
||||
export function employeeChartTooltip(
|
||||
palette: IEmployeeChartPalette,
|
||||
formatValue: (value: number) => string,
|
||||
/**
|
||||
* Reads the number a hovered mark should REPORT, which is not always the
|
||||
* number it plots. The stacked chart normalises its segments so each month's
|
||||
* stack sums to that month's income, so its geometry is a share and its
|
||||
* tooltip has to reach back to the unscaled figure. Defaults to the plotted
|
||||
* value, which is the truth for the other two charts.
|
||||
*/
|
||||
resolveValue?: (item: TooltipItem<any>) => number
|
||||
): ChartOptions<any>['plugins']['tooltip'] {
|
||||
return {
|
||||
enabled: true,
|
||||
backgroundColor: palette.surface,
|
||||
titleColor: palette.strongTextColor,
|
||||
bodyColor: palette.strongTextColor,
|
||||
borderColor: palette.borderColor,
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
usePointStyle: true,
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
boxPadding: 6,
|
||||
titleFont: { size: CHART_FONT_SIZE, weight: 'normal' },
|
||||
bodyFont: { size: CHART_FONT_SIZE + 1 },
|
||||
callbacks: {
|
||||
// The dataset label already carries a running total for the period, so
|
||||
// strip it back to the series name before appending THIS point's value.
|
||||
label: (item: TooltipItem<any>) => {
|
||||
const series = (item.dataset?.label ?? '').split(':')[0].trim();
|
||||
const plotted = typeof item.parsed === 'object' ? (item.parsed.x ?? item.parsed.y) : item.parsed;
|
||||
const value = resolveValue ? resolveValue(item) : Number(plotted);
|
||||
return `${series}: ${formatValue(Number(value) || 0)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis that carries money: hairline grid, no border, abbreviated ticks.
|
||||
*
|
||||
* Ticks are abbreviated (12.5k) rather than fully formatted — a money axis
|
||||
* spelled out in full is what forces Chart.js to rotate its labels, and the
|
||||
* exact figure is one hover away.
|
||||
*/
|
||||
export function employeeChartValueScale(palette: IEmployeeChartPalette, stacked = false): ScaleOptions<'linear'> {
|
||||
return {
|
||||
stacked,
|
||||
border: { display: false },
|
||||
grid: {
|
||||
display: true,
|
||||
color: palette.axisLineColor,
|
||||
// Chart.js draws a tick mark outside the plot by default; the grid line
|
||||
// is the whole point, the little spur beside it is noise.
|
||||
tickLength: 0
|
||||
},
|
||||
ticks: {
|
||||
color: palette.textColor,
|
||||
font: { size: CHART_FONT_SIZE },
|
||||
padding: 8,
|
||||
maxTicksLimit: 6,
|
||||
callback: (value: number | string) => abbreviateNumber(Number(value))
|
||||
}
|
||||
} as ScaleOptions<'linear'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The axis that carries categories (months, or the series names): no grid at
|
||||
* all. Gridlines running along the categorical axis do not help anyone read a
|
||||
* bar's length, and drawing both axes is what made these charts read as graph
|
||||
* paper.
|
||||
*/
|
||||
export function employeeChartCategoryScale(palette: IEmployeeChartPalette, stacked = false): ScaleOptions<'category'> {
|
||||
return {
|
||||
stacked,
|
||||
border: { display: false },
|
||||
grid: { display: false, tickLength: 0 },
|
||||
ticks: {
|
||||
color: palette.textColor,
|
||||
font: { size: CHART_FONT_SIZE },
|
||||
padding: 6,
|
||||
autoSkip: true
|
||||
}
|
||||
} as ScaleOptions<'category'>;
|
||||
}
|
||||
|
||||
/** Mark geometry shared by both bar charts: thin, rounded, with a surface gap. */
|
||||
export function employeeChartBarDataset(palette: IEmployeeChartPalette) {
|
||||
return {
|
||||
// A border painted in the SURFACE colour, which reads as a gap rather than
|
||||
// as an outline — the separation the eye needs between stacked segments and
|
||||
// between adjacent bars, without drawing chrome around every mark.
|
||||
borderWidth: BAR_GAP,
|
||||
borderColor: palette.surface,
|
||||
borderRadius: BAR_RADIUS,
|
||||
// Rounds every corner rather than only the data-end, so a stacked segment
|
||||
// is a rounded tile instead of a slab with one shaped end.
|
||||
borderSkipped: false as const,
|
||||
maxBarThickness: 18,
|
||||
// Space between the bars of one month, and between the months themselves.
|
||||
barPercentage: 0.86,
|
||||
categoryPercentage: 0.72,
|
||||
hoverBorderWidth: BAR_GAP,
|
||||
hoverBorderColor: palette.surface
|
||||
};
|
||||
}
|
||||
|
||||
/** Chart-wide options every one of the three shares. */
|
||||
export function employeeChartBase(): Partial<ChartOptions<any>> {
|
||||
return {
|
||||
responsive: true,
|
||||
// The canvas fills the height its container gives it; see `.chart-wrap`.
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: { top: 4, right: 8, bottom: 0, left: 0 } },
|
||||
interaction: { mode: 'nearest', intersect: true },
|
||||
animation: { duration: prefersReducedMotion() ? 0 : 300 }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Honours the OS "reduce motion" setting for the chart's own entrance/update
|
||||
* animation, which no CSS media query can reach.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact axis tick: 1_250_000 -> "1.25M". Keeps a money axis to one line
|
||||
* without rotating labels.
|
||||
*/
|
||||
export function abbreviateNumber(value: number): string {
|
||||
if (!isFinite(value)) {
|
||||
return '';
|
||||
}
|
||||
const magnitude = Math.abs(value);
|
||||
const sign = value < 0 ? '-' : '';
|
||||
if (magnitude >= 1_000_000) {
|
||||
return `${sign}${trimZeroes(magnitude / 1_000_000)}M`;
|
||||
}
|
||||
if (magnitude >= 1_000) {
|
||||
return `${sign}${trimZeroes(magnitude / 1_000)}k`;
|
||||
}
|
||||
return `${sign}${trimZeroes(magnitude)}`;
|
||||
}
|
||||
|
||||
/** `1.50` -> `1.5`, `2.00` -> `2`. */
|
||||
function trimZeroes(value: number): string {
|
||||
return String(Number(value.toFixed(2)));
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export interface IEmployeeChartPalette {
|
||||
textColor: string;
|
||||
/** Grid line colour. */
|
||||
axisLineColor: string;
|
||||
/** Card surface the chart sits on — the tooltip's ground, and the gap between marks. */
|
||||
surface: string;
|
||||
/** Hairline used for the tooltip's edge. */
|
||||
borderColor: string;
|
||||
/** Primary ink, for tooltip values. */
|
||||
strongTextColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,24 +32,55 @@ function themeColour(variables: Record<string, unknown> | undefined, name: strin
|
||||
return typeof value === 'string' && value ? value : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one `--gauzy-*` custom property off the host element.
|
||||
*
|
||||
* The chart palette lives in the theme maps rather than in this file so every
|
||||
* registered theme carries its own steps (see `gauzy-chart-*` in
|
||||
* `_gauzy-theme-maps.scss`). Those are emitted as CSS custom properties, so the
|
||||
* only way to see them from TypeScript is to resolve them against a live
|
||||
* element — inherited from whichever `.nb-theme-*` class is currently on the
|
||||
* page, which is also what makes this follow a theme switch for free.
|
||||
*/
|
||||
function cssToken(styles: CSSStyleDeclaration | undefined, name: string, fallback: string): string {
|
||||
const value = styles?.getPropertyValue(name)?.trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the palette for the currently active theme.
|
||||
*
|
||||
* @param config - The theme emitted by `NbThemeService.getJsTheme()`.
|
||||
* @param host - An element inside the themed tree. Omit it and the function
|
||||
* falls back to Nebular's raw status colours, which is what these charts used
|
||||
* before the `gauzy-chart-*` tokens existed: correct, but not the tuned steps.
|
||||
* @returns A fully populated palette.
|
||||
*/
|
||||
export function resolveEmployeeChartPalette(config: NbJSThemeOptions): IEmployeeChartPalette {
|
||||
export function resolveEmployeeChartPalette(config: NbJSThemeOptions, host?: Element | null): IEmployeeChartPalette {
|
||||
const variables = (config?.variables ?? {}) as Record<string, unknown>;
|
||||
const chartJs = (variables['chartjs'] ?? {}) as { textColor?: string; axisLineColor?: string };
|
||||
|
||||
// Guarded: `getComputedStyle` is absent when this runs outside a browser.
|
||||
const styles =
|
||||
host && typeof getComputedStyle === 'function' ? getComputedStyle(host as Element) : (undefined as any);
|
||||
|
||||
const text = cssToken(styles, '--gauzy-chart-label', chartJs.textColor || themeColour(variables, 'fgText', 'gray'));
|
||||
|
||||
return {
|
||||
revenue: themeColour(variables, 'success', 'green'),
|
||||
expenses: themeColour(variables, 'warning', 'gold'),
|
||||
bonus: themeColour(variables, 'info', 'blue'),
|
||||
profit: themeColour(variables, 'successLight', 'lime'),
|
||||
negativeBonus: themeColour(variables, 'danger', 'red'),
|
||||
negativeProfit: themeColour(variables, 'dangerLight', 'orange'),
|
||||
textColor: chartJs.textColor || themeColour(variables, 'fgText', 'gray'),
|
||||
axisLineColor: chartJs.axisLineColor || themeColour(variables, 'separator', 'silver')
|
||||
revenue: cssToken(styles, '--gauzy-chart-revenue', themeColour(variables, 'success', 'green')),
|
||||
expenses: cssToken(styles, '--gauzy-chart-expenses', themeColour(variables, 'warning', 'gold')),
|
||||
bonus: cssToken(styles, '--gauzy-chart-bonus', themeColour(variables, 'info', 'blue')),
|
||||
profit: cssToken(styles, '--gauzy-chart-profit', themeColour(variables, 'successLight', 'lime')),
|
||||
negativeBonus: cssToken(styles, '--gauzy-chart-negative', themeColour(variables, 'danger', 'red')),
|
||||
negativeProfit: cssToken(styles, '--gauzy-chart-negative', themeColour(variables, 'dangerLight', 'orange')),
|
||||
textColor: text,
|
||||
axisLineColor: cssToken(
|
||||
styles,
|
||||
'--gauzy-chart-grid',
|
||||
chartJs.axisLineColor || themeColour(variables, 'separator', 'silver')
|
||||
),
|
||||
surface: cssToken(styles, '--gauzy-card-1', themeColour(variables, 'bg', '#ffffff')),
|
||||
borderColor: cssToken(styles, '--gauzy-border-default-color', 'rgba(126, 126, 143, 0.2)'),
|
||||
strongTextColor: cssToken(styles, '--gauzy-text-color-1', text)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
<div class="charts">
|
||||
<label class="chart-select-label">
|
||||
{{ 'DASHBOARD_PAGE.CHARTS.CHART_TYPE' | translate }}
|
||||
</label>
|
||||
<nb-select
|
||||
placeholder="Chart"
|
||||
class="select-charts"
|
||||
outline
|
||||
status="basic"
|
||||
[(selected)]="selectedChart"
|
||||
size="small"
|
||||
>
|
||||
<nb-option [value]="EmployeeChartEnum.BAR">
|
||||
<img width="auto" height="16" style="margin-right: 8px" src="assets/images/others/bar-chart.svg" />
|
||||
{{ 'DASHBOARD_PAGE.CHARTS.BAR' | translate }}
|
||||
</nb-option>
|
||||
<nb-option [value]="EmployeeChartEnum.DOUGHNUT">
|
||||
<img width="auto" height="16" style="margin-right: 8px" src="assets/images/others/doughnut.svg" />
|
||||
{{ 'DASHBOARD_PAGE.CHARTS.DOUGHNUT' | translate }}
|
||||
</nb-option>
|
||||
<nb-option [value]="EmployeeChartEnum.STACKED_BAR">
|
||||
<img width="auto" height="16" style="margin-right: 8px" src="assets/images/others/stacked-bar-chart.svg" />
|
||||
{{ 'DASHBOARD_PAGE.CHARTS.STACKED_BAR' | translate }}
|
||||
</nb-option>
|
||||
</nb-select>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Chart surface only. The chart-type switcher used to live here, right-aligned
|
||||
above the canvas, which on the HR dashboard put a second header-ish row
|
||||
directly under the panel's real header with nothing to anchor it to. The page
|
||||
that hosts this component now owns that control and drives `selectedChart`,
|
||||
so the toolbar and the panel title share one line.
|
||||
-->
|
||||
<div class="chart-wrap">
|
||||
@switch (selectedChart) {
|
||||
@case (EmployeeChartEnum.DOUGHNUT) {
|
||||
|
||||
@@ -1,32 +1,24 @@
|
||||
.charts {
|
||||
/*
|
||||
* Fills the panel it is dropped into rather than shrinking to its content.
|
||||
*
|
||||
* `align-items: center` used to sit here, and in a flex COLUMN that is the
|
||||
* cross axis — so the chart shrank to its own intrinsic width and floated in
|
||||
* the middle of the panel instead of spanning it. Stretch is what makes the
|
||||
* canvas actually fit the section.
|
||||
*/
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
label {
|
||||
margin: 0;
|
||||
}
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-select-label {
|
||||
font-size: var(--text-label-font-size, 0.75rem);
|
||||
font-weight: 500;
|
||||
color: var(--gauzy-text-color-2);
|
||||
}
|
||||
.chart-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.select-charts {
|
||||
cursor: pointer;
|
||||
font-size: var(--text-button-medium-font-size, 0.8125rem);
|
||||
min-width: 9.5rem;
|
||||
width: fit-content;
|
||||
}
|
||||
.chart-option {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,14 @@ export enum EmployeeChartEnum {
|
||||
standalone: false
|
||||
})
|
||||
export class EmployeeChartsComponent implements OnInit {
|
||||
selectedChart = EmployeeChartEnum.BAR;
|
||||
/**
|
||||
* Which chart to draw. An input rather than internal state: the switcher that
|
||||
* sets it belongs in the host panel's header beside the panel title, not in a
|
||||
* row of its own above the canvas. Defaulted, so a host that renders no
|
||||
* switcher still gets a chart.
|
||||
*/
|
||||
@Input() selectedChart: EmployeeChartEnum = EmployeeChartEnum.BAR;
|
||||
|
||||
EmployeeChartEnum: typeof EmployeeChartEnum = EmployeeChartEnum;
|
||||
|
||||
@Input() employeeStatistics: IMonthAggregatedEmployeeStatistics[] = [];
|
||||
|
||||
+172
-80
@@ -1,8 +1,7 @@
|
||||
import { Component, OnInit, OnDestroy, Input, OnChanges, ViewChild } from '@angular/core';
|
||||
import { Component, ElementRef, inject, OnInit, OnDestroy, Input, OnChanges, ViewChild } from '@angular/core';
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { debounceTime, filter, tap } from 'rxjs/operators';
|
||||
import { NbJSThemeOptions, NbThemeService } from '@nebular/theme';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { BaseChartDirective } from 'ng2-charts';
|
||||
import { ChartConfiguration, ChartType, TooltipItem } from 'chart.js';
|
||||
@@ -13,20 +12,20 @@ import { distinctUntilChange } from '@gauzy/ui-core/common';
|
||||
import { CurrencyPositionPipe } from '@gauzy/ui-core/shared';
|
||||
import { Store } from '@gauzy/ui-core/core';
|
||||
import { IEmployeeChartPalette, resolveEmployeeChartPalette } from '../employee-chart-palette';
|
||||
import { employeeChartBase, employeeChartTooltip } from '../employee-chart-options';
|
||||
import { IEmployeeChartLegendItem } from '../employee-chart-legend/employee-chart-legend.component';
|
||||
|
||||
@UntilDestroy({ checkProperties: true })
|
||||
@Component({
|
||||
selector: 'ga-employee-doughnut-chart',
|
||||
template: `
|
||||
@if (employeeStatistics.length) {
|
||||
<!-- <span>{{ displayDate }}</span> -->
|
||||
<canvas
|
||||
style="height: 20rem; width: 100%;"
|
||||
baseChart
|
||||
[data]="data"
|
||||
[options]="chartOptions"
|
||||
[type]="chartType"
|
||||
></canvas>
|
||||
<div class="chart">
|
||||
<div class="chart-canvas">
|
||||
<canvas baseChart [data]="data" [options]="chartOptions" [type]="chartType"></canvas>
|
||||
</div>
|
||||
<ga-employee-chart-legend class="chart-legend" [items]="legendItems"></ga-employee-chart-legend>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="title">
|
||||
<nb-icon icon="info-outline"></nb-icon>
|
||||
@@ -39,10 +38,60 @@ import { IEmployeeChartPalette, resolveEmployeeChartPalette } from '../employee-
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
/* The ring on the left, the legend naming its arcs on the right. */
|
||||
.chart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The ring's own box, and position: relative on it is not
|
||||
* decoration: with maintainAspectRatio disabled, Chart.js sizes the
|
||||
* canvas from its OFFSET PARENT, so without a positioned ancestor it
|
||||
* measures against something further up the tree and draws a plot
|
||||
* that does not match the space it was given.
|
||||
*
|
||||
* A doughnut inscribes itself in the SHORTER side of its box, so
|
||||
* handing it the panel's full height is what makes the ring as large
|
||||
* as the section allows and no larger.
|
||||
*/
|
||||
.chart-canvas {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
* Content-sized and capped: the legend asks for the width its longest
|
||||
* row needs, and past 45% of the panel the names ellipsize instead of
|
||||
* eating into the ring. This is the job the canvas legend's maxWidth
|
||||
* used to do, except the two now divide the panel in CSS rather than
|
||||
* competing for one canvas rectangle.
|
||||
*/
|
||||
.chart-legend {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 45%;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: auto;
|
||||
color: var(--gauzy-text-color-2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -52,13 +101,28 @@ import { IEmployeeChartPalette, resolveEmployeeChartPalette } from '../employee-
|
||||
})
|
||||
export class EmployeeDoughnutChartComponent extends TranslationBaseComponent implements OnInit, OnDestroy, OnChanges {
|
||||
public chartType: ChartType = 'doughnut';
|
||||
public chartOptions: ChartConfiguration['options'];
|
||||
public data: ChartConfiguration['data'];
|
||||
|
||||
/*
|
||||
* Parameterised with 'doughnut'. Bare `ChartConfiguration` resolves to the
|
||||
* union over every registered chart type, whose options are only the ones
|
||||
* common to all of them — so arc-only settings like `cutout` are not "known
|
||||
* properties" there and the object literal is rejected.
|
||||
*/
|
||||
public chartOptions: ChartConfiguration<'doughnut'>['options'];
|
||||
public data: ChartConfiguration<'doughnut'>['data'];
|
||||
|
||||
/** Slice colours for the active theme; see `employee-chart-palette.ts`. */
|
||||
private palette: IEmployeeChartPalette = resolveEmployeeChartPalette({} as NbJSThemeOptions);
|
||||
|
||||
public organization: IOrganization;
|
||||
|
||||
/**
|
||||
* The series named beside the ring, each with its total.
|
||||
*
|
||||
* Built alongside the dataset in {@link _initializeChartDataset} so a swatch
|
||||
* cannot end up a different colour from the arc it stands for.
|
||||
*/
|
||||
public legendItems: IEmployeeChartLegendItem[] = [];
|
||||
public labels: string[] = [];
|
||||
public statistics = {
|
||||
income: 0 as number,
|
||||
@@ -95,14 +159,19 @@ export class EmployeeDoughnutChartComponent extends TranslationBaseComponent imp
|
||||
|
||||
@ViewChild(BaseChartDirective, { static: false }) baseChartDirective: BaseChartDirective;
|
||||
|
||||
constructor(
|
||||
public readonly translateService: TranslateService,
|
||||
private readonly _themeService: NbThemeService,
|
||||
private readonly _currencyPipe: CurrencyPipe,
|
||||
private readonly _currencyPositionPipe: CurrencyPositionPipe,
|
||||
private readonly _store: Store
|
||||
) {
|
||||
super(translateService);
|
||||
private readonly _themeService = inject(NbThemeService);
|
||||
private readonly _currencyPipe = inject(CurrencyPipe);
|
||||
private readonly _currencyPositionPipe = inject(CurrencyPositionPipe);
|
||||
private readonly _store = inject(Store);
|
||||
private readonly _elementRef: ElementRef<HTMLElement> = inject(ElementRef);
|
||||
|
||||
/**
|
||||
* Declared rather than inherited: `TranslationBaseComponent` takes an optional
|
||||
* `TranslateService` and falls back to `inject()`, and a component that inherits
|
||||
* a constructor with parameters from an undecorated base does not compile.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -111,7 +180,21 @@ export class EmployeeDoughnutChartComponent extends TranslationBaseComponent imp
|
||||
debounceTime(100),
|
||||
distinctUntilChange(),
|
||||
filter((organization: IOrganization) => !!organization),
|
||||
tap((organization: IOrganization) => (this.organization = organization)),
|
||||
tap((organization: IOrganization) => {
|
||||
this.organization = organization;
|
||||
/*
|
||||
* The currency and its position come off the organization, and the
|
||||
* tooltip picks them up for free: its callback calls `formatCurrency`
|
||||
* at hover time. The legend does not — its amounts are strings built
|
||||
* once, the last time the dataset was. Rebuilding them here keeps the
|
||||
* two from disagreeing over the window between an organization change
|
||||
* and the new statistics arriving, during which the legend would
|
||||
* otherwise still be printing the previous organization's currency
|
||||
* beside a tooltip already using the new one.
|
||||
*/
|
||||
this._getChartStatistics();
|
||||
this._initializeChartDataset();
|
||||
}),
|
||||
untilDestroyed(this)
|
||||
)
|
||||
.subscribe();
|
||||
@@ -138,34 +221,33 @@ export class EmployeeDoughnutChartComponent extends TranslationBaseComponent imp
|
||||
* @param config - The configuration options for the Chart, including theme variables.
|
||||
*/
|
||||
private _initializeChart(config: NbJSThemeOptions) {
|
||||
this.palette = resolveEmployeeChartPalette(config);
|
||||
this.palette = resolveEmployeeChartPalette(config, this._elementRef.nativeElement);
|
||||
|
||||
// Step 2: Set the overall chart options
|
||||
this.chartOptions = {
|
||||
responsive: true, // Makes the chart responsive
|
||||
maintainAspectRatio: false, // Allows adjusting the aspect ratio
|
||||
indexAxis: 'y',
|
||||
// Elements options apply to all of the options unless overridden in a dataset
|
||||
// In this case, we are setting the border of each horizontal bar to be 2px wide
|
||||
elements: {
|
||||
arc: {
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
...employeeChartBase(),
|
||||
// A doughnut rather than a pie: the hole keeps the four arcs thin, the
|
||||
// same "thin marks" rule the bar charts follow.
|
||||
cutout: '64%',
|
||||
// A circle centres itself in whatever box it is given, so the only
|
||||
// padding worth spending is a little breathing room around the ring.
|
||||
layout: { padding: { top: 4, right: 4, bottom: 4, left: 4 } },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: this.palette.textColor,
|
||||
usePointStyle: false
|
||||
}
|
||||
},
|
||||
/*
|
||||
* The legend is `ga-employee-chart-legend`, rendered in HTML beside
|
||||
* the canvas. Chart.js cannot lay an entry out as two columns, so the
|
||||
* amounts it drew after each name never lined up; and whatever width
|
||||
* it claimed came out of the ring's own box.
|
||||
*/
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
// Define callback for tooltip labels
|
||||
...employeeChartTooltip(this.palette, this.formatCurrency),
|
||||
callbacks: {
|
||||
title: () => '',
|
||||
label: (tooltipItem: TooltipItem<ChartType>) => this.getTooltip(tooltipItem)
|
||||
// A doughnut has one unnamed dataset, so the series name lives on
|
||||
// the slice label rather than on `dataset.label` (which is what
|
||||
// the shared callback reads).
|
||||
label: (item: TooltipItem<ChartType>) =>
|
||||
`${item.label}: ${this.formatCurrency(Number(item.parsed) || 0)}`
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -198,52 +280,62 @@ export class EmployeeDoughnutChartComponent extends TranslationBaseComponent imp
|
||||
* Initializes the chart dataset with appropriate colors and labels.
|
||||
*/
|
||||
private _initializeChartDataset(): void {
|
||||
const income = this.formatCurrency(this.statistics.income);
|
||||
const expense = this.formatCurrency(this.statistics.expense);
|
||||
const bonus = this.formatCurrency(this.statistics.bonus);
|
||||
const profit = this.formatCurrency(this.statistics.profit);
|
||||
/*
|
||||
* Name, value and colour as one row per series, rather than three parallel
|
||||
* arrays that only agree while every one of them is edited together — and
|
||||
* that the legend beside the ring would have had to be kept in step with as
|
||||
* a fourth.
|
||||
*/
|
||||
const series = [
|
||||
{
|
||||
label: this.getTranslation('DASHBOARD_PAGE.CHARTS.REVENUE'),
|
||||
value: this.statistics.income,
|
||||
color: this.palette.revenue
|
||||
},
|
||||
{
|
||||
label: this.getTranslation('DASHBOARD_PAGE.CHARTS.EXPENSES'),
|
||||
value: this.statistics.expense,
|
||||
color: this.palette.expenses
|
||||
},
|
||||
{
|
||||
label: this.getTranslation('DASHBOARD_PAGE.CHARTS.BONUS'),
|
||||
value: this.statistics.bonus,
|
||||
color: this.palette.bonus
|
||||
},
|
||||
{
|
||||
label: this.getTranslation('DASHBOARD_PAGE.CHARTS.PROFIT'),
|
||||
value: this.statistics.profit,
|
||||
color: this.palette.profit
|
||||
}
|
||||
];
|
||||
|
||||
// Each series' total, beside its name and in its own trailing column.
|
||||
this.legendItems = series.map(({ label, value, color }) => ({
|
||||
label,
|
||||
color,
|
||||
amount: this.formatCurrency(value)
|
||||
}));
|
||||
|
||||
this.data = {
|
||||
labels: [
|
||||
`${this.getTranslation('DASHBOARD_PAGE.CHARTS.REVENUE')}: ${income}`,
|
||||
`${this.getTranslation('DASHBOARD_PAGE.CHARTS.EXPENSES')}: ${expense}`,
|
||||
`${this.getTranslation('DASHBOARD_PAGE.CHARTS.BONUS')}: ${bonus}`,
|
||||
`${this.getTranslation('DASHBOARD_PAGE.CHARTS.PROFIT')}: ${profit}`
|
||||
],
|
||||
// Names only on the arcs: the amount is the legend's column and the
|
||||
// tooltip formats its own, so baking the figure in here only made the
|
||||
// slice labels long enough to wrap.
|
||||
labels: series.map(({ label }) => label),
|
||||
datasets: [
|
||||
{
|
||||
data: [
|
||||
this.statistics.income,
|
||||
this.statistics.expense,
|
||||
this.statistics.bonus,
|
||||
this.statistics.profit
|
||||
],
|
||||
backgroundColor: [
|
||||
this.palette.revenue,
|
||||
this.palette.expenses,
|
||||
this.palette.bonus,
|
||||
this.palette.profit
|
||||
],
|
||||
hoverBorderColor: 'rgba(0, 0, 0, 0)',
|
||||
borderWidth: 1
|
||||
data: series.map(({ value }) => value),
|
||||
backgroundColor: series.map(({ color }) => color),
|
||||
// A gap in the surface colour, not a ring: a border drawn around
|
||||
// each arc reads as chrome, a gap reads as separation.
|
||||
borderColor: this.palette.surface,
|
||||
borderWidth: 2,
|
||||
hoverBorderColor: this.palette.surface,
|
||||
hoverOffset: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizes the tooltip content for a chart.
|
||||
* @param tooltipItem - The tooltip item containing information about the data point.
|
||||
* @returns The customized tooltip string.
|
||||
*/
|
||||
getTooltip(tooltipItem: TooltipItem<ChartType>) {
|
||||
// Initialize the tooltip with the label from tooltipItem
|
||||
let tooltip = tooltipItem.label;
|
||||
|
||||
// Return the customized tooltip
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates the local statistics variables with input employeeStatistics data.
|
||||
*/
|
||||
|
||||
+56
-58
@@ -1,7 +1,6 @@
|
||||
import { Component, Input, OnDestroy, OnInit, OnChanges, ViewChild } from '@angular/core';
|
||||
import { Component, ElementRef, inject, Input, OnDestroy, OnInit, OnChanges, ViewChild } from '@angular/core';
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { NbJSThemeOptions, NbThemeService } from '@nebular/theme';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { debounceTime, filter, tap } from 'rxjs/operators';
|
||||
import { BaseChartDirective } from 'ng2-charts';
|
||||
@@ -13,6 +12,14 @@ import { Store, months } from '@gauzy/ui-core/core';
|
||||
import { TranslationBaseComponent } from '@gauzy/ui-core/i18n';
|
||||
import { CurrencyPositionPipe } from '@gauzy/ui-core/shared';
|
||||
import { IEmployeeChartPalette, resolveEmployeeChartPalette } from '../employee-chart-palette';
|
||||
import {
|
||||
employeeChartBarDataset,
|
||||
employeeChartBase,
|
||||
employeeChartCategoryScale,
|
||||
employeeChartLegend,
|
||||
employeeChartTooltip,
|
||||
employeeChartValueScale
|
||||
} from '../employee-chart-options';
|
||||
|
||||
@UntilDestroy({ checkProperties: true })
|
||||
@Component({
|
||||
@@ -34,15 +41,39 @@ import { IEmployeeChartPalette, resolveEmployeeChartPalette } from '../employee-
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
/*
|
||||
* Fills the height the panel gives it rather than the old fixed
|
||||
* 20rem, which took no account of the legend above the plot or the
|
||||
* tick band below it.
|
||||
*/
|
||||
/*
|
||||
* position: relative is not decoration. With
|
||||
* maintainAspectRatio disabled, Chart.js sizes the canvas from its
|
||||
* OFFSET PARENT, and without a positioned ancestor it measures
|
||||
* against something further up the tree and under-sizes the plot,
|
||||
* leaving it small in the middle of the panel.
|
||||
*/
|
||||
.chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 20rem;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: auto;
|
||||
color: var(--gauzy-text-color-2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -97,14 +128,19 @@ export class EmployeeHorizontalBarChartComponent
|
||||
|
||||
@ViewChild(BaseChartDirective, { static: false }) baseChartDirective: BaseChartDirective;
|
||||
|
||||
constructor(
|
||||
public readonly translateService: TranslateService,
|
||||
private readonly _themeService: NbThemeService,
|
||||
private readonly _currencyPipe: CurrencyPipe,
|
||||
private readonly _currencyPositionPipe: CurrencyPositionPipe,
|
||||
private readonly _store: Store
|
||||
) {
|
||||
super(translateService);
|
||||
private readonly _themeService = inject(NbThemeService);
|
||||
private readonly _currencyPipe = inject(CurrencyPipe);
|
||||
private readonly _currencyPositionPipe = inject(CurrencyPositionPipe);
|
||||
private readonly _store = inject(Store);
|
||||
private readonly _elementRef: ElementRef<HTMLElement> = inject(ElementRef);
|
||||
|
||||
/**
|
||||
* Declared rather than inherited: `TranslationBaseComponent` takes an optional
|
||||
* `TranslateService` and falls back to `inject()`, and a component that inherits
|
||||
* a constructor with parameters from an undecorated base does not compile.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -140,57 +176,19 @@ export class EmployeeHorizontalBarChartComponent
|
||||
* @param config - The configuration options for the Chart, including theme variables.
|
||||
*/
|
||||
private _initializeChart(config: NbJSThemeOptions) {
|
||||
this.palette = resolveEmployeeChartPalette(config);
|
||||
this.palette = resolveEmployeeChartPalette(config, this._elementRef.nativeElement);
|
||||
|
||||
// Step 2: Set the overall chart options
|
||||
this.chartOptions = {
|
||||
responsive: true, // Makes the chart responsive
|
||||
maintainAspectRatio: false, // Allows adjusting the aspect ratio
|
||||
...employeeChartBase(),
|
||||
indexAxis: 'y',
|
||||
// Elements options apply to all of the options unless overridden in a dataset
|
||||
// In this case, we are setting the border of each horizontal bar to be 2px wide
|
||||
elements: {
|
||||
bar: {
|
||||
borderWidth: 2
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: this.palette.textColor,
|
||||
usePointStyle: false
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
// Define callback for tooltip labels
|
||||
callbacks: {
|
||||
title: () => ''
|
||||
}
|
||||
}
|
||||
legend: employeeChartLegend(this.palette),
|
||||
tooltip: employeeChartTooltip(this.palette, this.formatCurrency)
|
||||
},
|
||||
scales: {
|
||||
// Configure x-axis scale
|
||||
x: {
|
||||
grid: {
|
||||
display: true,
|
||||
color: this.palette.axisLineColor
|
||||
},
|
||||
ticks: {
|
||||
color: this.palette.textColor
|
||||
}
|
||||
},
|
||||
// Configure y-axis scale
|
||||
y: {
|
||||
grid: {
|
||||
display: true,
|
||||
color: this.palette.axisLineColor
|
||||
},
|
||||
ticks: {
|
||||
color: this.palette.textColor
|
||||
}
|
||||
}
|
||||
// Horizontal bars: x carries the money, y carries the months.
|
||||
x: employeeChartValueScale(this.palette),
|
||||
y: employeeChartCategoryScale(this.palette)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -270,8 +268,8 @@ export class EmployeeHorizontalBarChartComponent
|
||||
data: this.statistics.bonus // Data values for the bonus dataset
|
||||
}
|
||||
].map((dataset) => ({
|
||||
...dataset,
|
||||
borderWidth: 0 // Set the border width for each dataset to 0
|
||||
...employeeChartBarDataset(this.palette),
|
||||
...dataset
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
+180
-72
@@ -1,56 +1,178 @@
|
||||
import { Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, ElementRef, inject, Input, OnChanges, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { NbThemeService } from '@nebular/theme';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { IMonthAggregatedEmployeeStatistics } from '@gauzy/contracts';
|
||||
import { monthNames } from '@gauzy/ui-core/core';
|
||||
import { debounceTime, filter, tap } from 'rxjs/operators';
|
||||
import { environment } from '@gauzy/ui-config';
|
||||
import { CurrencyPosition, IMonthAggregatedEmployeeStatistics, IOrganization } from '@gauzy/contracts';
|
||||
import { distinctUntilChange } from '@gauzy/ui-core/common';
|
||||
import { Store, monthNames } from '@gauzy/ui-core/core';
|
||||
import { TranslationBaseComponent } from '@gauzy/ui-core/i18n';
|
||||
import { CurrencyPositionPipe } from '@gauzy/ui-core/shared';
|
||||
import { resolveEmployeeChartPalette } from '../employee-chart-palette';
|
||||
import {
|
||||
employeeChartBarDataset,
|
||||
employeeChartBase,
|
||||
employeeChartCategoryScale,
|
||||
employeeChartTooltip,
|
||||
employeeChartValueScale
|
||||
} from '../employee-chart-options';
|
||||
import { IEmployeeChartLegendItem } from '../employee-chart-legend/employee-chart-legend.component';
|
||||
|
||||
@UntilDestroy()
|
||||
@Component({
|
||||
selector: 'ga-employee-stacked-bar-chart',
|
||||
template: `
|
||||
@if (noData) {
|
||||
<div style="display: flex; flex-direction: column; align-items: center;">
|
||||
<div class="title">
|
||||
<nb-icon icon="info-outline"></nb-icon>
|
||||
<div>
|
||||
{{ 'DASHBOARD_PAGE.CHARTS.NO_MONTH_DATA' | translate }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!noData) {
|
||||
<canvas
|
||||
style="height: 20rem; width: 100%;"
|
||||
baseChart
|
||||
[type]="'bar'"
|
||||
[data]="data"
|
||||
[options]="options"
|
||||
></canvas>
|
||||
} @else {
|
||||
<div class="chart">
|
||||
<ga-employee-chart-legend
|
||||
class="chart-legend"
|
||||
[items]="legendItems"
|
||||
[orientation]="'row'"
|
||||
></ga-employee-chart-legend>
|
||||
<div class="chart-canvas">
|
||||
<canvas baseChart [type]="'bar'" [data]="data" [options]="options"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
/* The legend above, the bars filling everything left over. */
|
||||
.chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* The plot's own box, and position: relative on it is not
|
||||
* decoration: with maintainAspectRatio disabled, Chart.js sizes the
|
||||
* canvas from its OFFSET PARENT, so without a positioned ancestor it
|
||||
* measures against something further up the tree and draws a plot
|
||||
* that does not match the space it was given.
|
||||
*
|
||||
* min-height: 0 is what actually holds the chart inside the panel:
|
||||
* a flex child will not shrink below its content without it, so a
|
||||
* canvas asked to draw a dozen month rows would otherwise push the
|
||||
* section taller than the height it was given.
|
||||
*/
|
||||
.chart-canvas {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: auto;
|
||||
color: var(--gauzy-text-color-2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
`
|
||||
],
|
||||
providers: [CurrencyPipe, CurrencyPositionPipe],
|
||||
standalone: false
|
||||
})
|
||||
export class EmployeeStackedBarChartComponent extends TranslationBaseComponent implements OnInit, OnDestroy, OnChanges {
|
||||
data: any;
|
||||
options: any;
|
||||
proportion: number;
|
||||
incomeStatistics: number[] = [];
|
||||
expenseStatistics: number[] = [];
|
||||
profitStatistics: number[] = [];
|
||||
bonusStatistics: number[] = [];
|
||||
labels: string[] = [];
|
||||
selectedDate: Date;
|
||||
noData = false;
|
||||
|
||||
/**
|
||||
* The three series named above the bars.
|
||||
*
|
||||
* No amounts here, unlike the doughnut's legend: these bars are normalised so
|
||||
* each month's stack sums to that month's income, so a total printed beside a
|
||||
* series name would not be the quantity any bar is showing.
|
||||
*/
|
||||
legendItems: IEmployeeChartLegendItem[] = [];
|
||||
|
||||
/**
|
||||
* The unscaled figures behind the plotted ones, per dataset index
|
||||
* (0 = expenses, 1 = bonus, 2 = profit) then per month.
|
||||
*
|
||||
* The bars are normalised so each month's stack sums to that month's income —
|
||||
* the chart's job is composition, not magnitude — which means the plotted
|
||||
* number is a share and would be a lie if a tooltip printed it as money.
|
||||
*/
|
||||
private rawSeries: number[][] = [[], [], []];
|
||||
|
||||
@Input()
|
||||
employeeStatistics: IMonthAggregatedEmployeeStatistics[];
|
||||
|
||||
constructor(private themeService: NbThemeService, translateService: TranslateService) {
|
||||
super(translateService);
|
||||
public organization: IOrganization;
|
||||
|
||||
private readonly themeService = inject(NbThemeService);
|
||||
private readonly _elementRef: ElementRef<HTMLElement> = inject(ElementRef);
|
||||
private readonly _currencyPipe = inject(CurrencyPipe);
|
||||
private readonly _currencyPositionPipe = inject(CurrencyPositionPipe);
|
||||
private readonly _store = inject(Store);
|
||||
|
||||
/**
|
||||
* Declared rather than inherited: `TranslationBaseComponent` takes an optional
|
||||
* `TranslateService` and falls back to `inject()`, and a component that inherits
|
||||
* a constructor with parameters from an undecorated base does not compile.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the given value as currency.
|
||||
*
|
||||
* Same shape as the sibling charts', so a tooltip here and a tooltip on the
|
||||
* bar or doughnut chart cannot disagree about how money looks.
|
||||
*/
|
||||
formatCurrency = (value: number): string => {
|
||||
const currencyPosition = this.organization?.currencyPosition || CurrencyPosition.LEFT;
|
||||
const currency = this._currencyPipe.transform(
|
||||
value,
|
||||
this.organization?.currency || environment.DEFAULT_CURRENCY
|
||||
);
|
||||
return this._currencyPositionPipe.transform(currency, currencyPosition);
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this._store.selectedOrganization$
|
||||
.pipe(
|
||||
debounceTime(100),
|
||||
distinctUntilChange(),
|
||||
filter((organization: IOrganization) => !!organization),
|
||||
tap((organization: IOrganization) => (this.organization = organization)),
|
||||
untilDestroyed(this)
|
||||
)
|
||||
.subscribe();
|
||||
this._loadData();
|
||||
this._LoadChart();
|
||||
}
|
||||
@@ -65,83 +187,63 @@ export class EmployeeStackedBarChartComponent extends TranslationBaseComponent i
|
||||
.getJsTheme()
|
||||
.pipe(untilDestroyed(this))
|
||||
.subscribe((config) => {
|
||||
const palette = resolveEmployeeChartPalette(config);
|
||||
const palette = resolveEmployeeChartPalette(config, this._elementRef.nativeElement);
|
||||
const bonusColors = this.bonusStatistics.map((val) =>
|
||||
val < 0 ? palette.negativeBonus : palette.bonus
|
||||
);
|
||||
const profitColors = this.profitStatistics.map((val) =>
|
||||
val < 0 ? palette.negativeProfit : palette.profit
|
||||
);
|
||||
this.legendItems = [
|
||||
{ label: this.getTranslation('DASHBOARD_PAGE.CHARTS.EXPENSES'), color: palette.expenses },
|
||||
{ label: this.getTranslation('DASHBOARD_PAGE.CHARTS.BONUS'), color: palette.bonus },
|
||||
{ label: this.getTranslation('DASHBOARD_PAGE.CHARTS.PROFIT'), color: palette.profit }
|
||||
];
|
||||
|
||||
this.data = {
|
||||
labels: this.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: this.selectedDate
|
||||
? `${this.getTranslation('DASHBOARD_PAGE.CHARTS.EXPENSES')}: ${
|
||||
Math.round(+this.expenseStatistics * this.proportion * 100) / 100
|
||||
}`
|
||||
: this.getTranslation('DASHBOARD_PAGE.CHARTS.EXPENSES'),
|
||||
label: this.getTranslation('DASHBOARD_PAGE.CHARTS.EXPENSES'),
|
||||
...employeeChartBarDataset(palette),
|
||||
backgroundColor: palette.expenses,
|
||||
data: this.expenseStatistics
|
||||
},
|
||||
{
|
||||
label: this.selectedDate
|
||||
? `${this.getTranslation('DASHBOARD_PAGE.CHARTS.BONUS')}: ${
|
||||
Math.round(+this.bonusStatistics * this.proportion * 100) / 100
|
||||
}`
|
||||
: this.getTranslation('DASHBOARD_PAGE.CHARTS.BONUS'),
|
||||
label: this.getTranslation('DASHBOARD_PAGE.CHARTS.BONUS'),
|
||||
...employeeChartBarDataset(palette),
|
||||
backgroundColor: bonusColors,
|
||||
data: this.bonusStatistics
|
||||
},
|
||||
{
|
||||
label: this.selectedDate
|
||||
? `${this.getTranslation('DASHBOARD_PAGE.CHARTS.PROFIT')}: ${
|
||||
Math.round(+this.profitStatistics * this.proportion * 100) / 100
|
||||
}`
|
||||
: this.getTranslation('DASHBOARD_PAGE.CHARTS.PROFIT'),
|
||||
label: this.getTranslation('DASHBOARD_PAGE.CHARTS.PROFIT'),
|
||||
...employeeChartBarDataset(palette),
|
||||
backgroundColor: profitColors,
|
||||
data: this.profitStatistics
|
||||
}
|
||||
]
|
||||
};
|
||||
this.options = {
|
||||
...employeeChartBase(),
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
elements: {
|
||||
rectangle: {
|
||||
borderWidth: 2
|
||||
}
|
||||
plugins: {
|
||||
/*
|
||||
* The legend is `ga-employee-chart-legend`, rendered in HTML
|
||||
* above the canvas. A canvas legend is measured out of the same
|
||||
* rectangle the chart draws in, so on a panel this short it was
|
||||
* taking its band of height directly off the plot.
|
||||
*/
|
||||
legend: { display: false },
|
||||
tooltip: employeeChartTooltip(
|
||||
palette,
|
||||
this.formatCurrency,
|
||||
(item) => this.rawSeries[item.datasetIndex]?.[item.dataIndex] ?? 0
|
||||
)
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true
|
||||
},
|
||||
y: {
|
||||
stacked: true
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
onClick: (e) => e.stopPropagation(),
|
||||
position: 'right',
|
||||
labels: {
|
||||
fontColor: palette.textColor
|
||||
}
|
||||
},
|
||||
tooltips: this.selectedDate
|
||||
? {
|
||||
enabled: true,
|
||||
mode: 'dataset',
|
||||
callbacks: {
|
||||
label: function (tooltipItem, data) {
|
||||
const label = data.datasets[tooltipItem.datasetIndex].label || '';
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
enabled: true
|
||||
}
|
||||
x: employeeChartValueScale(palette, true),
|
||||
y: employeeChartCategoryScale(palette, true)
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -155,16 +257,22 @@ export class EmployeeStackedBarChartComponent extends TranslationBaseComponent i
|
||||
this.expenseStatistics = [];
|
||||
this.profitStatistics = [];
|
||||
this.bonusStatistics = [];
|
||||
this.rawSeries = [[], [], []];
|
||||
|
||||
this.noData = !(this.employeeStatistics || []).length;
|
||||
|
||||
(this.employeeStatistics || []).forEach((stat) => {
|
||||
const labelValue = `${monthNames[stat.month]} '${stat.year.toString(10).substring(2)}`;
|
||||
this.labels.push(labelValue);
|
||||
this.proportion = (stat.expense + stat.profit + stat.bonus) / stat.income || 1;
|
||||
this.expenseStatistics.push(Math.round((stat.expense / this.proportion) * 100) / 100);
|
||||
this.bonusStatistics.push(Math.round((stat.bonus / this.proportion) * 100) / 100);
|
||||
this.profitStatistics.push(Math.round((stat.profit / this.proportion) * 100) / 100);
|
||||
// Scoped to the month it describes — it was a field, which made it read
|
||||
// like chart-wide state when it is recomputed on every iteration.
|
||||
const proportion = (stat.expense + stat.profit + stat.bonus) / stat.income || 1;
|
||||
this.expenseStatistics.push(Math.round((stat.expense / proportion) * 100) / 100);
|
||||
this.bonusStatistics.push(Math.round((stat.bonus / proportion) * 100) / 100);
|
||||
this.profitStatistics.push(Math.round((stat.profit / proportion) * 100) / 100);
|
||||
this.rawSeries[0].push(stat.expense);
|
||||
this.rawSeries[1].push(stat.bonus);
|
||||
this.rawSeries[2].push(stat.profit);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './employee-chart-palette';
|
||||
export * from './employee-chart-legend/employee-chart-legend.component';
|
||||
export * from './employee-chart-options';
|
||||
export * from './employee-charts.component';
|
||||
export * from './employee-stacked-bar-chart/employee-stacked-bar-chart.component';
|
||||
export * from './employee-doughnut-chart/employee-doughnut-chart.component';
|
||||
|
||||
+332
-340
@@ -1,13 +1,15 @@
|
||||
@if (selectedEmployee && selectedEmployee.id) {
|
||||
<nb-card [nbSpinner]="loading" nbSpinnerStatus="primary">
|
||||
<nb-card-header class="header">
|
||||
<h4>
|
||||
<ngx-header-title>
|
||||
{{ 'DASHBOARD_PAGE.HUMAN_RESOURCES' | translate }}
|
||||
</ngx-header-title>
|
||||
</h4>
|
||||
<nb-card-header class="hr-header">
|
||||
<div class="hr-title-row">
|
||||
<h4>
|
||||
<ngx-header-title>
|
||||
{{ 'DASHBOARD_PAGE.HUMAN_RESOURCES' | translate }}
|
||||
</ngx-header-title>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@if (incomePermissionsError || expensePermissionError) {
|
||||
<nb-alert outline="danger">
|
||||
<nb-alert outline="danger" class="hr-alert">
|
||||
{{ 'ORGANIZATIONS_PAGE.PERMISSIONS.INSUFFICIENT' | translate }}
|
||||
@if (incomePermissionsError) {
|
||||
<span>{{ 'ORGANIZATIONS_PAGE.PERMISSIONS.ORG_INCOMES_VIEW' | translate }}</span>
|
||||
@@ -16,344 +18,334 @@
|
||||
}
|
||||
</nb-alert>
|
||||
}
|
||||
<div class="header-container">
|
||||
<div class="employee-info">
|
||||
<img class="employee-image" [src]="selectedEmployee.imageUrl" alt="Employee Avatar" />
|
||||
<div class="employee-details">
|
||||
<span class="employee-name">
|
||||
{{ selectedEmployee.fullName | truncate : 22 }}
|
||||
<nb-icon class="mr-1 edit-icon" icon="edit-outline" (click)="edit()"></nb-icon
|
||||
></span>
|
||||
<div class="employee-position" [innerText]="employeePosition"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@if (salary !== 0) {
|
||||
<div class="employee-details employee-salary">
|
||||
{{ 'DASHBOARD_PAGE.TITLE.SALARY' | translate }}:
|
||||
{{ salary | currency : defaultCurrency | position : selectedOrganization?.currencyPosition }}
|
||||
</div>
|
||||
} @if (selectedOrganization?.bonusType && averageBonus) {
|
||||
<div class="employee-details">
|
||||
{{ 'DASHBOARD_PAGE.DEVELOPER.AVERAGE_BONUS' | translate }}:
|
||||
{{ averageBonus | currency : defaultCurrency | position : selectedOrganization?.currencyPosition }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nb-card-header>
|
||||
<nb-card-body class="body">
|
||||
<div class="half-content">
|
||||
<div>
|
||||
<!-- Total Income block with non zero income -->
|
||||
@if (directIncomeBonus !== 0) {
|
||||
<ga-info-block
|
||||
accordion="true"
|
||||
title="{{ 'DASHBOARD_PAGE.DEVELOPER.TOTAL_INCOME' | translate }}"
|
||||
meta="{{
|
||||
'DASHBOARD_PAGE.TITLE.TOTAL_INCOME_CALC'
|
||||
| translate
|
||||
: {
|
||||
totalNonBonusIncome: defaultCurrency
|
||||
? (nonBonusIncome || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: nonBonusIncome || 0,
|
||||
totalBonusIncome: defaultCurrency
|
||||
? (directIncomeBonus || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: directIncomeBonus || 0
|
||||
}
|
||||
}}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (income || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: income || 0
|
||||
}}"
|
||||
color="var(--gauzy-action-success-text)"
|
||||
(openInfo)="openHistoryDialog('INCOME')"
|
||||
>
|
||||
<!-- Expanded Accordion Income info block -->
|
||||
<ga-info-block
|
||||
listItem="true"
|
||||
title="{{ 'INCOME_PAGE.INCOME' | translate }}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (nonBonusIncome
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: nonBonusIncome
|
||||
}}"
|
||||
color="var(--gauzy-action-success-text)"
|
||||
(openInfo)="openHistoryDialog('NON_BONUS_INCOME')"
|
||||
>
|
||||
</ga-info-block>
|
||||
|
||||
<!-- Expanded Accordion Direct Income info block -->
|
||||
<ga-info-block
|
||||
listItem="true"
|
||||
title="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_DIRECT_INCOME' | translate }}"
|
||||
meta="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_DIRECT_INCOME_INFO' | translate }}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (directIncomeBonus
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: directIncomeBonus
|
||||
}}"
|
||||
color="var(--gauzy-action-success-text)"
|
||||
(openInfo)="openHistoryDialog('BONUS_INCOME')"
|
||||
>
|
||||
</ga-info-block>
|
||||
</ga-info-block>
|
||||
} @else {
|
||||
<ga-info-block
|
||||
title="{{ 'DASHBOARD_PAGE.DEVELOPER.TOTAL_INCOME' | translate }}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (income || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: income || 0
|
||||
}}"
|
||||
color="var(--gauzy-action-success-text)"
|
||||
(openInfo)="openHistoryDialog('INCOME')"
|
||||
>
|
||||
</ga-info-block>
|
||||
}
|
||||
<!-- Expenses without salary block -->
|
||||
<ga-info-block
|
||||
title="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_EXPENSES_WITHOUT_SALARY' | translate }}"
|
||||
meta="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_EXPENSES_WITHOUT_SALARY_CALC' | translate }}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (expenseWithoutSalary || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: expenseWithoutSalary || 0
|
||||
}}"
|
||||
color="var(--gauzy-action-warning-text)"
|
||||
(openInfo)="openHistoryDialog('EXPENSES_WITHOUT_SALARY')"
|
||||
>
|
||||
</ga-info-block>
|
||||
<!-- Total Expenses block -->
|
||||
<ga-info-block
|
||||
title="{{ 'DASHBOARD_PAGE.DEVELOPER.TOTAL_EXPENSES' | translate }}"
|
||||
meta="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_EXPENSE_CALC' | translate }}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (expense || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: expense || 0
|
||||
}}"
|
||||
color="var(--gauzy-action-warning-text)"
|
||||
(openInfo)="openHistoryDialog('EXPENSES')"
|
||||
>
|
||||
</ga-info-block>
|
||||
<!-- Profit block -->
|
||||
<ga-info-block
|
||||
title="{{ 'DASHBOARD_PAGE.DEVELOPER.PROFIT' | translate }}"
|
||||
meta="{{
|
||||
'DASHBOARD_PAGE.DEVELOPER.PROFIT_CALC'
|
||||
| translate
|
||||
: {
|
||||
totalAllIncome: defaultCurrency
|
||||
? (income || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: income || 0,
|
||||
totalExpense: defaultCurrency
|
||||
? (expense || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: expense || 0
|
||||
}
|
||||
}}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (profit || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: profit || 0
|
||||
}}"
|
||||
color="{{ profit >= 0 ? 'var(--gauzy-text-color-1)' : 'var(--gauzy-action-danger-text)' }}"
|
||||
blockType="highlight"
|
||||
(openInfo)="openProfitDialog()"
|
||||
>
|
||||
</ga-info-block>
|
||||
<!-- Direct Income Bonus block -->
|
||||
@if ( selectedOrganization?.bonusType && directIncomeBonus !== 0 ) {
|
||||
<ga-info-block
|
||||
title="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_DIRECT_BONUS' | translate }}"
|
||||
meta="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_DIRECT_BONUS_INFO' | translate }}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (directIncomeBonus || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: directIncomeBonus || 0
|
||||
}}"
|
||||
color="var(--gauzy-action-info-text)"
|
||||
(openInfo)="openHistoryDialog('BONUS_INCOME')"
|
||||
>
|
||||
</ga-info-block>
|
||||
}
|
||||
<!-- Bonus block: Profit Based Bonus -->
|
||||
@if ( bonusType === 'PROFIT_BASED_BONUS' && calculatedBonus !== 0 ) {
|
||||
<ga-info-block
|
||||
title="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_PROFIT_BONUS' | translate }}"
|
||||
meta="{{
|
||||
'DASHBOARD_PAGE.TITLE.TOTAL_PROFIT_BONUS_INFO'
|
||||
| translate
|
||||
: {
|
||||
bonusPercentage: bonusPercentage || 0,
|
||||
difference: defaultCurrency
|
||||
? (profit || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: profit || 0
|
||||
}
|
||||
}}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (calculatedBonus || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: calculatedBonus || 0
|
||||
}}"
|
||||
color="{{ calculatedBonus >= 0 ? 'var(--gauzy-action-info-text)' : 'var(--gauzy-action-danger-text)' }}"
|
||||
>
|
||||
</ga-info-block>
|
||||
}
|
||||
<!-- Bonus block: Revenue Based Bonus -->
|
||||
@if ( bonusType === 'REVENUE_BASED_BONUS' && calculatedBonus !== 0 ) {
|
||||
<ga-info-block
|
||||
title="{{ 'DASHBOARD_PAGE.TITLE.TOTAL_INCOME_BONUS' | translate }}"
|
||||
meta="{{
|
||||
'DASHBOARD_PAGE.TITLE.TOTAL_INCOME_BONUS_INFO'
|
||||
| translate
|
||||
: {
|
||||
bonusPercentage: bonusPercentage || 0,
|
||||
totalIncome: defaultCurrency
|
||||
? (income || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: income || 0
|
||||
}
|
||||
}}"
|
||||
value="{{
|
||||
defaultCurrency
|
||||
? (calculatedBonus || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: calculatedBonus || 0
|
||||
}}"
|
||||
color="{{ calculatedBonus >= 0 ? 'var(--gauzy-action-info-text)' : 'var(--gauzy-action-danger-text)' }}"
|
||||
>
|
||||
</ga-info-block>
|
||||
}
|
||||
<!-- End of info blocks -->
|
||||
@if (selectedOrganization?.bonusType) {
|
||||
<div class="bonus">
|
||||
<div class="bonus-value">
|
||||
<!-- CASE: Total Bonus calculation breakup with non zero income bonus -->
|
||||
@if (directIncomeBonus !== 0) {
|
||||
<div>
|
||||
{{
|
||||
'DASHBOARD_PAGE.TITLE.TOTAL_BONUS_CALC'
|
||||
| translate
|
||||
: {
|
||||
totalBonusIncome: defaultCurrency
|
||||
? (directIncomeBonus
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: directIncomeBonus,
|
||||
calculatedBonus: defaultCurrency
|
||||
? (calculatedBonus || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: calculatedBonus || 0
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
<!-- CASE: Total Bonus calculation breakup with zero income bonus -->
|
||||
@if ( directIncomeBonus === 0 && bonusType === 'REVENUE_BASED_BONUS' ) {
|
||||
<div>
|
||||
<!-- Revenue based bonus -->
|
||||
{{
|
||||
'DASHBOARD_PAGE.TITLE.TOTAL_INCOME_BONUS_INFO'
|
||||
| translate
|
||||
: {
|
||||
bonusPercentage: bonusPercentage || 0,
|
||||
totalIncome: defaultCurrency
|
||||
? (income || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: income || 0
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
} @else if ( bonusType === 'PROFIT_BASED_BONUS' && directIncomeBonus === 0 ) {
|
||||
<div>
|
||||
<!-- Profit based bonus -->
|
||||
{{
|
||||
'DASHBOARD_PAGE.TITLE.TOTAL_PROFIT_BONUS_INFO'
|
||||
| translate
|
||||
: {
|
||||
bonusPercentage: bonusPercentage || 0,
|
||||
difference: defaultCurrency
|
||||
? (profit || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition)
|
||||
: profit || 0
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
} @if (defaultCurrency) {
|
||||
<div [class.negative-bonus-color]="bonus < 0">
|
||||
{{ 'DASHBOARD_PAGE.DEVELOPER.BONUS' | translate }}
|
||||
:
|
||||
{{
|
||||
bonus || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition
|
||||
}}
|
||||
</div>
|
||||
|
||||
@if (selectedEmployee?.id) {
|
||||
<!--
|
||||
Identity bar: who the figures below belong to, and the two contract
|
||||
numbers that frame them. Rendered inside the header so it stays with
|
||||
the page title rather than competing with the KPI row for attention.
|
||||
-->
|
||||
<div class="identity-bar">
|
||||
<div class="identity">
|
||||
<span class="identity-avatar">
|
||||
@if (selectedEmployee.imageUrl) {
|
||||
<img [src]="selectedEmployee.imageUrl" [alt]="selectedEmployee.fullName" />
|
||||
} @else {
|
||||
<span class="identity-initials" aria-hidden="true">{{ employeeInitials }}</span>
|
||||
}
|
||||
</span>
|
||||
<span class="identity-text">
|
||||
<span class="identity-name">
|
||||
{{ selectedEmployee.fullName | truncate : 28 }}
|
||||
<button
|
||||
type="button"
|
||||
class="identity-edit"
|
||||
(click)="edit()"
|
||||
[attr.aria-label]="'BUTTONS.EDIT' | translate"
|
||||
[nbTooltip]="'BUTTONS.EDIT' | translate"
|
||||
nbTooltipTrigger="hover"
|
||||
>
|
||||
<nb-icon icon="edit-outline"></nb-icon>
|
||||
</button>
|
||||
</span>
|
||||
@if (employeeTraits.length) {
|
||||
<span class="identity-traits">
|
||||
<!--
|
||||
TODO: handle non-defaultCurrency case differently.
|
||||
currently same thing is rendered in both cases
|
||||
-->
|
||||
} @else {
|
||||
<div [class.negative-bonus-color]="bonus < 0">
|
||||
{{ 'DASHBOARD_PAGE.DEVELOPER.BONUS' | translate }}
|
||||
:
|
||||
{{
|
||||
bonus || 0
|
||||
| currency : defaultCurrency
|
||||
| position : selectedOrganization?.currencyPosition
|
||||
}}
|
||||
</div>
|
||||
Tracked by position, not by value: the two traits are the
|
||||
employee's short description and their level, and nothing stops
|
||||
those being the same string ("Senior", say, in both fields).
|
||||
`track trait` would then hand Angular two identical keys for one
|
||||
list and it would throw NG0955 instead of rendering the chips.
|
||||
-->
|
||||
@for (trait of employeeTraits; track $index) {
|
||||
<span class="trait-chip">{{ trait }}</span>
|
||||
}
|
||||
</div>
|
||||
<!-- Note for negative total bonus -->
|
||||
@if (bonus < 0) {
|
||||
<div class="bonus-disclaimer">
|
||||
{{ 'DASHBOARD_PAGE.DEVELOPER.NOTE' | translate }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="identity-facts">
|
||||
@if (salary) {
|
||||
<div class="fact">
|
||||
<dt>{{ 'DASHBOARD_PAGE.TITLE.SALARY' | translate }}</dt>
|
||||
<dd>{{ figures.salary }}</dd>
|
||||
</div>
|
||||
} @if (hasBonus && averageBonus) {
|
||||
<div class="fact">
|
||||
<dt>{{ 'DASHBOARD_PAGE.DEVELOPER.AVERAGE_BONUS' | translate }}</dt>
|
||||
<dd>{{ figures.averageBonus }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</nb-card-header>
|
||||
|
||||
<nb-card-body class="hr-body">
|
||||
@if (!selectedEmployee?.id) {
|
||||
<!--
|
||||
Gated on `loading` (which starts true) rather than on the employee
|
||||
alone: the selector streams are debounced, so on the very first paint
|
||||
nothing is resolved yet and claiming "no employee selected" there
|
||||
would flash on every visit.
|
||||
-->
|
||||
@if (!loading) {
|
||||
<div class="hr-state">
|
||||
<nb-icon icon="person-outline" aria-hidden="true"></nb-icon>
|
||||
<p class="state-title">{{ 'DASHBOARD_PAGE.BUILDER.WIDGETS.HR.NO_EMPLOYEE' | translate }}</p>
|
||||
<p class="state-hint">{{ 'DASHBOARD_PAGE.BUILDER.WIDGETS.HR.NO_EMPLOYEE_HINT' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- ── KPI row ──────────────────────────────────────────────────────
|
||||
The four totals the page exists to answer, each a button because each
|
||||
opens the records behind it.
|
||||
|
||||
A tile carries a label, its figure, and the arithmetic behind that
|
||||
figure — the `meta` line `ga-info-block` printed under every title
|
||||
before this page was rebuilt. It is body text, not a tooltip: these
|
||||
are the numbers the page is read for, and a figure a reader has to
|
||||
hover to find is a figure that has been taken away.
|
||||
|
||||
The section is deliberately unnamed. An `aria-label` here would
|
||||
publish a second landmark called "Employee Statistics" alongside the
|
||||
chart panel that already carries that title, and each tile names
|
||||
itself through its own `aria-label`.
|
||||
-->
|
||||
<section class="kpi-row">
|
||||
<button
|
||||
type="button"
|
||||
class="kpi kpi--success"
|
||||
(click)="openHistoryDialog('INCOME')"
|
||||
[attr.aria-label]="('DASHBOARD_PAGE.DEVELOPER.TOTAL_INCOME' | translate) + ': ' + figures.income"
|
||||
>
|
||||
<span class="kpi-icon" aria-hidden="true"><nb-icon icon="trending-up-outline"></nb-icon></span>
|
||||
<span class="kpi-text">
|
||||
<span class="kpi-label">{{ 'DASHBOARD_PAGE.DEVELOPER.TOTAL_INCOME' | translate }}</span>
|
||||
<span class="kpi-value">{{ figures.income }}</span>
|
||||
<span class="kpi-meta">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_INCOME_CALC' | translate : { totalNonBonusIncome: figures.nonBonusIncome, totalBonusIncome: figures.directIncomeBonus } }}</span>
|
||||
</span>
|
||||
<nb-icon class="kpi-affordance" icon="chevron-right-outline" aria-hidden="true"></nb-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="kpi kpi--warning"
|
||||
(click)="openHistoryDialog('EXPENSES')"
|
||||
[attr.aria-label]="
|
||||
('DASHBOARD_PAGE.DEVELOPER.TOTAL_EXPENSES' | translate) + ': ' + figures.expense
|
||||
"
|
||||
>
|
||||
<span class="kpi-icon" aria-hidden="true"><nb-icon icon="trending-down-outline"></nb-icon></span>
|
||||
<span class="kpi-text">
|
||||
<span class="kpi-label">{{ 'DASHBOARD_PAGE.DEVELOPER.TOTAL_EXPENSES' | translate }}</span>
|
||||
<span class="kpi-value">{{ figures.expense }}</span>
|
||||
<span class="kpi-meta">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_EXPENSE_CALC' | translate }}</span>
|
||||
</span>
|
||||
<nb-icon class="kpi-affordance" icon="chevron-right-outline" aria-hidden="true"></nb-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="kpi kpi--info"
|
||||
[class.is-negative]="profit < 0"
|
||||
(click)="openProfitDialog()"
|
||||
[attr.aria-label]="('DASHBOARD_PAGE.DEVELOPER.PROFIT' | translate) + ': ' + figures.profit"
|
||||
>
|
||||
<span class="kpi-icon" aria-hidden="true"><nb-icon icon="pie-chart-outline"></nb-icon></span>
|
||||
<span class="kpi-text">
|
||||
<span class="kpi-label">{{ 'DASHBOARD_PAGE.DEVELOPER.PROFIT' | translate }}</span>
|
||||
<span class="kpi-value">{{ figures.profit }}</span>
|
||||
<span class="kpi-meta">{{ 'DASHBOARD_PAGE.DEVELOPER.PROFIT_CALC' | translate : { totalAllIncome: figures.income, totalExpense: figures.expense } }}</span>
|
||||
</span>
|
||||
<nb-icon class="kpi-affordance" icon="chevron-right-outline" aria-hidden="true"></nb-icon>
|
||||
</button>
|
||||
|
||||
@if (hasBonus) {
|
||||
<!--
|
||||
Static, not a button: there is no `BONUS` history type on
|
||||
`EmployeeStatisticsHistoryEnum`, so this tile has nothing to open.
|
||||
The two components it is made of are drilled into from the
|
||||
breakdown panel below instead.
|
||||
-->
|
||||
<div
|
||||
class="kpi kpi--accent kpi--static"
|
||||
[class.is-negative]="bonus < 0"
|
||||
>
|
||||
<span class="kpi-icon" aria-hidden="true"><nb-icon icon="award-outline"></nb-icon></span>
|
||||
<span class="kpi-text">
|
||||
<span class="kpi-label">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_BONUS' | translate }}</span>
|
||||
<span class="kpi-value">{{ figures.bonus }}</span>
|
||||
<span class="kpi-meta">{{ bonusFormula ? (bonusFormula.key | translate : bonusFormula.params) : '' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="hr-panels">
|
||||
<!-- ── Breakdown ────────────────────────────────────────────────
|
||||
What each KPI above is made of, grouped under section headings so
|
||||
a row can be found by the question it answers.
|
||||
|
||||
Each row prints its own formula under its label, as the
|
||||
info-block list this replaced did. Several of those lines carry
|
||||
figures that appear nowhere else on the page — the bonus
|
||||
percentage among them — so they cannot live on a tooltip.
|
||||
-->
|
||||
<section class="panel">
|
||||
<header class="panel-header">
|
||||
<h5 class="panel-title">{{ 'DASHBOARD_PAGE.TITLE.BREAKDOWN' | translate }}</h5>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<h6 class="group-title">{{ 'INCOME_PAGE.INCOME' | translate }}</h6>
|
||||
<button
|
||||
type="button"
|
||||
class="stat-row"
|
||||
(click)="openHistoryDialog('NON_BONUS_INCOME')"
|
||||
>
|
||||
<span class="stat-row-text">
|
||||
<span class="stat-row-label">{{ 'INCOME_PAGE.INCOME' | translate }}</span>
|
||||
<span class="stat-row-meta">{{ 'DASHBOARD_PAGE.BUILDER.WIDGETS.HR.INCOME.META' | translate }}</span>
|
||||
</span>
|
||||
<span class="stat-row-value">{{ figures.nonBonusIncome }}</span>
|
||||
<nb-icon class="stat-row-chevron" icon="chevron-right-outline" aria-hidden="true"></nb-icon>
|
||||
</button>
|
||||
|
||||
@if (directIncomeBonus !== 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="stat-row"
|
||||
(click)="openHistoryDialog('BONUS_INCOME')"
|
||||
>
|
||||
<span class="stat-row-text">
|
||||
<span class="stat-row-label">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_DIRECT_INCOME' | translate }}</span>
|
||||
<span class="stat-row-meta">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_DIRECT_INCOME_INFO' | translate }}</span>
|
||||
</span>
|
||||
<span class="stat-row-value">{{ figures.directIncomeBonus }}</span>
|
||||
<nb-icon class="stat-row-chevron" icon="chevron-right-outline" aria-hidden="true"></nb-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<h6 class="group-title">{{ 'EXPENSES_PAGE.EXPENSES' | translate }}</h6>
|
||||
<button
|
||||
type="button"
|
||||
class="stat-row"
|
||||
(click)="openHistoryDialog('EXPENSES_WITHOUT_SALARY')"
|
||||
>
|
||||
<span class="stat-row-text">
|
||||
<span class="stat-row-label">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_EXPENSES_WITHOUT_SALARY' | translate }}</span>
|
||||
<span class="stat-row-meta">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_EXPENSES_WITHOUT_SALARY_CALC' | translate }}</span>
|
||||
</span>
|
||||
<span class="stat-row-value">{{ figures.expenseWithoutSalary }}</span>
|
||||
<nb-icon class="stat-row-chevron" icon="chevron-right-outline" aria-hidden="true"></nb-icon>
|
||||
</button>
|
||||
|
||||
@if (salary) {
|
||||
<div class="stat-row is-static">
|
||||
<span class="stat-row-label">{{ 'DASHBOARD_PAGE.TITLE.SALARY' | translate }}</span>
|
||||
<span class="stat-row-value">{{ figures.salary }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasBonus) {
|
||||
<h6 class="group-title">{{ 'DASHBOARD_PAGE.DEVELOPER.BONUS' | translate }}</h6>
|
||||
|
||||
@if (directIncomeBonus !== 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="stat-row"
|
||||
(click)="openHistoryDialog('BONUS_INCOME')"
|
||||
>
|
||||
<span class="stat-row-text">
|
||||
<span class="stat-row-label">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_DIRECT_BONUS' | translate }}</span>
|
||||
<span class="stat-row-meta">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_DIRECT_BONUS_INFO' | translate }}</span>
|
||||
</span>
|
||||
<span class="stat-row-value">{{ figures.directIncomeBonus }}</span>
|
||||
<nb-icon class="stat-row-chevron" icon="chevron-right-outline" aria-hidden="true"></nb-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (bonusType === 'PROFIT_BASED_BONUS' && calculatedBonus !== 0) {
|
||||
<div
|
||||
class="stat-row is-static"
|
||||
[class.is-negative]="calculatedBonus < 0"
|
||||
>
|
||||
<span class="stat-row-text">
|
||||
<span class="stat-row-label">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_PROFIT_BONUS' | translate }}</span>
|
||||
<span class="stat-row-meta">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_PROFIT_BONUS_INFO' | translate : { bonusPercentage: bonusPercentage || 0, difference: figures.profit } }}</span>
|
||||
</span>
|
||||
<span class="stat-row-value">{{ figures.calculatedBonus }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (bonusType === 'REVENUE_BASED_BONUS' && calculatedBonus !== 0) {
|
||||
<div
|
||||
class="stat-row is-static"
|
||||
[class.is-negative]="calculatedBonus < 0"
|
||||
>
|
||||
<span class="stat-row-text">
|
||||
<span class="stat-row-label">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_INCOME_BONUS' | translate }}</span>
|
||||
<span class="stat-row-meta">{{ 'DASHBOARD_PAGE.TITLE.TOTAL_INCOME_BONUS_INFO' | translate : { bonusPercentage: bonusPercentage || 0, totalIncome: figures.income } }}</span>
|
||||
</span>
|
||||
<span class="stat-row-value">{{ figures.calculatedBonus }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (bonus < 0) {
|
||||
<p class="panel-note" role="note">
|
||||
<nb-icon icon="info-outline" aria-hidden="true"></nb-icon>
|
||||
<span>{{ 'DASHBOARD_PAGE.DEVELOPER.NOTE' | translate }}</span>
|
||||
</p>
|
||||
} }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="half-content">
|
||||
<ga-employee-charts [employeeStatistics]="employeeStatistics"></ga-employee-charts>
|
||||
</section>
|
||||
|
||||
<!-- ── Employee statistics ──────────────────────────────────────
|
||||
The same figures over time.
|
||||
|
||||
The chart-type switcher sits in the panel header, on the title's
|
||||
line — `ga-employee-charts` used to render it right-aligned above
|
||||
its own canvas, which produced a second, unanchored header row
|
||||
directly beneath this one.
|
||||
|
||||
`ga-employee-charts` is rendered UNCONDITIONALLY, exactly as it
|
||||
was before this page was redesigned. Each chart inside it already
|
||||
draws its own "no month data" state off `employeeStatistics`, so
|
||||
gating this on the totals being non-zero (as an earlier pass did)
|
||||
only managed to hide the charts outright whenever a period summed
|
||||
to zero — which is every period on a freshly seeded organization.
|
||||
-->
|
||||
<section class="panel panel--chart">
|
||||
<header class="panel-header">
|
||||
<h5 class="panel-title">{{ 'DASHBOARD_PAGE.EMPLOYEE_STATISTICS' | translate }}</h5>
|
||||
<nb-select
|
||||
class="chart-switcher"
|
||||
size="small"
|
||||
status="basic"
|
||||
[(selected)]="selectedChart"
|
||||
[attr.aria-label]="'DASHBOARD_PAGE.CHARTS.CHART_TYPE' | translate"
|
||||
>
|
||||
<nb-option [value]="EmployeeChartEnum.BAR">
|
||||
{{ 'DASHBOARD_PAGE.CHARTS.BAR' | translate }}
|
||||
</nb-option>
|
||||
<nb-option [value]="EmployeeChartEnum.DOUGHNUT">
|
||||
{{ 'DASHBOARD_PAGE.CHARTS.DOUGHNUT' | translate }}
|
||||
</nb-option>
|
||||
<nb-option [value]="EmployeeChartEnum.STACKED_BAR">
|
||||
{{ 'DASHBOARD_PAGE.CHARTS.STACKED_BAR' | translate }}
|
||||
</nb-option>
|
||||
</nb-select>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<ga-employee-charts
|
||||
[employeeStatistics]="employeeStatistics"
|
||||
[selectedChart]="selectedChart"
|
||||
></ga-employee-charts>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
}
|
||||
|
||||
+782
-152
@@ -1,164 +1,794 @@
|
||||
@use 'gauzy/_gauzy-cards' as *;
|
||||
|
||||
.header {
|
||||
background-color: unset;
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
/*
|
||||
* ── HR dashboard ─────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Reads top-down as: who → the four totals → what they are made of, beside the
|
||||
* same figures over time. The page used to be a two-column split where the left
|
||||
* column was one undifferentiated stack of label/value rows, so the totals had
|
||||
* no more weight than their own components and there was nothing to scan.
|
||||
*
|
||||
* Everything here paints with the shared gauzy-* tokens (surfaces, borders, the
|
||||
* four action accents) rather than literal colours, so all eight registered
|
||||
* themes and both canvases follow without a per-theme branch.
|
||||
*/
|
||||
|
||||
.employee-info,
|
||||
.org-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-width: 0;
|
||||
// One rhythm for the whole page, so panel padding, grid gutters and row spacing
|
||||
// are not each invented separately.
|
||||
$gap-lg: 1rem;
|
||||
$gap-md: 0.75rem;
|
||||
$gap-sm: 0.5rem;
|
||||
$gap-xs: 0.25rem;
|
||||
|
||||
.employee-image,
|
||||
.org-image {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
object-fit: cover;
|
||||
margin-right: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
// Below this the two content panels stop being readable side by side (the chart
|
||||
// canvas needs roughly half of it on its own) and stack instead.
|
||||
$panels-breakpoint: 62rem;
|
||||
|
||||
.employee-details,
|
||||
.org-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
// The KPI row drops from four across to two, then one, on its own terms — the
|
||||
// tiles have a hard minimum width, so `auto-fit` handles it without a query.
|
||||
$kpi-min-width: 13.5rem;
|
||||
|
||||
.employee-name,
|
||||
.org-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-weight: 600;
|
||||
font-size: nb-theme(text-subtitle-font-size);
|
||||
line-height: 1.25rem;
|
||||
// `.employee-details` now also styles the muted meta row, so the name has to
|
||||
// state its own colour instead of inheriting the hint colour from it.
|
||||
color: nb-theme(text-basic-color);
|
||||
}
|
||||
// Height of the chart-type switcher in the Employee Statistics header. The
|
||||
// panel header's min-height is derived from it so both panels' headers match.
|
||||
$switcher-height: 1.875rem;
|
||||
|
||||
.employee-position,
|
||||
.org-position {
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
}
|
||||
// ── Type weight ─────────────────────────────────────────────────────────────
|
||||
// THREE steps, and only three. An earlier pass set nearly every element on the
|
||||
// page to 600 — the employee name, both panel titles, the group headings, the
|
||||
// KPI values, the breakdown figures, the contract figures — and when everything
|
||||
// is semibold nothing reads as emphasis; the page just looks heavy.
|
||||
//
|
||||
// Hierarchy is carried by size and by the muted/strong text tokens instead, and
|
||||
// weight is spent on one thing: the four headline numbers.
|
||||
$weight-strong: 600; // KPI values, and nothing else
|
||||
$weight-medium: 500; // titles, headings, secondary figures
|
||||
$weight-normal: 400; // labels, captions, chips
|
||||
|
||||
.edit-icon {
|
||||
cursor: pointer;
|
||||
height: 0.875rem;
|
||||
width: 0.875rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
}
|
||||
}
|
||||
|
||||
.employee-details {
|
||||
font-size: nb-theme(gauzy-table-font-size);
|
||||
color: var(--gauzy-text-color-2);
|
||||
}
|
||||
|
||||
.employee-salary {
|
||||
font-size: nb-theme(text-heading-6-font-size);
|
||||
font-weight: 600;
|
||||
color: var(--gauzy-action-info-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.open {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
|
||||
.half-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
ga-info-block {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
.bonus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.bonus-value {
|
||||
margin-right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
|
||||
:first-child {
|
||||
text-align: right;
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:last-child {
|
||||
font-size: nb-theme(text-heading-4-font-size);
|
||||
font-weight: 500;
|
||||
line-height: 2rem;
|
||||
color: var(--gauzy-action-info-text);
|
||||
}
|
||||
|
||||
.negative-bonus-color {
|
||||
color: var(--gauzy-action-danger-text);
|
||||
}
|
||||
}
|
||||
|
||||
.bonus-disclaimer {
|
||||
margin-top: 0.5rem;
|
||||
margin-right: 0;
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
max-width: 16rem;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
:host {
|
||||
// `unset, auto`: the dashboard page owns the single scrollbar now (see
|
||||
// `../dashboard.component.scss`), so this card must not open a second one.
|
||||
// The viewport-derived height it used to be pinned to was a hand-tuned guess
|
||||
// that the density pass and the new chips row both invalidated.
|
||||
@include nb-card_overrides(
|
||||
unset,
|
||||
auto,
|
||||
0 $default-radius $default-radius $default-radius
|
||||
);
|
||||
// Must stay AFTER `nb-card_overrides`, which emits its own `:host nb-card`
|
||||
// with `height: 100%` at the same specificity — the later block wins.
|
||||
nb-card {
|
||||
height: auto;
|
||||
}
|
||||
// `unset, auto`: the dashboard page owns the single scrollbar (see
|
||||
// `../dashboard.component.scss`), so this card must not open a second one.
|
||||
@include nb-card_overrides(unset, auto, 0 $default-radius $default-radius $default-radius);
|
||||
|
||||
nb-card,
|
||||
nb-card-body{
|
||||
background-color: var(--gauzy-card-2);
|
||||
}
|
||||
// Must stay AFTER `nb-card_overrides`, which emits its own `:host nb-card`
|
||||
// with `height: 100%` at the same specificity — the later block wins.
|
||||
nb-card {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
nb-card,
|
||||
nb-card-body {
|
||||
background-color: var(--gauzy-card-2);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.hr-header {
|
||||
background-color: unset;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $gap-md;
|
||||
}
|
||||
|
||||
.hr-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: nb-theme(gauzy-page-title-row-height);
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hr-alert {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $gap-xs;
|
||||
}
|
||||
|
||||
/*
|
||||
* Identity bar: a surface of its own rather than loose text on the header, so
|
||||
* the employee the figures belong to is stated once and unmistakably.
|
||||
*/
|
||||
.identity-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: $gap-lg;
|
||||
padding: $gap-md;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
background-color: var(--gauzy-card-1);
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $gap-md;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-avatar {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
overflow: hidden;
|
||||
// The fallback's own ground; also what shows through a transparent avatar.
|
||||
background-color: var(--gauzy-active-tint);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.identity-initials {
|
||||
font-size: 0.75rem;
|
||||
font-weight: $weight-medium;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--gauzy-text-color-1);
|
||||
}
|
||||
|
||||
.identity-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $gap-xs;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.identity-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $gap-xs;
|
||||
font-size: 0.75rem;
|
||||
// Medium, not semibold: it is already the largest and darkest text in the
|
||||
// bar, and the page title sits directly above it.
|
||||
font-weight: $weight-medium;
|
||||
line-height: 1.25rem;
|
||||
color: nb-theme(text-basic-color);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* A real `<button>`, not an icon with a click handler: it is the only way into
|
||||
* the employee's profile from this page, and it has to be reachable by keyboard
|
||||
* and announced as an action.
|
||||
*/
|
||||
.identity-edit {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// Visually 1.25rem, but padded out to a comfortable hit area — the icon is
|
||||
// well under any sane minimum on its own.
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
color: var(--gauzy-text-color-2);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease-in-out, color 0.12s ease-in-out;
|
||||
|
||||
nb-icon {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gauzy-hover-tint);
|
||||
color: var(--gauzy-text-color-1);
|
||||
}
|
||||
}
|
||||
|
||||
.identity-traits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $gap-xs;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trait-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
background-color: var(--gauzy-hover-tint);
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
font-weight: $weight-normal;
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* Contract figures — a definition list, because that is what they are: two
|
||||
* term/value pairs, not a sentence and not a stat tile (they do not change with
|
||||
* the selected period the way the KPI row does).
|
||||
*/
|
||||
.identity-facts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $gap-lg;
|
||||
margin: 0;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.fact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
// Values read as a column of money, so they align on the end regardless
|
||||
// of how wide the label above them is.
|
||||
align-items: flex-end;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
font-weight: $weight-normal;
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $weight-medium;
|
||||
line-height: 1.25rem;
|
||||
color: var(--gauzy-text-color-1);
|
||||
// Money lines up column-wise only with fixed-advance digits.
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Body ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.hr-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $gap-lg;
|
||||
// The body is empty while the header selectors resolve, and `nbSpinner`
|
||||
// overlays its host — without a floor the spinner has nothing to sit in.
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
/* ── KPI row ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax($kpi-min-width, 1fr));
|
||||
gap: $gap-md;
|
||||
}
|
||||
|
||||
/*
|
||||
* A tile is an accent glyph, a two-line text stack, and a chevron.
|
||||
*
|
||||
* One flex row: the glyph, then the label-and-value block, then the chevron.
|
||||
*
|
||||
* The row aligns to `flex-start` so the glyph sits level with the label's cap
|
||||
* height instead of floating against the middle of the two-line stack. The
|
||||
* chevron opts back out with `align-self: center`, staying centred against the
|
||||
* tile however the text wraps.
|
||||
*/
|
||||
.kpi {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
// Top, not centre: the glyph lines up with the label's cap height rather than
|
||||
// floating against the middle of a two-line stack.
|
||||
align-items: flex-start;
|
||||
gap: $gap-sm;
|
||||
width: 100%;
|
||||
padding: $gap-md;
|
||||
border: 0;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
// A `<button>` does not inherit the page font; without this the tile renders
|
||||
// in the browser's default UI font.
|
||||
font: inherit;
|
||||
// A tile is a label and a number. Keeping every tile the same height stops
|
||||
// the row from stepping when one label wraps and its neighbours do not.
|
||||
min-height: 4.25rem;
|
||||
background-color: var(--gauzy-card-1);
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color);
|
||||
text-align: start;
|
||||
transition: box-shadow 0.12s ease-in-out, background-color 0.12s ease-in-out;
|
||||
|
||||
/*
|
||||
* Accent per tile, set by the `kpi--*` modifiers below. Declared here so a
|
||||
* tile without a modifier still resolves to something sensible.
|
||||
*
|
||||
* The modifiers set ONE variable, so a tile's icon can never disagree with
|
||||
* the rest of the tile — and they read the same
|
||||
* `gauzy-chart-*` tokens the charts paint with, so the Total Bonus tile and
|
||||
* the Bonus slice beside it are the same colour by construction. They were
|
||||
* not: `--info` and `--accent` both resolved to the info step, which made
|
||||
* Profit and Bonus identical on the tiles while the chart drew Bonus in
|
||||
* fuchsia.
|
||||
*/
|
||||
--kpi-accent: var(--gauzy-text-color-2);
|
||||
|
||||
&:not(.kpi--static) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gauzy-hover-tint);
|
||||
box-shadow: inset 0 0 0 1px var(--kpi-accent);
|
||||
}
|
||||
|
||||
// Keyboard focus must be at least as loud as hover — this is the only
|
||||
// affordance a keyboard user gets that the tile opens something.
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--kpi-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&--success {
|
||||
--kpi-accent: var(--gauzy-chart-revenue);
|
||||
}
|
||||
|
||||
&--warning {
|
||||
--kpi-accent: var(--gauzy-chart-expenses);
|
||||
}
|
||||
|
||||
&--info {
|
||||
--kpi-accent: var(--gauzy-chart-profit);
|
||||
}
|
||||
|
||||
&--accent {
|
||||
--kpi-accent: var(--gauzy-chart-bonus);
|
||||
}
|
||||
|
||||
/*
|
||||
* A negative profit or bonus re-points the whole tile at the danger accent.
|
||||
* The sign in the formatted value carries the same information, so this is
|
||||
* emphasis rather than the only cue.
|
||||
*/
|
||||
&.is-negative {
|
||||
--kpi-accent: var(--gauzy-chart-negative);
|
||||
|
||||
.kpi-value {
|
||||
color: var(--gauzy-chart-negative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// Never squeezed by a long label or a wide figure beside it.
|
||||
flex: 0 0 auto;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
// No fill behind the glyph: the accent is carried by the icon itself, and the
|
||||
// tile already has its own surface and hairline to sit on.
|
||||
color: var(--kpi-accent);
|
||||
|
||||
nb-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Label over value, as one block beside the glyph. `min-width: 0` so a long
|
||||
* figure shrinks here rather than pushing the chevron off the tile.
|
||||
*/
|
||||
.kpi-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
row-gap: 0.125rem;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
font-weight: $weight-normal;
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
// Wraps instead of truncating: these are short, meaningful headings, and a
|
||||
// translation that needs two lines should get them (the tile's `min-height`
|
||||
// already reserves the room).
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 0.75rem;
|
||||
// The one place on this page that spends weight.
|
||||
font-weight: $weight-strong;
|
||||
// Was 1.75rem, set for the 22px figure this used to be; at 12px that leading
|
||||
// left the number floating well below its label.
|
||||
line-height: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--gauzy-text-color-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kpi-affordance {
|
||||
flex: 0 0 auto;
|
||||
// Pushed to the trailing edge and centred, whatever the text stack does.
|
||||
margin-inline-start: auto;
|
||||
align-self: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
font-size: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-in-out;
|
||||
|
||||
.kpi:hover &,
|
||||
.kpi:focus-visible & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Panels ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.hr-panels {
|
||||
display: grid;
|
||||
// The chart takes the wider share: the breakdown beside it is now a list of
|
||||
// single-line rows that needs no more than its labels, while the chart is the
|
||||
// part of this page that actually rewards extra width.
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr);
|
||||
gap: $gap-md;
|
||||
// Stretch, not start: the chart panel has a fixed body height and the
|
||||
// breakdown is content-sized, so letting each find its own height left the
|
||||
// row with a ragged bottom edge. They share the taller one now.
|
||||
align-items: stretch;
|
||||
|
||||
@media screen and (max-width: $panels-breakpoint) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
background-color: var(--gauzy-card-1);
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* Title on the left, optional control on the right.
|
||||
*
|
||||
* `min-height` is derived, not guessed: it is the switcher's height plus the
|
||||
* header's own padding ($switcher-height + 2 × $gap-md). Both panels share it,
|
||||
* so the Breakdown header — which holds only a title — lines up exactly with
|
||||
* the Employee Statistics header that holds a control, and the two panel bodies
|
||||
* start on the same line.
|
||||
*/
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $gap-sm;
|
||||
padding: 0 $gap-md;
|
||||
min-height: $switcher-height + 2 * $gap-md;
|
||||
border-bottom: 1px solid var(--gauzy-border-default-color);
|
||||
}
|
||||
|
||||
/*
|
||||
* The chart-type switcher, lifted out of `ga-employee-charts` and into this
|
||||
* header. Height is pinned rather than inherited so the derived
|
||||
* `.panel-header` min-height above stays true — the global
|
||||
* `nb-select-overrides` mixin sets only a `min-height` floor, which a longer
|
||||
* option label could push past.
|
||||
*/
|
||||
.chart-switcher {
|
||||
flex: 0 0 auto;
|
||||
min-width: 8.5rem;
|
||||
|
||||
::ng-deep .select-button {
|
||||
min-height: $switcher-height;
|
||||
height: $switcher-height;
|
||||
padding-block: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $weight-normal;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $weight-medium;
|
||||
line-height: 1rem;
|
||||
letter-spacing: -0.009em;
|
||||
color: var(--gauzy-text-color-1);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: $gap-md;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The chart's height is set HERE, by the panel, not by the canvas.
|
||||
*
|
||||
* Chart.js runs with `maintainAspectRatio: false` so it fills whatever box it
|
||||
* is given — which means that box needs a definite height, and the charts used
|
||||
* to provide one themselves (a hard-coded 20rem on the canvas that took no
|
||||
* account of the legend above the plot or the tick band below it). The panel
|
||||
* owns it now, so the plot, its legend and its axis labels all live inside one
|
||||
* measured area and the panel cannot be resized by switching chart type.
|
||||
*/
|
||||
.panel--chart .panel-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 20rem;
|
||||
// A flex child will not shrink below its content without this.
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Section label inside the breakdown — "Income", "Expenses", "Bonus". */
|
||||
.group-title {
|
||||
margin: 0 0 $gap-sm;
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
// The uppercasing, the tracking and the muted tone already separate this from
|
||||
// the rows under it; it does not also need to be bold.
|
||||
font-weight: $weight-medium;
|
||||
line-height: 1rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gauzy-text-color-2);
|
||||
|
||||
// Every group after the first opens a new block, so it needs air above it
|
||||
// that the first one — already sitting against the panel padding — does not.
|
||||
&:not(:first-child) {
|
||||
margin-top: $gap-lg;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* One line of the breakdown: label on the left, figure on the right.
|
||||
*
|
||||
* Rendered as a `<button>` when it drills into a records dialog and a
|
||||
* `<div class="is-static">` when it does not, so the pointer, the focus ring and
|
||||
* the chevron all agree about whether it opens something — the old info-block
|
||||
* was a click handler on a div either way.
|
||||
*
|
||||
* The chevron is taken OUT OF FLOW and the row reserves its width as padding, so
|
||||
* every figure lands on the same right edge whether or not its row has one. In
|
||||
* flow, a static row's value would sit a chevron's width further right than its
|
||||
* clickable neighbours and the column would not read as a column.
|
||||
*/
|
||||
.stat-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
// Top, not centre: a row is a label over its formula, and the figure belongs
|
||||
// on the label's line rather than against the middle of the stack under it.
|
||||
align-items: flex-start;
|
||||
gap: $gap-sm;
|
||||
width: 100%;
|
||||
padding: 0.375rem $gap-sm;
|
||||
padding-inline-end: 1.75rem;
|
||||
border: 0;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
color: var(--gauzy-text-color-1);
|
||||
transition: background-color 0.12s ease-in-out;
|
||||
|
||||
|
||||
&:not(.is-static) {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gauzy-hover-tint);
|
||||
|
||||
.stat-row-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--gauzy-action-info-text);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-negative .stat-row-value {
|
||||
color: var(--gauzy-action-danger-text);
|
||||
}
|
||||
}
|
||||
|
||||
/* Label over formula, as one block; the figure sits beside it. */
|
||||
.stat-row-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
row-gap: 0.125rem;
|
||||
}
|
||||
|
||||
.stat-row-label {
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--gauzy-text-color-1);
|
||||
// One line each: a wrapped label would break the value's alignment with the
|
||||
// rows above and below it.
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* The salary row is the one row with no formula to stack, so its label is a
|
||||
* direct child of the row and has to take the flexible width itself —
|
||||
* `.stat-row-text` is what carries it on every other row.
|
||||
*/
|
||||
.stat-row > .stat-row-label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* The formula under a label or a figure: the `meta` line `ga-info-block` used to
|
||||
* print. Muted and a step down in size so it reads as support for the number
|
||||
* above it, and wrapping, because several of these are full sentences.
|
||||
*/
|
||||
.stat-row-meta,
|
||||
.kpi-meta {
|
||||
color: var(--gauzy-text-color-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.stat-row-meta {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* A step smaller than the breakdown's, at 10px: a tile carries the same sentence
|
||||
* in a quarter of the width, so it wraps to three or four lines where a
|
||||
* breakdown row takes one. The leading tightens with it — 1rem of it under a
|
||||
* 10px face reads as separate paragraphs rather than one wrapped line.
|
||||
*/
|
||||
.kpi-meta {
|
||||
font-size: 0.625rem;
|
||||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
.stat-row-value {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $weight-medium;
|
||||
line-height: 1.25rem;
|
||||
color: var(--gauzy-text-color-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-row-chevron {
|
||||
position: absolute;
|
||||
inset-inline-end: $gap-sm;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-in-out;
|
||||
}
|
||||
|
||||
/* The negative-bonus caveat that used to float, unattached, under the column. */
|
||||
.panel-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $gap-sm;
|
||||
margin: $gap-md 0 0;
|
||||
padding: $gap-sm;
|
||||
border-radius: var(--gauzy-radius-sm);
|
||||
background-color: var(--gauzy-action-warning-tint);
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-1);
|
||||
|
||||
nb-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.0625rem;
|
||||
color: var(--gauzy-action-warning-text);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Empty states ────────────────────────────────────────────────────────── */
|
||||
|
||||
.hr-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $gap-xs;
|
||||
padding: 2.5rem $gap-lg;
|
||||
text-align: center;
|
||||
|
||||
nb-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: $gap-xs;
|
||||
color: var(--gauzy-text-color-2);
|
||||
}
|
||||
|
||||
.state-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: $weight-medium;
|
||||
color: var(--gauzy-text-color-1);
|
||||
}
|
||||
|
||||
.state-hint {
|
||||
margin: 0;
|
||||
font-size: nb-theme(text-caption-font-size);
|
||||
line-height: 1rem;
|
||||
color: var(--gauzy-text-color-2);
|
||||
max-width: 22rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Motion ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Everything above animates colour or opacity only, but a viewer who has asked
|
||||
* for less motion should still get none of it.
|
||||
*/
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kpi,
|
||||
.stat-row,
|
||||
.identity-edit,
|
||||
.kpi-affordance,
|
||||
.stat-row-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Narrow viewports ────────────────────────────────────────────────────── */
|
||||
|
||||
@media screen and (max-width: 36rem) {
|
||||
.identity-bar {
|
||||
// Name and figures each take the full width instead of being squeezed
|
||||
// onto one line and truncated to nothing.
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: $gap-md;
|
||||
}
|
||||
|
||||
.identity-facts {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
|
||||
.fact {
|
||||
align-items: flex-start;
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import * as moment from 'moment';
|
||||
import { environment } from '@gauzy/ui-config';
|
||||
import {
|
||||
BonusTypeEnum,
|
||||
CurrencyPosition,
|
||||
EmployeeStatisticsHistoryEnum,
|
||||
IDateRangePicker,
|
||||
IMonthAggregatedEmployeeStatistics,
|
||||
@@ -15,19 +19,50 @@ import { Subject } from 'rxjs';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { distinctUntilChange, toUTC } from '@gauzy/ui-core/common';
|
||||
import { DateRangePickerBuilderService, EmployeeStatisticsService, Store, ToastrService } from '@gauzy/ui-core/core';
|
||||
import { ProfitHistoryComponent, RecordsHistoryComponent } from '@gauzy/ui-core/shared';
|
||||
import { CurrencyPositionPipe, ProfitHistoryComponent, RecordsHistoryComponent } from '@gauzy/ui-core/shared';
|
||||
import { EmployeeChartEnum } from '../employee-charts';
|
||||
|
||||
/**
|
||||
* Every money figure the page prints, already formatted for display.
|
||||
*
|
||||
* Formatting lives here rather than in the template because each figure needs
|
||||
* the same three steps — coalesce a missing value to zero, apply the currency,
|
||||
* then move the symbol to the side the organization configured. Spelling that
|
||||
* out as a ternary plus two pipes per figure is what made the previous template
|
||||
* unreadable, and the KPI row multiplies the number of call sites.
|
||||
*/
|
||||
interface IHumanResourcesFigures {
|
||||
income: string;
|
||||
nonBonusIncome: string;
|
||||
directIncomeBonus: string;
|
||||
expense: string;
|
||||
expenseWithoutSalary: string;
|
||||
salary: string;
|
||||
profit: string;
|
||||
bonus: string;
|
||||
calculatedBonus: string;
|
||||
averageBonus: string;
|
||||
}
|
||||
|
||||
@UntilDestroy({ checkProperties: true })
|
||||
@Component({
|
||||
selector: 'ga-human-resources',
|
||||
templateUrl: './human-resources.component.html',
|
||||
styleUrls: ['./human-resources.component.scss'],
|
||||
standalone: false
|
||||
selector: 'ga-human-resources',
|
||||
templateUrl: './human-resources.component.html',
|
||||
styleUrls: ['./human-resources.component.scss'],
|
||||
standalone: false,
|
||||
providers: [CurrencyPipe, CurrencyPositionPipe]
|
||||
})
|
||||
export class HumanResourcesComponent implements OnInit, OnDestroy {
|
||||
loading: boolean;
|
||||
/**
|
||||
* Starts true: the employee / organization / date-range streams are debounced
|
||||
* by 300ms, so the first paint happens before anything is resolved. Starting
|
||||
* false would render the "no employee selected" empty state for that window
|
||||
* on every single visit. Cleared once those streams settle — in `ngOnInit`
|
||||
* when they settle without an employee, and in `getEmployeeStatistics()`
|
||||
* when they settle with one.
|
||||
*/
|
||||
loading = true;
|
||||
|
||||
selectedDate: Date;
|
||||
selectedEmployee: ISelectedEmployee;
|
||||
selectedOrganization: IOrganization;
|
||||
selectedDateRange: IDateRangePicker;
|
||||
@@ -50,6 +85,32 @@ export class HumanResourcesComponent implements OnInit, OnDestroy {
|
||||
bonusPercentage: number;
|
||||
salary: number;
|
||||
averageBonus: number;
|
||||
|
||||
/** Display strings for {@link IHumanResourcesFigures}, rebuilt on every load. */
|
||||
figures: IHumanResourcesFigures = HumanResourcesComponent.emptyFigures();
|
||||
|
||||
/**
|
||||
* The formula shown on the Total Bonus tile's tooltip: which of the three
|
||||
* applies depends on the organization's bonus type and on whether any direct
|
||||
* income bonus was earned.
|
||||
*
|
||||
* Resolved here rather than as a chain of `@if`s in the template because a
|
||||
* tooltip is an attribute binding and cannot host control flow. Snapshotted
|
||||
* alongside {@link figures} so the params object is not rebuilt on every
|
||||
* change-detection pass.
|
||||
*/
|
||||
bonusFormula: { key: string; params: Record<string, string | number> } | null = null;
|
||||
|
||||
/**
|
||||
* Which chart the statistics panel draws. Owned here rather than inside
|
||||
* `ga-employee-charts` so the switcher can sit in that panel's header, on the
|
||||
* same line as its title.
|
||||
*/
|
||||
selectedChart: EmployeeChartEnum = EmployeeChartEnum.BAR;
|
||||
|
||||
/** Exposed for the switcher's `[value]` bindings. */
|
||||
readonly EmployeeChartEnum = EmployeeChartEnum;
|
||||
|
||||
statistics$: Subject<any> = new Subject();
|
||||
|
||||
constructor(
|
||||
@@ -58,7 +119,9 @@ export class HumanResourcesComponent implements OnInit, OnDestroy {
|
||||
private readonly router: Router,
|
||||
private readonly employeeStatisticsService: EmployeeStatisticsService,
|
||||
private readonly dateRangePickerBuilderService: DateRangePickerBuilderService,
|
||||
private readonly toastrService: ToastrService
|
||||
private readonly toastrService: ToastrService,
|
||||
private readonly currencyPipe: CurrencyPipe,
|
||||
private readonly currencyPositionPipe: CurrencyPositionPipe
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -76,6 +139,20 @@ export class HumanResourcesComponent implements OnInit, OnDestroy {
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChange(),
|
||||
/*
|
||||
* Before the filter, so it runs on the emission where there is no
|
||||
* employee too. `loading` is otherwise only cleared inside
|
||||
* `getEmployeeStatistics()`, which the filter below can never reach
|
||||
* without one — so with no employee ever selected the spinner ran
|
||||
* forever and the "no employee selected" state, gated on `!loading`,
|
||||
* was unreachable. Past the debounce the selectors have settled: if
|
||||
* there is still no employee the page is not waiting on anything.
|
||||
*/
|
||||
tap(([, , employee]) => {
|
||||
if (!employee) {
|
||||
this.loading = false;
|
||||
}
|
||||
}),
|
||||
filter(([organization, dateRange, employee]) => !!organization && !!dateRange && !!employee),
|
||||
tap(([organization, dateRange, employee]) => {
|
||||
this.selectedOrganization = organization;
|
||||
@@ -101,6 +178,9 @@ export class HumanResourcesComponent implements OnInit, OnDestroy {
|
||||
|
||||
async getEmployeeStatistics() {
|
||||
if (!this.selectedOrganization) {
|
||||
// Nothing to ask for. Release the initial spinner rather than leaving it
|
||||
// running forever over an empty page.
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
const { tenantId } = this.store.user;
|
||||
@@ -126,14 +206,137 @@ export class HumanResourcesComponent implements OnInit, OnDestroy {
|
||||
this.bonus = this._statsSum(this.employeeStatistics, 'bonus');
|
||||
this.calculatedBonus = +(this.bonus - this.directIncomeBonus).toFixed(2);
|
||||
this.salary = +(this.expense - this.expenseWithoutSalary).toFixed(2);
|
||||
// The header has always had an "Average Monthly Bonus" row, but the field
|
||||
// behind it was declared and never assigned, so the row could not render.
|
||||
//
|
||||
// The divisor is the length of the selected range, NOT `employeeStatistics.length`:
|
||||
// the aggregation endpoint emits a row only for months that hold records, so
|
||||
// dividing by the rows it returned averages over the ACTIVE months. A quarter
|
||||
// with one idle month would then report a third more than the employee actually
|
||||
// averaged over the period the picker asked for.
|
||||
const months = HumanResourcesComponent.monthsInRange(this.selectedDateRange);
|
||||
this.averageBonus = months ? +(this.bonus / months).toFixed(2) : 0;
|
||||
} catch (error) {
|
||||
console.error('Error while retrieving employee aggregated statistics', error);
|
||||
this.toastrService.danger(error);
|
||||
} finally {
|
||||
this.figures = this.buildFigures();
|
||||
this.bonusFormula = this.buildBonusFormula();
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the organization pays bonuses at all — gates the bonus KPI and section. */
|
||||
get hasBonus(): boolean {
|
||||
return !!this.selectedOrganization?.bonusType;
|
||||
}
|
||||
|
||||
/** Initials shown while the avatar image is missing or fails to load. */
|
||||
get employeeInitials(): string {
|
||||
const { firstName, lastName, fullName } = this.selectedEmployee ?? ({} as ISelectedEmployee);
|
||||
const parts = [firstName, lastName].filter(Boolean) as string[];
|
||||
const source = parts.length ? parts : (fullName ?? '').split(' ').filter(Boolean);
|
||||
return source
|
||||
.slice(0, 2)
|
||||
.map((part) => part.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats one figure the way the organization is configured to show money.
|
||||
*
|
||||
* Deliberately the same shape as `EmployeeDoughnutChartComponent.formatCurrency`
|
||||
* — the charts rendered beside these figures format their own labels that way,
|
||||
* and the two must not disagree. Both fall back to the configured default
|
||||
* currency rather than printing a bare number: the `position` pipe extracts a
|
||||
* symbol out of its input and cannot be handed an unformatted value.
|
||||
*/
|
||||
private toCurrency(value: number): string {
|
||||
const currencyPosition = this.selectedOrganization?.currencyPosition || CurrencyPosition.LEFT;
|
||||
const formatted = this.currencyPipe.transform(value || 0, this.defaultCurrency || environment.DEFAULT_CURRENCY);
|
||||
return this.currencyPositionPipe.transform(formatted, currencyPosition);
|
||||
}
|
||||
|
||||
/** Snapshots every total as a display string, so the template holds no formatting. */
|
||||
private buildFigures(): IHumanResourcesFigures {
|
||||
return {
|
||||
income: this.toCurrency(this.income),
|
||||
nonBonusIncome: this.toCurrency(this.nonBonusIncome),
|
||||
directIncomeBonus: this.toCurrency(this.directIncomeBonus),
|
||||
expense: this.toCurrency(this.expense),
|
||||
expenseWithoutSalary: this.toCurrency(this.expenseWithoutSalary),
|
||||
salary: this.toCurrency(this.salary),
|
||||
profit: this.toCurrency(this.profit),
|
||||
bonus: this.toCurrency(this.bonus),
|
||||
calculatedBonus: this.toCurrency(this.calculatedBonus),
|
||||
averageBonus: this.toCurrency(this.averageBonus)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the bonus formula that matches how this organization pays bonuses.
|
||||
*
|
||||
* Mirrors the branch order the template used to carry inline: a direct income
|
||||
* bonus describes the total as its two parts, and otherwise the organization's
|
||||
* bonus type decides whether the bonus is a share of revenue or of profit.
|
||||
*/
|
||||
private buildBonusFormula(): { key: string; params: Record<string, string | number> } | null {
|
||||
if (this.directIncomeBonus) {
|
||||
return {
|
||||
key: 'DASHBOARD_PAGE.TITLE.TOTAL_BONUS_CALC',
|
||||
params: {
|
||||
totalBonusIncome: this.figures.directIncomeBonus,
|
||||
calculatedBonus: this.figures.calculatedBonus
|
||||
}
|
||||
};
|
||||
}
|
||||
if (this.bonusType === BonusTypeEnum.REVENUE_BASED_BONUS) {
|
||||
return {
|
||||
key: 'DASHBOARD_PAGE.TITLE.TOTAL_INCOME_BONUS_INFO',
|
||||
params: { bonusPercentage: this.bonusPercentage || 0, totalIncome: this.figures.income }
|
||||
};
|
||||
}
|
||||
if (this.bonusType === BonusTypeEnum.PROFIT_BASED_BONUS) {
|
||||
return {
|
||||
key: 'DASHBOARD_PAGE.TITLE.TOTAL_PROFIT_BONUS_INFO',
|
||||
params: { bonusPercentage: this.bonusPercentage || 0, difference: this.figures.profit }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many calendar months the selected range covers, both ends included.
|
||||
*
|
||||
* Counted off month boundaries rather than from the day span: a range is a
|
||||
* whole number of months only by accident, and 15 Jan – 3 Feb is two months of
|
||||
* bonus to the aggregation behind this figure, not the 0.6 a day count gives.
|
||||
*/
|
||||
private static monthsInRange(range: IDateRangePicker): number {
|
||||
const { startDate, endDate } = range ?? ({} as IDateRangePicker);
|
||||
if (!startDate || !endDate) {
|
||||
return 0;
|
||||
}
|
||||
const months = moment(endDate).startOf('month').diff(moment(startDate).startOf('month'), 'months') + 1;
|
||||
return Math.max(0, months);
|
||||
}
|
||||
|
||||
/** Placeholder figures used before the first response lands. */
|
||||
private static emptyFigures(): IHumanResourcesFigures {
|
||||
return {
|
||||
income: '',
|
||||
nonBonusIncome: '',
|
||||
directIncomeBonus: '',
|
||||
expense: '',
|
||||
expenseWithoutSalary: '',
|
||||
salary: '',
|
||||
profit: '',
|
||||
bonus: '',
|
||||
calculatedBonus: '',
|
||||
averageBonus: ''
|
||||
};
|
||||
}
|
||||
|
||||
async openHistoryDialog(type: EmployeeStatisticsHistoryEnum) {
|
||||
if (!this.selectedOrganization) {
|
||||
return;
|
||||
@@ -223,14 +426,15 @@ export class HumanResourcesComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET Employee Position Accessor
|
||||
* Position and seniority, as separate labels.
|
||||
*
|
||||
* Returned as an array rather than the pipe-joined string the header used to
|
||||
* print, so each one can be rendered as its own chip — and so an employee
|
||||
* with only one of the two does not render a dangling separator.
|
||||
*/
|
||||
get employeePosition() {
|
||||
if (!this.selectedEmployee) {
|
||||
return;
|
||||
}
|
||||
const { shortDescription, employeeLevel } = this.selectedEmployee;
|
||||
return [shortDescription, employeeLevel].filter(Boolean).join(' | ');
|
||||
get employeeTraits(): string[] {
|
||||
const { shortDescription, employeeLevel } = this.selectedEmployee ?? ({} as ISelectedEmployee);
|
||||
return [shortDescription, employeeLevel].filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
ngOnDestroy() {}
|
||||
|
||||
@@ -2284,7 +2284,8 @@
|
||||
"TOTAL_EXPENSE_CALC": "Total = Employee Expenses + Split Expenses + Recurring + Salary",
|
||||
"TOTAL_EXPENSES_WITHOUT_SALARY": "Total Expense without salary",
|
||||
"TOTAL_EXPENSES_WITHOUT_SALARY_CALC": "Expense = Employee Expenses + Split Expenses + Recurring",
|
||||
"TOTAL_BONUS_CALC": "Total Bonus = Direct Income Bonus {{ totalBonusIncome }} + Bonus {{ calculatedBonus }}"
|
||||
"TOTAL_BONUS_CALC": "Total Bonus = Direct Income Bonus {{ totalBonusIncome }} + Bonus {{ calculatedBonus }}",
|
||||
"BREAKDOWN": "Breakdown"
|
||||
},
|
||||
"DEVELOPER": {
|
||||
"DEVELOPER": "Developer",
|
||||
|
||||
+30
-1
@@ -1,6 +1,18 @@
|
||||
@use 'gauzy/_gauzy-table' as *;
|
||||
@use 'gauzy/_gauzy-dialogs' as ga-dialogs;
|
||||
@use 'gauzy/_gauzy-overrides' as ga-overrides;
|
||||
@use 'gauzy/_gauzy-table-hub' as ga-hub;
|
||||
|
||||
// The dialog's table is the same object as every other data table in the app
|
||||
// (documents, employees, invoices, payments, pipelines, tasks), so it takes its
|
||||
// density, surface and pager from the shared hub instead of growing a second set
|
||||
// of metrics that drift. See `gauzy/_gauzy-table-hub.scss`.
|
||||
//
|
||||
// `profit-history.component.scss` @forwards this file, so everything below lands
|
||||
// on the profit dialog too — both are opened from the HR dashboard KPI tiles.
|
||||
:host {
|
||||
@include ga-hub.density-tokens();
|
||||
}
|
||||
|
||||
:host nb-card {
|
||||
border-radius: nb-theme(border-radius);
|
||||
@@ -21,7 +33,12 @@
|
||||
background-color: var(--gauzy-card-2);
|
||||
padding: 1rem 11px;
|
||||
.table-scroll-container {
|
||||
padding: 0 11px;
|
||||
// Edge-to-edge inside the ring. The cells carry their own horizontal
|
||||
// padding (`--gauzy-table-cell-padding-x`), and the card body above
|
||||
// already supplies the gutter — the 11px that used to be here left the
|
||||
// surface outline floating a column-width off the table it outlines.
|
||||
padding: 0;
|
||||
@include ga-hub.surface();
|
||||
}
|
||||
}
|
||||
h5 {
|
||||
@@ -29,3 +46,15 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep angular2-smart-table {
|
||||
@include ga-hub.rows();
|
||||
}
|
||||
|
||||
:host .pagination-container {
|
||||
@include ga-hub.pager-container();
|
||||
}
|
||||
|
||||
:host .pagination-container ::ng-deep ngx-pagination {
|
||||
@include ga-hub.pager();
|
||||
}
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@
|
||||
|
||||
/**
|
||||
* The three renderings of the monthly employee statistics offered by the legacy
|
||||
* `<ga-employee-charts>` switcher on the HR dashboard.
|
||||
* chart switcher on the HR dashboard (the control lives in that page's
|
||||
* "Employee Statistics" panel header; `<ga-employee-charts>` only draws the
|
||||
* rendering it is handed).
|
||||
*
|
||||
* The string values are persisted inside a placement's `config` (the switcher
|
||||
* widget stores the user's pick under `chartType`), so treat them as a data
|
||||
|
||||
+3
-3
@@ -19,9 +19,9 @@ import {
|
||||
* Chart widget: the HR dashboard's chart switcher, canvas-hosted.
|
||||
*
|
||||
* One widget that renders any of the three employee-statistics charts and lets
|
||||
* the viewer flip between them — the same dropdown `<ga-employee-charts>` puts
|
||||
* above its chart, so a canvas does not have to spend three cells to offer all
|
||||
* three views.
|
||||
* the viewer flip between them — the same choice the HR dashboard offers from
|
||||
* its "Employee Statistics" panel header, so a canvas does not have to spend
|
||||
* three cells to offer all three views.
|
||||
*
|
||||
* The dropdown is a VIEW state, not a persisted one: a canvas widget has no
|
||||
* write access to its own placement, so a pick lasts for the session. The
|
||||
|
||||
@@ -309,16 +309,75 @@ $gauzy-menu-scrollbar-dark: (
|
||||
gauzy-menu-scrollbar-thumb-hover: rgba(0, 0, 0, 0.9)
|
||||
);
|
||||
|
||||
// ── Chart series ────────────────────────────────────────────────────────────
|
||||
// The four monthly employee-statistics series (revenue / expenses / profit /
|
||||
// bonus), plus the step a profit or bonus bar flips to when it goes negative.
|
||||
//
|
||||
// These are NOT aliases of `gauzy-action-*-text`, and the difference is the
|
||||
// point. Those tokens are tuned for TEXT on a surface; a chart mark is a large
|
||||
// fill, judged on a different band, and the two jobs only coincide in light
|
||||
// mode (where the first three values here are in fact identical to the action
|
||||
// steps). In dark mode the action steps are deliberately light — emerald-400 et
|
||||
// al, so they read as text — and used as fills they blow out.
|
||||
//
|
||||
// Every value below was produced by snapping the theme's own ramp to a step
|
||||
// that passes all six checks of the categorical-palette validator against the
|
||||
// surface the charts actually sit on (`gauzy-card-1`): the OKLCH lightness band
|
||||
// for the mode, the chroma floor, all-pairs CVD separation, the normal-vision
|
||||
// floor, and 3:1 contrast. Re-run that validator before changing any of them.
|
||||
//
|
||||
// Danger is reserved for the negative state, so it cannot double as the fourth
|
||||
// categorical hue — hence fuchsia, which is the step that clears blue under
|
||||
// deuteranopia/protanopia. Blue↔violet and blue↔indigo both FAIL outright
|
||||
// (ΔE 0.3 and 3.0 deutan); do not "simplify" this back to a purple.
|
||||
//
|
||||
// Both palettes carry a CVD WARN on the blue↔fuchsia pair (ΔE 7.4 light / 6.1
|
||||
// dark, inside the 6–8 floor band), which is legal only because identity never
|
||||
// rests on hue alone here: every chart ships a legend naming each series in text
|
||||
// beside its swatch, and the marks are separated by a surface gap. Keep that
|
||||
// legend if you keep these colours.
|
||||
$gauzy-chart-series-light: (
|
||||
gauzy-chart-revenue: #047857, // emerald-700
|
||||
gauzy-chart-expenses: #b45309, // amber-700
|
||||
gauzy-chart-profit: #1d4ed8, // blue-700
|
||||
gauzy-chart-bonus: #a21caf, // fuchsia-700
|
||||
gauzy-chart-negative: #dc2626, // red-600
|
||||
// Chrome, one shade off the surface: a grid is a reading aid, not data.
|
||||
gauzy-chart-grid: rgba(126, 126, 143, 0.16),
|
||||
// Deliberately darker than `gauzy-text-color-2`, which it started out as:
|
||||
// that step is 3.99:1 on white, and axis ticks and legend entries are small
|
||||
// TEXT, so they owe the 4.5:1 AA floor. This one measures 6.08:1.
|
||||
gauzy-chart-label: rgba(97, 97, 112, 1)
|
||||
);
|
||||
|
||||
$gauzy-chart-series-dark: (
|
||||
gauzy-chart-revenue: #059669, // emerald-600
|
||||
gauzy-chart-expenses: #d97706, // amber-600
|
||||
gauzy-chart-profit: #3b82f6, // blue-500
|
||||
gauzy-chart-bonus: #c026d3, // fuchsia-600
|
||||
gauzy-chart-negative: #ef4444, // red-500
|
||||
gauzy-chart-grid: rgba(255, 255, 255, 0.1),
|
||||
// Full white rather than the 55% step it was: axis ticks and legend entries
|
||||
// are small text and were reading as grey against the dark canvas.
|
||||
gauzy-chart-label: #ffffff
|
||||
);
|
||||
|
||||
// What actually gets merged into a theme, last, so it wins over the per-theme
|
||||
// map — same contract `$gauzy-density` has always had. Themes take the light or
|
||||
// the dark accent set depending on the canvas they paint.
|
||||
$gauzy-shared-light: map.merge(
|
||||
map.merge(map.merge($gauzy-density, $gauzy-action-accents-light), gauzy-action-buttons()),
|
||||
$gauzy-menu-scrollbar-light
|
||||
map.merge(
|
||||
map.merge(map.merge($gauzy-density, $gauzy-action-accents-light), gauzy-action-buttons()),
|
||||
$gauzy-menu-scrollbar-light
|
||||
),
|
||||
$gauzy-chart-series-light
|
||||
);
|
||||
$gauzy-shared-dark: map.merge(
|
||||
map.merge(map.merge($gauzy-density, $gauzy-action-accents-dark), gauzy-action-buttons()),
|
||||
$gauzy-menu-scrollbar-dark
|
||||
map.merge(
|
||||
map.merge(map.merge($gauzy-density, $gauzy-action-accents-dark), gauzy-action-buttons()),
|
||||
$gauzy-menu-scrollbar-dark
|
||||
),
|
||||
$gauzy-chart-series-dark
|
||||
);
|
||||
|
||||
// ── Canvas token sets for themes that do not define their own ───────────────
|
||||
|
||||
Reference in New Issue
Block a user