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
7 changes: 6 additions & 1 deletion SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,12 @@ the SPA). All list endpoints paginate and accept filters.
**Requests**
- `GET /api/requests` — list (scoped by role: own / reports / all-for-HR; filters: status, type, date range, employee, group).
- `POST /api/requests` — create (§5.1).
- `GET /api/requests/{id}` — detail (with comments, coverage summary).
- `GET /api/requests/{id}` — detail (with comments, coverage summary, and the
employee's **balance** for that leave type in the year the leave starts — gated on
`canViewBalanceOf`, so a colleague who may read the request still cannot read the
allowance. Null for a type that counts against nothing. It is there because "took
three days" says nothing about whether any are left, and finding out otherwise
means abandoning the view for the Balances report).
- `PUT /api/requests/{id}` — edit (§5.3; behavior depends on current status).
- `POST /api/requests/{id}/cancel` — cancel / request withdrawal.
- `POST /api/requests/{id}/approve` — manager/HR approve (optional comment).
Expand Down
18 changes: 9 additions & 9 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.

39 changes: 39 additions & 0 deletions lib/Service/RequestService.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public function __construct(
private PermissionService $permission,
private ClockService $clock,
private CoverageService $coverage,
private BalanceService $balances,
private NoticeService $notice,
private CalendarService $calendar,
private NotificationService $notifications,
Expand Down Expand Up @@ -188,6 +189,14 @@ public function getDetail(string $actorUid, int $id): array {
$detail['canDecide'] = $this->permission->canDecide($actorUid, $request);
$detail['canModify'] = $this->permission->canModify($actorUid, $request);
$detail = $this->withDisplayNames($detail, $request);
// What this absence leaves the person with. Without it, seeing that somebody
// took three days says nothing about whether they have any left, and the only
// way to find out was to abandon the view for the Balances report and come
// back. Gated on canViewBalanceOf(), the same rule the balance endpoints use,
// so a colleague who may read the request still cannot read the allowance.
if ($this->permission->canViewBalanceOf($actorUid, $request->getEmployeeUid())) {
$detail['balance'] = $this->balanceFor($request);
}
if ($detail['canDecide']) {
$detail['coverage'] = $this->coverage->getRequestCoverage($request, $actorUid);
// Only for someone who may decide, like the coverage summary: it is there to
Expand All @@ -197,6 +206,36 @@ public function getDetail(string $actorUid, int $id): array {
return $detail;
}

/**
* The employee's balance for this absence's leave type, in the year it starts.
*
* That year, not the current one, because usage is attributed the same way
* ({@see BalanceService}) — reporting this year's allowance next to last year's
* leave would describe a different thing entirely.
*
* Null for a type with no ceiling to spend: unpaid and special leave count
* against nothing, so "how many are left" has no answer to give.
*
* @return ?array<string,mixed>
*/
private function balanceFor(LeaveRequest $request): ?array {
$year = (int)substr($request->getStartDate(), 0, 4);
foreach ($this->balances->getBalance($request->getEmployeeUid(), $year)['balances'] as $row) {
if ($row['typeId'] !== $request->getTypeId() || $row['entitlement'] === null) {
continue;
}
return [
'year' => $year,
'entitlement' => $row['entitlement'],
'used' => $row['used'],
'pending' => $row['pending'],
'remaining' => $row['remaining'],
'available' => $row['available'],
];
}
return null;
}

/**
* Serialize a list of requests, resolving display names so the client can
* name people instead of printing uids.
Expand Down
11 changes: 10 additions & 1 deletion src/components/RequestListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export default {
request: { type: Object, required: true },
active: { type: Boolean, default: false },
showEmployee: { type: Boolean, default: false },
// The employee's remaining days for this leave type and year, when the caller
// has them to hand. Null for leave that counts against no allowance, and while
// the figures are still loading.
remaining: { type: Number, default: null },
},

emits: ['select'],
Expand Down Expand Up @@ -67,7 +71,12 @@ export default {
subtitle() {
const range = formatRange(this.request.startDate, this.request.endDate)
const days = n('absence', '%n day', '%n days', this.request.workingDays)
return `${range} · ${days}`
if (this.remaining === null) {
return `${range} · ${days}`
}
// Days taken alone does not answer the question the list is scanned for.
const left = n('absence', '%n day left', '%n days left', Math.round(this.remaining * 10) / 10)
return `${range} · ${days} · ${left}`
},
},

Expand Down
40 changes: 40 additions & 0 deletions src/components/RequestSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,27 @@
<dd>{{ rangeLabel }}</dd>
<dt>{{ t('absence', 'Working days') }}</dt>
<dd>{{ detail.workingDays }}</dd>
<!-- What this absence leaves them with, so nobody has to go to the
Balances report to find out. Absent for leave that counts against
nothing, where "left" has no answer. -->
<template v-if="detail.balance">
<dt>{{ t('absence', 'Balance {year}', { year: detail.balance.year }) }}</dt>
<dd class="facts__balance">
<strong>{{ t('absence', '{days} left', { days: fmtDays(detail.balance.remaining) }) }}</strong>
<span class="facts__muted">
{{ t('absence', 'of {total} · {used} taken', {
total: fmtDays(detail.balance.entitlement),
used: fmtDays(detail.balance.used),
}) }}
</span>
<span v-if="detail.balance.pending > 0" class="facts__muted">
{{ t('absence', '{days} awaiting a decision, leaving {available} free to book', {
days: fmtDays(detail.balance.pending),
available: fmtDays(detail.balance.available),
}) }}
</span>
</dd>
</template>
<template v-if="detail.replacementUid">
<dt>{{ t('absence', 'Replacement') }}</dt>
<dd class="facts__decided">
Expand Down Expand Up @@ -449,6 +470,17 @@ export default {
}
},

/**
* A day count without a trailing `.0`, so "22 days" not "22.0 days".
*
* @param {number} value day count
* @return {string}
*/
fmtDays(value) {
const days = Math.round((Number(value) || 0) * 10) / 10
return n('absence', '%n day', '%n days', days)
},

startReject() {
this.rejecting = true
},
Expand Down Expand Up @@ -540,6 +572,14 @@ export default {
color: var(--color-text-maxcontrast);
font-weight: 400;
}

// The balance is three facts, not one sentence: stack them so the headline
// number stays the thing the eye lands on.
&__balance {
display: flex;
flex-direction: column;
gap: 1px;
}
}

.actions {
Expand Down
40 changes: 40 additions & 0 deletions src/views/hr/HrAbsences.vue
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
:key="r.id"
:request="r"
:showEmployee="true"
:remaining="remainingFor(r)"
:active="store.selectedId === r.id"
@select="store.select($event)" />
</TransitionGroup>
Expand Down Expand Up @@ -168,6 +169,8 @@ export default {
return {
loading: true,
loadingMore: false,
// employeeUid|typeId => remaining days, for the selected year.
balances: {},
rows: [],
hasMore: false,
employee,
Expand Down Expand Up @@ -294,6 +297,43 @@ export default {
} finally {
this.loading = false
}
await this.loadBalances()
},

/**
* Remaining days per employee and leave type, indexed for the rows above.
*
* One batched report rather than a lookup per row — the server computes every
* employee's balances in a fixed number of queries, which a request per
* visible absence would not be.
*
* Only for a single reporting year: "remaining" is a per-year figure, so with
* the filter on "All years" there is no one answer and the rows simply omit
* it. Silent on failure — the list is still perfectly usable without it.
*/
async loadBalances() {
this.balances = {}
if (this.year.value === null) {
return
}
try {
const report = await api.reportBalances(this.year.value)
const index = {}
for (const row of report) {
if (row.remaining !== null) {
index[`${row.employeeUid}|${row.typeId}`] = row.remaining
}
}
this.balances = index
} catch {
this.balances = {}
}
},

/** @param {object} request one absence row */
remainingFor(request) {
const value = this.balances[`${request.employeeUid}|${request.typeId}`]
return value === undefined ? null : value
},

async loadMore() {
Expand Down
74 changes: 74 additions & 0 deletions tests/Unit/Service/RequestServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use OCA\Absence\Exception\ForbiddenException;
use OCA\Absence\Exception\ValidationException;
use OCA\Absence\Service\ActivityPublisher;
use OCA\Absence\Service\BalanceService;
use OCA\Absence\Service\CalendarService;
use OCA\Absence\Service\ConfigService;
use OCA\Absence\Service\CoverageService;
Expand Down Expand Up @@ -47,6 +48,7 @@ class RequestServiceTest extends TestCase {
private ManagerResolver&MockObject $managerResolver;
private PermissionService&MockObject $permission;
private CoverageService&MockObject $coverage;
private BalanceService&MockObject $balances;
private NoticeService&MockObject $notice;
private CalendarService&MockObject $calendar;
private NotificationService&MockObject $notifications;
Expand All @@ -69,6 +71,8 @@ protected function setUp(): void {
$this->managerResolver = $this->createMock(ManagerResolver::class);
$this->permission = $this->createMock(PermissionService::class);
$this->coverage = $this->createMock(CoverageService::class);
$this->balances = $this->createMock(BalanceService::class);
$this->balances->method('getBalance')->willReturn(['balances' => []]);
$this->notice = $this->createMock(NoticeService::class);
$this->calendar = $this->createMock(CalendarService::class);
$this->notifications = $this->createMock(NotificationService::class);
Expand All @@ -82,6 +86,17 @@ protected function setUp(): void {
);
$this->userManager = $this->createMock(IUserManager::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->rebuildService();
}

/**
* Rebuild the service against the current mocks.
*
* Called from setUp(), and again by any test that needs to reprogram a
* collaborator with expects() — those cannot be layered onto a mock the
* constructor has already been handed.
*/
private function rebuildService(): void {
$this->service = new RequestService(
$this->requestMapper,
$this->commentMapper,
Expand All @@ -91,6 +106,7 @@ protected function setUp(): void {
$this->permission,
$this->clockAtRealTime(),
$this->coverage,
$this->balances,
$this->notice,
$this->calendar,
$this->notifications,
Expand Down Expand Up @@ -172,6 +188,64 @@ public function testAGuestCannotBeNominatedAsReplacement(): void {
]);
}

public function testDetailCarriesTheEmployeeBalanceForTheYearTheLeaveStartsIn(): void {
// Seeing that somebody took three days says nothing about whether they have
// any left; the detail view is where that question gets answered now.
$request = $this->pendingOwnRequest();
$this->requestMapper->method('find')->with(5)->willReturn($request);
$this->permission->method('canView')->willReturn(true);
$this->permission->method('canViewBalanceOf')->with('hr', 'emp')->willReturn(true);
$this->commentMapper->method('findForRequest')->willReturn([]);
$this->eventMapper->method('findForRequest')->willReturn([]);

// The request starts in 2026, so 2026's allowance is the relevant one.
$this->balances = $this->createMock(BalanceService::class);
$this->balances->expects(self::once())->method('getBalance')->with('emp', 2026)
->willReturn(['balances' => [
['typeId' => 9, 'entitlement' => 10.0, 'used' => 1.0, 'pending' => 0.0, 'remaining' => 9.0, 'available' => 9.0],
['typeId' => 1, 'entitlement' => 28.0, 'used' => 6.0, 'pending' => 3.0, 'remaining' => 22.0, 'available' => 19.0],
]]);
$this->rebuildService();

$detail = $this->service->getDetail('hr', 5);

self::assertSame(2026, $detail['balance']['year']);
self::assertSame(22.0, $detail['balance']['remaining']);
self::assertSame(19.0, $detail['balance']['available']);
}

public function testDetailWithholdsTheBalanceFromSomeoneWhoMayNotSeeIt(): void {
$request = $this->pendingOwnRequest();
$this->requestMapper->method('find')->with(5)->willReturn($request);
$this->permission->method('canView')->willReturn(true);
// A colleague may read the request without being entitled to the allowance.
$this->permission->method('canViewBalanceOf')->willReturn(false);
$this->commentMapper->method('findForRequest')->willReturn([]);
$this->eventMapper->method('findForRequest')->willReturn([]);

$this->balances->expects(self::never())->method('getBalance');

self::assertArrayNotHasKey('balance', $this->service->getDetail('peer', 5));
}

public function testDetailHasNoBalanceForLeaveThatCountsAgainstNothing(): void {
$request = $this->pendingOwnRequest();
$this->requestMapper->method('find')->with(5)->willReturn($request);
$this->permission->method('canView')->willReturn(true);
$this->permission->method('canViewBalanceOf')->willReturn(true);
$this->commentMapper->method('findForRequest')->willReturn([]);
$this->eventMapper->method('findForRequest')->willReturn([]);

// Unpaid and special leave have no ceiling, so "how many are left" has no answer.
$this->balances = $this->createMock(BalanceService::class);
$this->balances->method('getBalance')->willReturn(['balances' => [
['typeId' => 1, 'entitlement' => null, 'used' => 2.0, 'pending' => 0.0, 'remaining' => null, 'available' => null],
]]);
$this->rebuildService();

self::assertNull($this->service->getDetail('hr', 5)['balance']);
}

public function testApplyingForOwnLeaveStillDemandsAReplacement(): void {
// §5.1 unchanged for self-service: the employee knows who can cover and is
// asked to arrange it before going.
Expand Down
Loading