From f7b0f00e766d52ba0a1709b43f2ce4e390d2e115 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Mon, 10 Aug 2026 23:27:36 +0200 Subject: [PATCH 1/3] feat(design): one card surface, one stat tile, one meter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four subtly different card treatments had grown up across the views — background-hover with no border on the statistics tiles, main-background with a border on the chart panels beside them, a third combination on the exports cards, a fourth padding scale on My leave. Nothing was broken and nothing looked deliberate, which is the kind of drift that reads as "unfinished" long before anybody can say why. There is now one `.surface`, one `.tiles` grid and one `.page__hint` in the shared stylesheet, and the views use them instead of each rolling their own. The tables get the same treatment. `.tbl` gave them bottom rules and nothing else: no container, no hover, no header weight — so a long HR table rendered as rules floating on the page background, and the sick leave view had quietly hand-rolled its own row hover because the shared styles offered none. The wrap is now the table's own surface, headers sit on a tinted band, rows highlight under the pointer, and the last row's rule no longer doubles up with the border. StatTile and MeterBar are extracted from the one view each had grown up in. The tile carries an accent colour rather than painting every figure in the primary element: a row of identical blue numbers gives the eye nothing to hold on to, and the accent is free information. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/MeterBar.vue | 76 +++++++++++++++++++++++++++++++ src/components/StatTile.vue | 83 +++++++++++++++++++++++++++++++++ src/styles/page.scss | 91 +++++++++++++++++++++++++++++++++++-- src/views/MyLeave.vue | 9 +--- src/views/hr/HrExports.vue | 20 ++++---- src/views/hr/HrWhosOff.vue | 10 +--- 6 files changed, 257 insertions(+), 32 deletions(-) create mode 100644 src/components/MeterBar.vue create mode 100644 src/components/StatTile.vue diff --git a/src/components/MeterBar.vue b/src/components/MeterBar.vue new file mode 100644 index 0000000..2857302 --- /dev/null +++ b/src/components/MeterBar.vue @@ -0,0 +1,76 @@ + + + + + + diff --git a/src/components/StatTile.vue b/src/components/StatTile.vue new file mode 100644 index 0000000..74e830b --- /dev/null +++ b/src/components/StatTile.vue @@ -0,0 +1,83 @@ + + + + + + diff --git a/src/styles/page.scss b/src/styles/page.scss index a704fb8..f3e4ec8 100644 --- a/src/styles/page.scss +++ b/src/styles/page.scss @@ -79,9 +79,50 @@ width: 240px; } - /* Wide tables scroll on their own so the page body never does. */ + /** + * The one card surface. Three subtly different ones had grown up across the HR + * views (background-hover with no border here, main-background with a border + * there, a third padding scale on My leave), which is the kind of drift that + * reads as "unfinished" long before anybody can say why. Everything raised off + * the page background now uses this. + */ + .surface { + background: var(--color-main-background); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large, 12px); + padding: calc(var(--default-grid-baseline, 4px) * 4); + } + + /** A quieter surface for nested blocks that must not compete with the card. */ + .surface--sunken { + background: var(--color-background-hover); + border-color: transparent; + } + + /** Responsive tile row, for stat tiles and the export cards alike. */ + .tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: calc(var(--default-grid-baseline, 4px) * 4); + } + + /** Muted one-liner under a page title. */ + .page__hint { + margin: -12px 0 0; + color: var(--color-text-maxcontrast); + font-size: 0.9rem; + } + + /* + * Wide tables scroll on their own so the page body never does. The wrap doubles + * as the table's surface, so a long HR table reads as one object instead of + * rules floating on the page background. + */ .table-wrap { overflow-x: auto; + background: var(--color-main-background); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large, 12px); } .tbl { @@ -89,16 +130,38 @@ border-collapse: collapse; th, td { - padding: 10px 12px; + padding: 12px 14px; text-align: start; border-bottom: 1px solid var(--color-border); } + /* Align the first and last cells with the surface's own corner radius. */ + th:first-child, td:first-child { padding-inline-start: 18px; } + th:last-child, td:last-child { padding-inline-end: 18px; } + th { - font-size: 0.8rem; + font-size: 0.76rem; + font-weight: 600; color: var(--color-text-maxcontrast); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.05em; + white-space: nowrap; + background: var(--color-background-hover); + border-bottom-color: var(--color-border-dark, var(--color-border)); + } + + thead th:first-child { border-start-start-radius: var(--border-radius-large, 12px); } + thead th:last-child { border-start-end-radius: var(--border-radius-large, 12px); } + + /* The last rule would otherwise double up with the surface's own border. */ + tbody tr:last-child td { border-bottom: none; } + + tbody tr { + transition: background-color 120ms ease; + } + + tbody tr:hover { + background: var(--color-background-hover); } .num { @@ -106,9 +169,29 @@ font-variant-numeric: tabular-nums; } + /** + * The figure a row is really about, when it sits among other numbers. + * Declared *before* the state colours below: a cell is routinely both + * `lead` and `neg`, the two have equal specificity, and whichever is + * declared last would win — which must be the one carrying the warning. + */ + .lead { + font-weight: 600; + color: var(--color-main-text); + } + .neg { color: var(--color-error); font-weight: 600; } } + + /** Rows that open something. Pairs with a keyboard-reachable control inside. */ + .tbl--interactive tbody tr { + cursor: pointer; + } + + @media (prefers-reduced-motion: reduce) { + .tbl tbody tr { transition: none; } + } } diff --git a/src/views/MyLeave.vue b/src/views/MyLeave.vue index bd592a1..a9c7096 100644 --- a/src/views/MyLeave.vue +++ b/src/views/MyLeave.vue @@ -30,10 +30,10 @@
-
+
-
+
@@ -256,11 +256,6 @@ export default { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: calc(var(--default-grid-baseline, 4px) * 3); - &__card { - background: var(--color-background-hover); - border-radius: var(--border-radius-large, 12px); - padding: calc(var(--default-grid-baseline, 4px) * 3); - } } .requests { diff --git a/src/views/hr/HrExports.vue b/src/views/hr/HrExports.vue index 706f756..54d0f44 100644 --- a/src/views/hr/HrExports.vue +++ b/src/views/hr/HrExports.vue @@ -10,9 +10,9 @@ -
-
-

{{ t('absence', 'Requests') }}

+
+
+

{{ t('absence', 'Requests') }}

{{ t('absence', 'All leave requests overlapping the selected date range, as CSV.') }}

@@ -26,8 +26,8 @@
-
-

{{ t('absence', 'Balances') }}

+
+

{{ t('absence', 'Balances') }}

{{ t('absence', 'Per-employee entitlement, used, remaining and carry-over for a year.') }}

-.cards { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; +// Export cards hold a form, so they need more width than a stat tile. +.tiles--wide { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); } .card { - background: var(--color-background-hover); - border-radius: var(--border-radius-large, 12px); - padding: calc(var(--default-grid-baseline, 4px) * 4); display: flex; flex-direction: column; gap: 12px; diff --git a/src/views/hr/HrWhosOff.vue b/src/views/hr/HrWhosOff.vue index 381d647..5a67205 100644 --- a/src/views/hr/HrWhosOff.vue +++ b/src/views/hr/HrWhosOff.vue @@ -9,7 +9,7 @@ {{ t('absence', 'Who\'s off') }} -

+

{{ t('absence', 'Select an absence to see its details, or to correct or cancel it.') }}

- - From 8b6e0f11fe14ddbf06c8620f51a34967a86be5bd Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Mon, 10 Aug 2026 23:27:50 +0200 Subject: [PATCH 2/3] feat(hr): make the HR overviews answerable at a glance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balances was seven numeric columns of equal weight, which meant the one column HR opens the page for — Available — looked exactly like the six around it, and questions as basic as "how many people are running low?" had to be totted up by eye. It now leads with four tiles answering the questions the table gets opened for: headcount, days taken, days still available, and how many people are nearly out or overdrawn. They are computed over the *filtered* rows, so narrowing to a search or a group re-answers them for that group. Only types that count against a balance are summed — adding unpaid leave, which has no entitlement, into "days available" would be meaningless. Each row gains a used-of-entitlement meter, and Available carries the only colour in the row: red when overdrawn, amber under a fifth left. The meter turns amber with it, so the warning is in the shape as well as in the number. Statistics gains a fourth tile, days in the busiest month, named. The average alone hides the August everybody disappears in, which is the one month worth staffing around. Its two charts now sit side by side, since they are read together, and the page uses the same measure as its siblings instead of being 200px narrower for no reason. Sick leave trades its summary sentence for the same tiles, and its average is taken across the whole workforce rather than the affected only — an average over the affected climbs as fewer people fall ill, which is the opposite of how the number gets read. Co-Authored-By: Claude Opus 5 (1M context) --- src/views/hr/HrBalances.vue | 294 +++++++++++++++++++++++++--------- src/views/hr/HrSickLeave.vue | 86 +++++----- src/views/hr/HrStatistics.vue | 121 +++++++------- 3 files changed, 319 insertions(+), 182 deletions(-) diff --git a/src/views/hr/HrBalances.vue b/src/views/hr/HrBalances.vue index f66153d..2c232ac 100644 --- a/src/views/hr/HrBalances.vue +++ b/src/views/hr/HrBalances.vue @@ -27,80 +27,109 @@ -
- - - - - - - - - - - - - - - - - - - - - - - - -
{{ t('absence', 'Employee') }}{{ t('absence', 'Type') }} - {{ t('absence', 'Entitlement') }} - - {{ t('absence', 'Used') }} - - {{ t('absence', 'Pending') }} - - {{ t('absence', 'Remaining') }} - - {{ t('absence', 'Available') }} - -
-
- {{ row.displayName }} -
-
{{ row.typeLabel }} - {{ fmt(row.entitlement) }} - - {{ fmt(row.used) }} - - {{ fmt(row.pending) }} - - {{ fmt(row.remaining) }} - - {{ fmt(row.available) }} - - - - -
- - - -
+
@@ -136,12 +165,18 @@ import NcTextField from '@nextcloud/vue/components/NcTextField' import Magnify from 'vue-material-design-icons/Magnify.vue' import Pencil from 'vue-material-design-icons/Pencil.vue' import ScaleBalance from 'vue-material-design-icons/ScaleBalance.vue' +import MeterBar from '../../components/MeterBar.vue' import SkeletonList from '../../components/SkeletonList.vue' +import StatTile from '../../components/StatTile.vue' import api from '../../api.js' +// Below this share of the entitlement, a balance is worth flagging rather than +// simply reporting — the number is still fine, but it is nearly spent. +const LOW_BALANCE_RATIO = 0.2 + export default { name: 'HrBalances', - components: { NcAvatar, NcButton, NcEmptyContent, NcModal, NcSelect, NcTextField, Magnify, Pencil, ScaleBalance, SkeletonList }, + components: { NcAvatar, NcButton, NcEmptyContent, NcModal, NcSelect, NcTextField, Magnify, Pencil, ScaleBalance, SkeletonList, StatTile, MeterBar }, data() { const y = new Date().getFullYear() return { @@ -164,6 +199,55 @@ export default { } return this.rows.filter((r) => r.displayName.toLowerCase().includes(q) || r.employeeUid.toLowerCase().includes(q)) }, + + /** + * The four questions this table gets opened to answer, lifted out of it so + * they do not have to be totted up by eye. Counted over the *filtered* rows, + * so narrowing to a search or a team re-answers them for that group. + * + * Only types that count against a balance are summed: adding unpaid leave, + * which has no entitlement, into "days available" would be meaningless. + */ + tiles() { + const counting = this.filtered.filter((r) => r.countsAgainstBalance && r.entitlement !== null) + if (!counting.length) { + return [] + } + const sum = (key) => counting.reduce((total, r) => total + (r[key] || 0), 0) + const lowOrOverdrawn = counting.filter((r) => this.isLow(r) || (r.available ?? 0) < 0) + const people = new Set(this.filtered.map((r) => r.employeeUid)) + + return [ + { + value: people.size, + label: t('absence', 'employees'), + caption: t('absence', 'in {year}', { year: this.year }), + icon: '👥', + accent: 'var(--color-primary-element)', + }, + { + value: this.fmt(sum('used')), + label: t('absence', 'days taken'), + caption: t('absence', 'approved so far'), + icon: '🏖️', + accent: 'var(--color-success)', + }, + { + value: this.fmt(sum('available')), + label: t('absence', 'days still available'), + caption: t('absence', 'across the company'), + icon: '📅', + accent: 'var(--color-info, var(--color-primary-element))', + }, + { + value: new Set(lowOrOverdrawn.map((r) => r.employeeUid)).size, + label: t('absence', 'running low'), + caption: t('absence', 'under {pct}% left, or overdrawn', { pct: Math.round(LOW_BALANCE_RATIO * 100) }), + icon: '⚠️', + accent: 'var(--color-warning)', + }, + ] + }, }, watch: { @@ -182,6 +266,50 @@ export default { return v === null || v === undefined ? '—' : Number(v).toLocaleString(undefined, { maximumFractionDigits: 1 }) }, + /** + * Nearly spent, but not yet overdrawn. + * + * @param row + */ + isLow(row) { + if (!row.countsAgainstBalance || !(row.entitlement > 0)) { + return false + } + const available = row.available ?? 0 + return available >= 0 && available < row.entitlement * LOW_BALANCE_RATIO + }, + + availableClass(row) { + if ((row.available ?? 0) < 0) { + return 'neg' + } + return this.isLow(row) ? 'low' : '' + }, + + /** + * The bar turns amber once the balance is nearly gone, so the warning is in + * the shape as well as the number — the type colour is decoration until the + * row needs attention, at which point it should stop being decoration. + * + * @param row + */ + barColor(row) { + if ((row.available ?? 0) < 0) { + return 'var(--color-error)' + } + if (this.isLow(row)) { + return 'var(--color-warning)' + } + return row.typeColor || 'var(--color-primary-element)' + }, + + usedLabel(row) { + return t('absence', '{used} of {entitlement} days used', { + used: this.fmt(row.used), + entitlement: this.fmt(row.entitlement), + }) + }, + async reload() { this.loading = true try { @@ -248,6 +376,22 @@ export default { gap: 8px; } +.bar-col { + width: 15%; + min-width: 90px; + + &__none { + color: var(--color-text-maxcontrast); + } +} + +// Amber, not red: the balance is nearly spent, which is worth noticing but is +// not yet the error that an overdrawn one is. Qualified with the element so it +// outranks the global `.tbl .lead` colour on the same cell. +td.low { + color: var(--color-warning); +} + .edit { display: flex; flex-direction: column; diff --git a/src/views/hr/HrSickLeave.vue b/src/views/hr/HrSickLeave.vue index 7bfeb77..66f568f 100755 --- a/src/views/hr/HrSickLeave.vue +++ b/src/views/hr/HrSickLeave.vue @@ -40,23 +40,26 @@
-

- {{ - n('absence', - '%n day of sick leave in {year}', - '%n days of sick leave in {year}', - Math.round(totals.days), - { year }) - }} - · - {{ - n('absence', - '%n employee affected', - '%n employees affected', - totals.affected) - }} - {{ t('absence', 'of {total}', { total: totals.employees }) }} -

+
+ + + +
@@ -128,10 +131,11 @@ @@ -162,12 +166,14 @@ import NcSelect from '@nextcloud/vue/components/NcSelect' import NcTextField from '@nextcloud/vue/components/NcTextField' import Magnify from 'vue-material-design-icons/Magnify.vue' import Thermometer from 'vue-material-design-icons/Thermometer.vue' +import MeterBar from '../../components/MeterBar.vue' import SkeletonList from '../../components/SkeletonList.vue' +import StatTile from '../../components/StatTile.vue' import api from '../../api.js' export default { name: 'HrSickLeave', - components: { NcAvatar, NcCheckboxRadioSwitch, NcEmptyContent, NcSelect, NcTextField, Magnify, Thermometer, SkeletonList }, + components: { NcAvatar, NcCheckboxRadioSwitch, NcEmptyContent, NcSelect, NcTextField, Magnify, Thermometer, SkeletonList, StatTile, MeterBar }, data() { const y = new Date().getFullYear() @@ -206,6 +212,15 @@ export default { barColor() { return this.types[0]?.color || 'var(--color-primary-element)' }, + + /** + * Spread across the whole workforce, not just the people who fell ill — + * the average over the affected only would climb as fewer people get sick, + * which is the opposite of what the number is read as meaning. + */ + averagePerEmployee() { + return this.totals.employees ? this.totals.days / this.totals.employees : 0 + }, }, watch: { @@ -255,13 +270,6 @@ export default { }) }, - barWidth(days) { - if (!days || this.maxDays <= 0) { - return '0' - } - return Math.max(2, Math.round((days / this.maxDays) * 100)) + '%' - }, - async reload() { this.loading = true try { @@ -283,16 +291,7 @@ export default { .report { display: flex; flex-direction: column; - gap: calc(var(--default-grid-baseline, 4px) * 2); -} - -.summary { - margin: 0; - color: var(--color-main-text); -} - -.summary__muted { - color: var(--color-text-maxcontrast); + gap: calc(var(--default-grid-baseline, 4px) * 4); } .rank { @@ -333,14 +332,7 @@ export default { } .bar-col { - width: 22%; + width: 20%; min-width: 80px; } - -.bar { - height: 8px; - border-radius: var(--border-radius); - /* a zero-width bar would still show a rounded stub */ - min-width: 0; -} diff --git a/src/views/hr/HrStatistics.vue b/src/views/hr/HrStatistics.vue index ba9a5af..4df2008 100644 --- a/src/views/hr/HrStatistics.vue +++ b/src/views/hr/HrStatistics.vue @@ -3,7 +3,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->
-
+