Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,12 @@ the SPA). All list endpoints paginate and accept filters.
**HR reporting**
- `GET /api/reports/balances` — balances report (filters).
- `GET /api/reports/trends` — aggregated stats for charts.
- `GET /api/reports/sick-leave` — sick-leave overview, every employee ranked by days
lost. Counts the leave type keyed `sick` by default; `typeId` counts another type
instead, which the *Sick leave* view exposes as a type picker. The response carries
the types it aggregated, so the page can name what it is counting rather than
implying "sickness" is a fixed concept — and can say so plainly when an instance has
no matching type instead of showing a table of zeroes.
- `GET /api/export/requests.csv|.xlsx`, `GET /api/export/balances.csv|.xlsx`.

All write endpoints require CSRF protection (default AppFramework) and validate role
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions js/absence-main.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/absence-main.mjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/absence-personal-settings.mjs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions js/index-BN1WbbeK.chunk.mjs → js/index-CAVYKD4e.chunk.mjs

Large diffs are not rendered by default.

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions lib/Db/LeaveRequestMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ public function findEscalated(): array {
return $this->findEntities($qb);
}

/**
* Render an instant the way `created_at` is stored.
*
* The timestamp columns hold UTC ({@see \OCA\Absence\Service\ClockService::now()}),
* but the callers of the two queries below work out their cut-offs on the
* *server's* calendar — which day boundary a request falls on is a company
* question, not a UTC one. Formatting such a cut-off directly would write its
* local wall-clock into the query and silently shift every bound by the
* server's UTC offset: the reminder window is exactly one working day wide, so
* off Berlin's or Auckland's offset it reminds a day early or skips a cohort
* entirely. Converting here keeps the working-day arithmetic on the server's
* calendar while the comparison happens in the column's own zone.
*
* Rebuilt from the timestamp rather than via setTimezone() so a caller's
* mutable \DateTime is never altered as a side effect.
*/
private function asStoredTimestamp(\DateTimeInterface $moment): string {
return (new \DateTimeImmutable('@' . $moment->getTimestamp()))->format('Y-m-d H:i:s');
}

/**
* Pending requests created before the given cut-off (for escalation/reminders).
*
Expand All @@ -117,7 +137,7 @@ public function findPendingOlderThan(\DateTimeInterface $cutoff): array {
->where($qb->expr()->eq('status', $qb->createNamedParameter(LeaveRequest::STATUS_PENDING)))
->andWhere($qb->expr()->isNotNull('manager_uid'))
->andWhere($qb->expr()->lt('created_at', $qb->createNamedParameter(
$cutoff->format('Y-m-d H:i:s'), IQueryBuilder::PARAM_STR)));
$this->asStoredTimestamp($cutoff), IQueryBuilder::PARAM_STR)));
return $this->findEntities($qb);
}

Expand Down Expand Up @@ -170,8 +190,8 @@ public function findPendingCreatedBetween(\DateTimeInterface $after, \DateTimeIn
->from($this->getTableName())
->where($qb->expr()->eq('status', $qb->createNamedParameter(LeaveRequest::STATUS_PENDING)))
->andWhere($qb->expr()->isNotNull('manager_uid'))
->andWhere($qb->expr()->lt('created_at', $qb->createNamedParameter($before->format('Y-m-d H:i:s'), IQueryBuilder::PARAM_STR)))
->andWhere($qb->expr()->gte('created_at', $qb->createNamedParameter($after->format('Y-m-d H:i:s'), IQueryBuilder::PARAM_STR)));
->andWhere($qb->expr()->lt('created_at', $qb->createNamedParameter($this->asStoredTimestamp($before), IQueryBuilder::PARAM_STR)))
->andWhere($qb->expr()->gte('created_at', $qb->createNamedParameter($this->asStoredTimestamp($after), IQueryBuilder::PARAM_STR)));
return $this->findEntities($qb);
}

Expand Down
14 changes: 0 additions & 14 deletions lib/Service/BalanceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,20 +252,6 @@ private function buildRow(string $employeeUid, int $year, LeaveType $type, float
];
}

/**
* The available balance for a single counting type in a year — used by the
* create flow to warn about (not block) negative balances.
*/
public function availableFor(string $employeeUid, int $typeId, int $year): ?float {
$type = null;
foreach ($this->getBalance($employeeUid, $year)['balances'] as $row) {
if ($row['typeId'] === $typeId) {
return $row['available'];
}
}
return null;
}

/**
* Ensure an entitlement row exists for (employee, year, type); creates one from
* the configured default when missing. Returns the row.
Expand Down
4 changes: 3 additions & 1 deletion src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ export default {
// Reports & export
reportBalances: (year, group) => axios.get(url('/api/reports/balances'), { params: { year, group } }).then((r) => r.data),
reportTrends: (from, to) => axios.get(url('/api/reports/trends'), { params: { from, to } }).then((r) => r.data),
reportSickLeave: (year, group) => axios.get(url('/api/reports/sick-leave'), { params: { year, group } }).then((r) => r.data),
// `typeId` overrides which leave type is counted; omitted, the server falls
// back to the type keyed "sick".
reportSickLeave: (year, group, typeId) => axios.get(url('/api/reports/sick-leave'), { params: { year, group, typeId } }).then((r) => r.data),
exportRequestsUrl: (from, to) => url(`/api/export/requests?from=${from}&to=${to}`),
exportBalancesUrl: (year) => url(`/api/export/balances?year=${year}`),
}
62 changes: 56 additions & 6 deletions src/views/hr/HrSickLeave.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,21 @@
</template>
</NcTextField>
<NcCheckboxRadioSwitch v-model="onlyAffected" type="switch">
{{ t('absence', 'Only employees with sick leave') }}
{{ t('absence', 'Only employees with {type}', { type: countedLabel }) }}
</NcCheckboxRadioSwitch>
<NcSelect
v-model="type"
:options="typeOptions"
label="label"
:placeholder="t('absence', 'Sick leave (default)')"
:aria-label-combobox="t('absence', 'Leave type counted')">
<template #option="{ icon, label }">
<span class="opt"><span class="opt__icon">{{ icon }}</span>{{ label }}</span>
</template>
<template #selected-option="{ icon, label }">
<span class="opt"><span class="opt__icon">{{ icon }}</span>{{ label }}</span>
</template>
</NcSelect>
<NcSelect
v-model="year"
:options="years"
Expand All @@ -33,7 +46,7 @@
<NcEmptyContent
v-else-if="!types.length"
:name="t('absence', 'No sick leave type configured')"
:description="t('absence', 'This overview counts the leave type with the key “sick”. Add or enable it in the admin settings to see figures here.')">
:description="t('absence', 'This overview counts the leave type with the key “sick” unless you pick another one above. Add or enable it in the admin settings to see figures here.')">
<template #icon>
<Thermometer :size="20" />
</template>
Expand All @@ -44,7 +57,7 @@
<StatTile
icon="🤒"
:value="fmt(totals.days)"
:label="t('absence', 'days of sick leave')"
:label="t('absence', 'days of {type}', { type: countedLabel })"
:caption="t('absence', 'in {year}', { year })"
accent="var(--color-warning)" />
<StatTile
Expand Down Expand Up @@ -144,10 +157,10 @@

<NcEmptyContent
v-if="!filtered.length"
:name="search ? t('absence', 'No matches') : t('absence', 'No sick leave recorded')"
:name="search ? t('absence', 'No matches') : t('absence', 'Nothing recorded')"
:description="search
? t('absence', 'No employee matches “{query}”.', { query: search })
: t('absence', 'Nobody has recorded sick leave in {year}.', { year })">
: t('absence', 'Nobody has recorded {type} in {year}.', { type: countedLabel, year })">
<template #icon>
<Thermometer :size="20" />
</template>
Expand All @@ -170,6 +183,7 @@ import MeterBar from '../../components/MeterBar.vue'
import SkeletonList from '../../components/SkeletonList.vue'
import StatTile from '../../components/StatTile.vue'
import api from '../../api.js'
import { store } from '../../store.js'

export default {
name: 'HrSickLeave',
Expand All @@ -184,12 +198,34 @@ export default {
totals: { employees: 0, affected: 0, days: 0, episodes: 0 },
search: '',
onlyAffected: true,
// null means "let the server pick" — it counts the type keyed "sick".
type: null,
year: y,
years: [y - 2, y - 1, y, y + 1],
}
},

computed: {
typeOptions() {
// A disabled type can still have history worth reporting on, so offer the
// full list rather than the enabled subset — same reasoning as HrAbsences.
return store.leaveTypes
},

/**
* What the page is actually counting, in the report's own words. Taken from
* the server's answer rather than from the picker so the labels stay true in
* the default case too — where nothing is picked and the server resolved the
* "sick" key on its own. Lowercased because every use sits mid-sentence
* ("days of sick leave").
*/
countedLabel() {
if (!this.types.length) {
return t('absence', 'sick leave')
}
return this.types.map((type) => type.label).join(' / ').toLowerCase()
},

filtered() {
const q = this.search.trim().toLowerCase()
return this.rows.filter((row) => {
Expand Down Expand Up @@ -227,6 +263,10 @@ export default {
year() {
this.reload()
},

type() {
this.reload()
},
},

mounted() {
Expand Down Expand Up @@ -273,7 +313,7 @@ export default {
async reload() {
this.loading = true
try {
const report = await api.reportSickLeave(this.year)
const report = await api.reportSickLeave(this.year, null, this.type?.id ?? null)
this.rows = report.rows ?? []
this.types = report.types ?? []
this.totals = report.totals ?? { employees: 0, affected: 0, days: 0, episodes: 0 }
Expand Down Expand Up @@ -303,6 +343,16 @@ export default {
font-weight: bold;
}

.opt {
display: inline-flex;
align-items: center;
gap: 8px;

&__icon {
font-size: 1.1em;
}
}

.emp {
display: inline-flex;
align-items: center;
Expand Down
Loading