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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ Built to the specification in [SPECIFICATION.md](./SPECIFICATION.md).
with a configurable year-rollover policy. *My leave* shows each balance as an
animated ring with a breakdown ledger (base + carry-over ± adjustment → available)
and monthly charts of leave taken and sick days for the current year.
- **HR area**: per-employee balances, company-wide statistics, a who's-off calendar,
- **HR area**: a filterable list of all recorded absences (correct or cancel any of
them), per-employee balances, company-wide statistics, a who's-off calendar,
and CSV export.
- **Calendar sync**: approved leave is written to a personal and a shared team calendar
via CalDAV.
Expand Down Expand Up @@ -59,6 +60,11 @@ Built to the specification in [SPECIFICATION.md](./SPECIFICATION.md).

![Record absence dialog](screenshots/6.png)

**Absences** — every recorded absence, filterable by employee, type, status and year.
Selecting one opens its details, where HR can correct the dates, type or working days,
or cancel it. Cancelling keeps the record and its history for the audit trail, so
there is no delete. The sick-leave overview and the who's-off timeline both link here.

**Balances** — per-employee entitlement, used, pending, remaining and available:

![HR balances table](screenshots/7.png)
Expand Down
17 changes: 16 additions & 1 deletion SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,11 @@ employees — sick leave is the canonical example (`employee_requestable = false
- **Only HR can edit or cancel it.** For an HR-recorded type, `PermissionService::canModify`
returns false for the employee (owner) — so the employee sees no Edit/Cancel controls
and the API rejects such attempts; only HR may change it (§17).
- **Correcting a record.** HR reaches individual records through the **Absences**
view (§15.2) — or by selecting one in *Who's off* / the *Sick leave* drilldown — and
edits or cancels it from the standard detail sidebar. There is **no delete**: a wrong
entry is cancelled (`CANCELLED`), so the row and its history stay intact for the audit
trail (§17). HR's cancel skips the withdrawal step and applies immediately (§5.5).
- **No "Approved" label shown.** Since approval isn't a concept for HR-recorded leave,
the UI hides the status chip and the approval **progress stepper** when such a request
is `APPROVED` (a cancelled one still shows its status). The leave-type chip (e.g.
Expand Down Expand Up @@ -724,7 +729,17 @@ NcContent(app-name="absence")
- **Team / Who's off** — a **Gantt-style month timeline** (`TeamTimeline`, §15.7):
a sticky avatar rail with continuous rounded leave **pills** (colored by type, hatched
while pending), weekend shading, a "today" line, month navigation + a "Today" jump,
and a legend. `scope="team"` for managers, `scope="company"` for HR.
and a legend. `scope="team"` for managers, `scope="company"` for HR. With
`selectable` the pills become buttons that open the request in the sidebar; only
*Who's off* sets it, because HR may read every request the org-wide calendar shows
while a team timeline can include leave the viewer is not allowed to open.
- **Absences** (`#/hr/absences`, HR only) — the counterpart to *Record absence*: the
full list of recorded absences with filters for employee (user autocomplete), leave
type, status and year, paged with a "Load more" button. Rows are the same
`RequestListItem` as elsewhere and open the detail sidebar, whose **Edit** and
**Cancel** controls are what let HR correct a wrong vacation or sick day (§5.6).
Accepts `?employee=&employeeName=&type=&status=&year=` so other views can deep-link
into it — the *Sick leave* overview does, from each employee row.
- **HR** (HR group only): *Balances* (searchable/sortable data table →
entitlement/used/pending/remaining/carry-over, inline entitlement editor, skeleton on
load), *Statistics* (stat tiles + a **`LineChart`** area for monthly trend and a
Expand Down
26 changes: 13 additions & 13 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.

5 changes: 4 additions & 1 deletion lib/Db/LeaveRequestMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ public function findFiltered(array $filters, ?int $limit = null, ?int $offset =
$qb->andWhere($qb->expr()->lte('start_date', $qb->createNamedParameter($filters['to'])));
}

$qb->orderBy('start_date', 'DESC');
// The id tiebreaker keeps the order total: start_date alone is not unique, so
// without it `limit`/`offset` paging (HR's absence list) could repeat or skip
// rows whenever several absences share a start date.
$qb->orderBy('start_date', 'DESC')->addOrderBy('id', 'DESC');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
Expand Down
12 changes: 11 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@
<ClipboardPlusOutline :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem :name="t('absence', 'Absences')" :to="{ name: 'hr-absences' }">
<template #icon>
<CalendarSearch :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem :name="t('absence', 'Balances')" :to="{ name: 'hr-balances' }">
<template #icon>
<ScaleBalance :size="20" />
Expand Down Expand Up @@ -111,6 +116,7 @@ import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import CalendarAccountOutline from 'vue-material-design-icons/CalendarAccountOutline.vue'
import CalendarMonth from 'vue-material-design-icons/CalendarMonth.vue'
import CalendarSearch from 'vue-material-design-icons/CalendarSearch.vue'
import ChartBar from 'vue-material-design-icons/ChartBar.vue'
import ClipboardCheck from 'vue-material-design-icons/ClipboardCheck.vue'
import ClipboardPlusOutline from 'vue-material-design-icons/ClipboardPlusOutline.vue'
Expand All @@ -136,6 +142,7 @@ export default {
RequestSidebar,
Plus,
CalendarAccountOutline,
CalendarSearch,
ClipboardCheck,
AccountGroup,
ScaleBalance,
Expand All @@ -147,8 +154,9 @@ export default {
},

setup() {
// Let any descendant view open the create/edit dialog or select a request.
// Let any descendant view open the create/edit/record dialog or select a request.
provide('absence:openNew', () => window.dispatchEvent(new CustomEvent('absence:open-new')))
provide('absence:openRecord', () => window.dispatchEvent(new CustomEvent('absence:open-record')))
provide('absence:openEdit', (r) => window.dispatchEvent(new CustomEvent('absence:open-edit', { detail: r })))
return { store }
},
Expand All @@ -173,6 +181,7 @@ export default {

mounted() {
window.addEventListener('absence:open-new', this.openNewRequest)
window.addEventListener('absence:open-record', this.openRecord)
window.addEventListener('absence:open-edit', this.onOpenEditEvent)
// Deep link: /requests/:id opens the sidebar.
if (this.$route.params.id) {
Expand All @@ -182,6 +191,7 @@ export default {

beforeUnmount() {
window.removeEventListener('absence:open-new', this.openNewRequest)
window.removeEventListener('absence:open-record', this.openRecord)
window.removeEventListener('absence:open-edit', this.onOpenEditEvent)
},

Expand Down
7 changes: 6 additions & 1 deletion src/components/RequestDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,12 @@ export default {

typeOptions() {
// HR may record any enabled type (incl. sick); employees only self-requestable ones.
return this.hrMode ? store.enabledLeaveTypes : store.requestableLeaveTypes
// HR correcting an existing record needs the same full list — otherwise the
// record's own type (sick) is missing from the picker it is preselected in.
if (this.hrMode || (this.isEdit && store.session.isHr)) {
return store.enabledLeaveTypes
}
return store.requestableLeaveTypes
},

typeColor() {
Expand Down
55 changes: 53 additions & 2 deletions src/components/RequestSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
</template>
{{ t('absence', 'Edit') }}
</NcButton>
<NcButton variant="tertiary" :disabled="busy" @click="cancel">
<NcButton variant="tertiary" :disabled="busy" @click="startCancel">
<template #icon>
<CancelIcon :size="20" />
</template>
Expand All @@ -86,6 +86,20 @@
</template>
</div>

<!-- HR's cancel is immediate and notifies the employee, and it is reached
from a browse list where a misclick is easy — so it is confirmed. -->
<NcNoteCard v-if="confirmingCancel" type="warning">
<p>{{ t('absence', 'Cancel this absence for {employee}? They are notified, and it is removed from the calendars. The record itself is kept, marked cancelled.', { employee: detail.employeeUid }) }}</p>
<div class="confirm__actions">
<NcButton variant="tertiary" :disabled="busy" @click="confirmingCancel = false">
{{ t('absence', 'Back') }}
</NcButton>
<NcButton variant="error" :disabled="busy" @click="cancel">
{{ t('absence', 'Confirm cancellation') }}
</NcButton>
</div>
</NcNoteCard>

<div v-if="rejecting" class="reject">
<NcTextArea
v-model="rejectComment"
Expand Down Expand Up @@ -188,6 +202,7 @@ import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import CancelIcon from 'vue-material-design-icons/Cancel.vue'
Expand All @@ -214,6 +229,7 @@ export default {
NcButton,
NcTextArea,
NcEmptyContent,
NcNoteCard,
StatusChip,
LeaveTypeChip,
CoveragePanel,
Expand All @@ -234,6 +250,7 @@ export default {
detail: null,
busy: false,
rejecting: false,
confirmingCancel: false,
rejectComment: '',
newComment: '',
}
Expand Down Expand Up @@ -280,8 +297,21 @@ export default {
return this.isWithdrawal ? t('absence', 'Keep leave') : t('absence', 'Decline')
},

/**
* HR cancelling somebody else's leave — or their own HR-recorded leave —
* takes effect immediately, with no withdrawal-approval step (§5.6). Mirrors
* the same condition in `RequestService::cancel`.
*/
hrOverride() {
return !!store.session.isHr
&& (this.detail.employeeUid !== store.session.uid || store.isHrRecorded(this.detail))
},
Comment on lines +305 to +308

cancelLabel() {
return this.detail.status === 'APPROVED' ? t('absence', 'Request withdrawal') : t('absence', 'Cancel request')
if (this.detail.status === 'APPROVED' && !this.hrOverride) {
return t('absence', 'Request withdrawal')
}
return this.hrOverride ? t('absence', 'Cancel absence') : t('absence', 'Cancel request')
},
},

Expand Down Expand Up @@ -333,6 +363,7 @@ export default {
this.detail = await api.getRequest(store.selectedId)
this.rejecting = false
this.rejectComment = ''
this.confirmingCancel = false
} catch {
showError(t('absence', 'Could not load the request'))
this.$emit('close')
Expand Down Expand Up @@ -367,6 +398,19 @@ export default {
}
},

/**
* Employees cancel straight away: their own request either has not been
* approved yet or goes through a withdrawal approval anyway. HR's cancel is
* final, so it asks first.
*/
startCancel() {
if (this.hrOverride) {
this.confirmingCancel = true
return
}
this.cancel()
},

async cancel() {
this.busy = true
try {
Expand Down Expand Up @@ -437,6 +481,13 @@ export default {
gap: 8px;
}

.confirm__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}

.reject {
display: flex;
flex-direction: column;
Expand Down
57 changes: 53 additions & 4 deletions src/components/TeamTimeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,23 @@
:class="{ 'gantt__col--weekend': d.weekend }"
:style="{ left: d.index * dayWidth + 'px' }" />
<span v-if="todayIndex >= 0" class="gantt__today" :style="{ left: (todayIndex * dayWidth) + 'px' }" />
<span
<component
:is="selectable ? 'button' : 'span'"
v-for="(seg, i) in row.segments"
:key="i"
class="gantt__pill"
Comment on lines +64 to 68
:class="{ 'gantt__pill--pending': seg.pending }"
:class="{
'gantt__pill--pending': seg.pending,
'gantt__pill--clickable': selectable,
'gantt__pill--active': selectable && store.selectedId === seg.requestId,
}"
:type="selectable ? 'button' : null"
:style="{ left: seg.left + 'px', width: seg.width + 'px', '--pill': seg.color }"
:title="seg.title">
:title="seg.title"
:aria-label="selectable ? seg.title : null"
@click="selectable && $emit('select', seg.requestId)">
<span class="gantt__pill-icon" aria-hidden="true">{{ seg.icon }}</span>
</span>
</component>
</div>
</div>
</div>
Expand Down Expand Up @@ -115,6 +123,16 @@ export default {
components: { NcAvatar, NcButton, NcEmptyContent, ChevronLeft, ChevronRight, CalendarBlank, SkeletonList },
props: {
scope: { type: String, default: 'team' },
// Turn the pills into buttons that open the request. Only set it where the
// viewer may actually read every request in the timeline (HR's "Who's off") —
// elsewhere the calendar can include leave whose detail the API would refuse.
selectable: { type: Boolean, default: false },
},

emits: ['select'],
setup() {
// Expose the module-level reactive store to the template (Options API).
return { store }
},

data() {
Expand Down Expand Up @@ -174,6 +192,7 @@ export default {
}
const type = store.leaveType(ev.typeId)
byUid[ev.employeeUid].segments.push({
requestId: ev.requestId,
left: startIdx * this.dayWidth + 2,
width: (endIdx - startIdx + 1) * this.dayWidth - 4,
color: type.color,
Expand All @@ -199,6 +218,12 @@ export default {

mounted() {
this.load()
// A cancellation made through the sidebar must drop off the timeline.
window.addEventListener('absence:refresh', this.load)
},

beforeUnmount() {
window.removeEventListener('absence:refresh', this.load)
},

methods: {
Expand Down Expand Up @@ -382,6 +407,30 @@ $name-w: 180px;
);
opacity: 0.9;
}

// As a <button> the pill inherits the browser's control styling, which
// would otherwise override the shared look above.
&--clickable {
border: none;
font: inherit;
text-align: start;
cursor: pointer;

&:hover {
filter: brightness(1.08);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.28);
}

&:focus-visible {
outline: 2px solid var(--color-main-text);
outline-offset: 1px;
}
}

&--active {
outline: 2px solid var(--color-main-text);
outline-offset: 1px;
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import { createRouter, createWebHashHistory } from 'vue-router'
import Approvals from './views/Approvals.vue'
import HrAbsences from './views/hr/HrAbsences.vue'
import HrBalances from './views/hr/HrBalances.vue'
import HrExports from './views/hr/HrExports.vue'
import HrSickLeave from './views/hr/HrSickLeave.vue'
Expand All @@ -17,6 +18,7 @@ const routes = [
{ path: '/my', name: 'my', component: MyLeave },
{ path: '/approvals', name: 'approvals', component: Approvals },
{ path: '/team', name: 'team', component: Team },
{ path: '/hr/absences', name: 'hr-absences', component: HrAbsences },
{ path: '/hr/balances', name: 'hr-balances', component: HrBalances },
{ path: '/hr/statistics', name: 'hr-statistics', component: HrStatistics },
{ path: '/hr/sick-leave', name: 'hr-sick-leave', component: HrSickLeave },
Expand Down
Loading
Loading