Skip to content

Commit 045cc0e

Browse files
authored
Merge pull request #1420 from it-at-m/zmskvr-670-tagessperre-gesamtuebersicht
Zmskvr 670 tagessperre gesamtuebersicht
2 parents a3825bc + 1da5e5c commit 045cc0e

File tree

13 files changed

+561
-15
lines changed

13 files changed

+561
-15
lines changed

zmsadmin/js/page/overallCalendar/overallCalendar.js

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ let lastUpdateAfter = null;
33
let autoRefreshTimer = null;
44
let currentRequest = null;
55
let SCOPE_COLORS = {};
6+
let CLOSURES = new Set();
67

78
function buildScopeColorMap(days) {
89
const ids = [...new Set(days.flatMap(d => d.scopes.map(s => s.id)))];
@@ -31,6 +32,21 @@ function isSameRequest(a, b) {
3132
return [...a.scopeIds].sort().join(',') === [...b.scopeIds].sort().join(',');
3233
}
3334

35+
async function fetchClosures({scopeIds, dateFrom, dateUntil, fullReload = true}) {
36+
const res = await fetch(`closureData/?${new URLSearchParams({
37+
scopeIds: scopeIds.join(','),
38+
dateFrom,
39+
dateUntil
40+
})}`);
41+
if (!res.ok) throw new Error('Fehler beim Laden der Closures');
42+
const { data } = await res.json();
43+
const set = new Set();
44+
for (const it of (data?.items || [])) {
45+
set.add(`${it.date}|${it.scopeId}`);
46+
}
47+
CLOSURES = set;
48+
}
49+
3450
document.addEventListener('DOMContentLoaded', () => {
3551
const form = document.getElementById('overall-calendar-form');
3652
const btnRefresh = document.getElementById('refresh-calendar');
@@ -56,7 +72,11 @@ document.addEventListener('DOMContentLoaded', () => {
5672
if (!currentRequest) return;
5773
const {scopeIds, dateFrom, dateUntil} = currentRequest;
5874
try {
59-
await loadCalendar({scopeIds, dateFrom, dateUntil, fullReload: false});
75+
await Promise.all([
76+
fetchCalendar({ scopeIds, dateFrom, dateUntil, fullReload: false }),
77+
fetchClosures({ scopeIds, dateFrom, dateUntil })
78+
]);
79+
renderMultiDayCalendar(calendarCache);
6080
} catch (e) {
6181
alert('Fehler beim Aktualisieren: ' + e.message);
6282
}
@@ -101,7 +121,7 @@ document.addEventListener('DOMContentLoaded', () => {
101121
}
102122
});
103123

104-
async function loadCalendar({scopeIds, dateFrom, dateUntil, fullReload = false}) {
124+
async function fetchCalendar({scopeIds, dateFrom, dateUntil, fullReload = false}) {
105125
const incremental = !fullReload && isSameRequest({scopeIds, dateFrom, dateUntil}, currentRequest);
106126
const paramsObj = {scopeIds: scopeIds.join(','), dateFrom, dateUntil};
107127
if (incremental && lastUpdateAfter) {
@@ -120,7 +140,6 @@ async function loadCalendar({scopeIds, dateFrom, dateUntil, fullReload = false})
120140
calendarCache = json.data.days;
121141
}
122142

123-
renderMultiDayCalendar(calendarCache);
124143
currentRequest = {scopeIds, dateFrom, dateUntil};
125144
lastUpdateAfter = serverTs;
126145
}
@@ -156,7 +175,11 @@ async function handleSubmit(event) {
156175
}
157176

158177
try {
159-
await loadCalendar({scopeIds, dateFrom, dateUntil, fullReload: true});
178+
await Promise.all([
179+
fetchCalendar({ scopeIds, dateFrom, dateUntil, fullReload: true }),
180+
fetchClosures({ scopeIds, dateFrom, dateUntil })
181+
]);
182+
renderMultiDayCalendar(calendarCache);
160183
startAutoRefresh();
161184
} catch (e) {
162185
alert(e.message);
@@ -176,19 +199,21 @@ function startAutoRefresh() {
176199

177200
async function fetchIncrementalUpdate() {
178201
if (!currentRequest || !lastUpdateAfter) return;
179-
const {scopeIds, dateFrom, dateUntil} = currentRequest;
180-
const params = new URLSearchParams({
202+
const { scopeIds, dateFrom, dateUntil } = currentRequest;
203+
204+
const res = await fetch(`overallcalendarData/?${new URLSearchParams({
181205
scopeIds: scopeIds.join(','),
182206
dateFrom,
183207
dateUntil,
184208
updateAfter: lastUpdateAfter
185-
});
186-
const res = await fetch(`overallcalendarData/?${params}`);
187-
if (!res.ok) return;
209+
})}`);
210+
if (res.ok) {
211+
lastUpdateAfter = toMysql(res.headers.get('Last-Modified') || new Date());
212+
const json = await res.json();
213+
mergeDelta(json.data.days);
214+
}
188215

189-
lastUpdateAfter = toMysql(res.headers.get('Last-Modified') || new Date());
190-
const json = await res.json();
191-
mergeDelta(json.data.days);
216+
await fetchClosures({ scopeIds, dateFrom, dateUntil });
192217
renderMultiDayCalendar(calendarCache);
193218
}
194219

@@ -238,6 +263,8 @@ function renderMultiDayCalendar(days) {
238263
return;
239264
}
240265

266+
const ymdLocalFromUnix = (ts) => new Date(ts * 1000).toLocaleDateString('sv-SE');
267+
241268
SCOPE_COLORS = buildScopeColorMap(days);
242269
const allTimes = [...new Set(
243270
days.flatMap(day =>
@@ -303,13 +330,17 @@ function renderMultiDayCalendar(days) {
303330

304331
colCursor = 2;
305332
days.forEach((day, dayIdx) => {
333+
const dateIsoForDay = new Date(day.date * 1000).toLocaleDateString('sv-SE');
306334
day.scopes.forEach((scope, scopeIdx) => {
307335
const head = addCell({
308336
text: scope.shortName || scope.name || `Scope ${scope.id}`,
309337
className: 'overall-calendar-head overall-calendar-scope-header overall-calendar-stick-top',
310338
row: 2, col: colCursor, colSpan: scope.maxSeats
311339
});
312340
head.style.background = SCOPE_COLORS[scope.id];
341+
if (isScopeClosed(dateIsoForDay, scope.id)) {
342+
head.classList.add('is-closed');
343+
}
313344
colCursor += scope.maxSeats;
314345
if (scopeIdx < day.scopes.length - 1) {
315346
addCell({
@@ -342,10 +373,10 @@ function renderMultiDayCalendar(days) {
342373

343374
let col = 2;
344375
days.forEach((day, dayIdx) => {
345-
const dateKey = new Date(day.date * 1000).toISOString().slice(0, 10);
376+
const dateKey = ymdLocalFromUnix(day.date);
346377
day.scopes.forEach((scope, scopeIdx) => {
347378
const timeObj = scope.times.find(t => t.name === time) || {seats: []};
348-
379+
const closed = isScopeClosed(dateKey, scope.id);
349380
for (let seatIdx = 0; seatIdx < scope.maxSeats; seatIdx++) {
350381
if (occupied.has(`${gridRow}-${col}`)) { col++; continue; }
351382

@@ -368,7 +399,7 @@ function renderMultiDayCalendar(days) {
368399
for (let i = 0; i < span; i++) occupied.add(`${gridRow + i}-${col}`);
369400
} else if (status !== 'skip') {
370401
addCell({
371-
className: `overall-calendar-seat overall-calendar-${status}`,
402+
className: `overall-calendar-seat overall-calendar-${status}${closed ? ' overall-calendar-closed' : ''}`,
372403
row: gridRow,
373404
col,
374405
id: cellId,
@@ -391,3 +422,7 @@ function togglePageScroll(disable) {
391422
document.documentElement.classList.toggle('no-page-scroll', disable);
392423
document.body.classList.toggle('no-page-scroll', disable);
393424
}
425+
426+
function isScopeClosed(dateIso, scopeId) {
427+
return CLOSURES.has(`${dateIso}|${scopeId}`);
428+
}

zmsadmin/routing.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@
5454
\App::$slim->get('/overallcalendar/overallcalendarData/', \BO\Zmsadmin\OverallCalendarLoadData::class)
5555
->setName("overallCalendarLoadData");
5656

57+
\App::$slim->get(
58+
'/overallcalendar/closureData/',
59+
\BO\Zmsadmin\OverallCalendarClosureLoadData::class
60+
)->setName('overallCalendarClosureLoadData');
61+
62+
5763
/*
5864
* ---------------------------------------------------------------------------
5965
* Config

zmsadmin/scss/overallCalendar.scss

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,19 @@ body.no-page-scroll {
185185
.overall-calendar-actions .btn {
186186
margin-bottom: 0;
187187
margin-top: 1rem;
188+
}
189+
190+
.overall-calendar-closed {
191+
border: 0 !important;
192+
background: repeating-linear-gradient(
193+
135deg,
194+
rgba(220, 53, 69, .08),
195+
rgba(220, 53, 69, .08) 6px,
196+
rgba(220, 53, 69, .14) 6px 12px
197+
);
198+
}
199+
200+
.overall-calendar-scope-header.is-closed {
201+
background: #fdecef !important;
202+
border-color: #f3c1c8 !important;
188203
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
namespace BO\Zmsadmin;
4+
5+
use Psr\Http\Message\RequestInterface;
6+
use Psr\Http\Message\ResponseInterface;
7+
8+
class OverallCalendarClosureLoadData extends BaseController
9+
{
10+
public function readResponse(
11+
RequestInterface $request,
12+
ResponseInterface $response,
13+
array $args
14+
) {
15+
$scopeIds = $_GET['scopeIds'] ?? null;
16+
$dateFrom = $_GET['dateFrom'] ?? null;
17+
$dateUntil = $_GET['dateUntil'] ?? null;
18+
19+
if ($scopeIds === null && $dateFrom === null && $dateUntil === null) {
20+
$response->getBody()->write(json_encode([]));
21+
return $response->withHeader('Content-Type', 'application/json');
22+
}
23+
24+
if (!$scopeIds || !$dateFrom || !$dateUntil) {
25+
$error = [
26+
'error' => true,
27+
'message' => 'Missing required parameters: scopeIds, dateFrom, dateUntil',
28+
];
29+
$response->getBody()->write(json_encode($error));
30+
return $response
31+
->withStatus(400)
32+
->withHeader('Content-Type', 'application/json');
33+
}
34+
35+
$params = [
36+
'scopeIds' => $scopeIds,
37+
'dateFrom' => $dateFrom,
38+
'dateUntil' => $dateUntil,
39+
];
40+
41+
$apiResult = \App::$http->readGetResult('/closure/', $params);
42+
$apiResponse = $apiResult->getResponse();
43+
44+
$lastMod = $apiResponse->getHeaderLine('Last-Modified');
45+
if ($lastMod !== '') {
46+
$response = $response->withHeader('Last-Modified', $lastMod);
47+
}
48+
49+
$contentType = $apiResponse->getHeaderLine('Content-Type');
50+
$response = $response->withHeader(
51+
'Content-Type',
52+
$contentType !== '' ? $contentType : 'application/json'
53+
);
54+
55+
$bodyStream = $apiResponse->getBody();
56+
if ($bodyStream->isSeekable()) {
57+
$bodyStream->rewind();
58+
}
59+
$rawBody = (string) $bodyStream;
60+
61+
$response->getBody()->write($rawBody);
62+
63+
return $response->withStatus($apiResponse->getStatusCode());
64+
}
65+
}

zmsadmin/templates/page/overallCalendar.twig

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@
7272
</div>
7373
</form>
7474

75+
<div class="overall-calendar-legend" style="margin:10px 0;">
76+
<strong>Legende:</strong>
77+
<span class="overall-calendar-seat overall-calendar-closed" style="display:inline-block; width:80px; height:18px; margin-left:8px; vertical-align:middle;"></span>
78+
<span style="margin-right:12px;">gesperrt</span>
79+
80+
<span class="overall-calendar-seat overall-calendar-cancelled" style="display:inline-block; width:80px; height:18px; vertical-align:middle;"></span>
81+
<span style="margin-right:12px;">storniert</span>
82+
83+
<span class="overall-calendar-seat overall-calendar-open" style="display:inline-block; width:80px; height:18px; vertical-align:middle;"></span>
84+
<span>frei</span>
85+
</div>
86+
7587
<div class="overall-calendar-wrapper">
7688
<div id="overall-calendar" class="overall-calendar"></div>
7789
</div>
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
namespace BO\Zmsadmin\Tests;
4+
5+
class OverallCalendarClosureLoadDataTest extends Base
6+
{
7+
protected $arguments = [];
8+
protected $parameters = [];
9+
protected $classname = "OverallCalendarClosureLoadData";
10+
11+
public function testRenderingWithoutParameters()
12+
{
13+
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer validtoken';
14+
$_GET = [];
15+
16+
$response = $this->render([], [], []);
17+
18+
$this->assertEquals(200, $response->getStatusCode());
19+
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
20+
$body = json_decode((string)$response->getBody(), true);
21+
$this->assertIsArray($body);
22+
$this->assertEmpty($body);
23+
}
24+
25+
public function testMissingParamsReturnsError()
26+
{
27+
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer validtoken';
28+
$_GET = [
29+
'scopeIds' => '58,59'
30+
];
31+
32+
$response = $this->render([], [], []);
33+
34+
$this->assertEquals(400, $response->getStatusCode());
35+
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
36+
37+
$body = json_decode((string)$response->getBody(), true);
38+
$this->assertIsArray($body);
39+
$this->assertArrayHasKey('error', $body);
40+
$this->assertTrue($body['error']);
41+
$this->assertStringContainsString('dateFrom', $body['message']);
42+
$this->assertStringContainsString('dateUntil', $body['message']);
43+
}
44+
45+
46+
public function testRendering()
47+
{
48+
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer validtoken';
49+
$_GET = [
50+
'scopeIds' => '58,59',
51+
'dateFrom' => '2025-09-02',
52+
'dateUntil' => '2025-09-05',
53+
];
54+
55+
$this->setApiCalls([
56+
[
57+
'function' => 'readGetResult',
58+
'url' => '/closure/',
59+
'parameters' => [
60+
'scopeIds' => '58,59',
61+
'dateFrom' => '2025-09-02',
62+
'dateUntil' => '2025-09-05',
63+
],
64+
'response' => $this->readFixture('GET_Closure_Data.json'),
65+
],
66+
]);
67+
68+
$response = $this->render([], [], []);
69+
70+
$this->assertEquals(200, $response->getStatusCode());
71+
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
72+
73+
$body = json_decode((string)$response->getBody(), true);
74+
$this->assertIsArray($body);
75+
$this->assertArrayHasKey('data', $body);
76+
$this->assertArrayHasKey('items', $body['data']);
77+
$this->assertIsArray($body['data']['items']);
78+
$this->assertNotEmpty($body['data']['items']);
79+
80+
$first = $body['data']['items'][0];
81+
$this->assertArrayHasKey('scopeId', $first);
82+
$this->assertArrayHasKey('date', $first);
83+
}
84+
85+
public function testResponseStructure()
86+
{
87+
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer validtoken';
88+
$_GET = [
89+
'scopeIds' => '58,59',
90+
'dateFrom' => '2025-09-02',
91+
'dateUntil' => '2025-09-05',
92+
];
93+
94+
$this->setApiCalls([
95+
[
96+
'function' => 'readGetResult',
97+
'url' => '/closure/',
98+
'parameters' => [
99+
'scopeIds' => '58,59',
100+
'dateFrom' => '2025-09-02',
101+
'dateUntil' => '2025-09-05',
102+
],
103+
'response' => $this->readFixture('GET_Closure_Data.json'),
104+
],
105+
]);
106+
107+
$response = $this->render([], [], []);
108+
$this->assertEquals('application/json', $response->getHeaderLine('Content-Type'));
109+
110+
$body = json_decode((string)$response->getBody(), true);
111+
$this->assertArrayHasKey('$schema', $body);
112+
$this->assertArrayHasKey('meta', $body);
113+
$this->assertArrayHasKey('data', $body);
114+
$this->assertArrayHasKey('items', $body['data']);
115+
}
116+
}

0 commit comments

Comments
 (0)