goodmap 1.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,743 @@
1
+ {% extends "admin.html" %}
2
+
3
+ {% block head_meta %}
4
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
5
+ integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
6
+ crossorigin=""/>
7
+ <style>
8
+ .map { height: 400px; }
9
+ /* Ensure dropdowns appear above maps */
10
+ .dropdown-menu { z-index: 2000 !important; }
11
+ /* Modernize button spacing and padding */
12
+ .container-fluid .btn {
13
+ padding: 0.6rem 1.2rem;
14
+ margin: 0.3rem;
15
+ }
16
+ </style>
17
+ {% endblock %}
18
+ {% block title %}{{ gettext("Goodmap Admin Panel") }}{% endblock %}
19
+
20
+ {% block content %}
21
+ <div class="container-fluid py-4">
22
+ <h1 class="mb-4">{{ gettext("Admin Panel") }}</h1>
23
+ <!-- Tabs navigation -->
24
+ <ul class="nav nav-tabs mb-3" id="adminTab" role="tablist">
25
+ <li class="nav-item" role="presentation">
26
+ <button class="nav-link active" id="locations-tab" data-bs-toggle="tab" data-bs-target="#locations" type="button" role="tab" aria-controls="locations" aria-selected="true">{{ gettext("Locations") }}</button>
27
+ </li>
28
+ <li class="nav-item" role="presentation">
29
+ <button class="nav-link" id="suggestions-tab" data-bs-toggle="tab" data-bs-target="#suggestions" type="button" role="tab" aria-controls="suggestions" aria-selected="false">{{ gettext("Suggestions") }}</button>
30
+ </li>
31
+ <li class="nav-item" role="presentation">
32
+ <button class="nav-link" id="reports-tab" data-bs-toggle="tab" data-bs-target="#reports" type="button" role="tab" aria-controls="reports" aria-selected="false">{{ gettext("Reports") }}</button>
33
+ </li>
34
+ </ul>
35
+ <div class="tab-content" id="adminTabContent">
36
+ <!-- Locations Tab -->
37
+ <div class="tab-pane fade show active" id="locations" role="tabpanel" aria-labelledby="locations-tab">
38
+ <div class="d-flex flex-wrap align-items-center mb-3">
39
+ <h2 class="me-auto">{{ gettext("Locations") }}</h2>
40
+ <div class="btn-group ms-2">
41
+ <button id="add-location-btn" class="btn btn-primary">{{ gettext("Add Location") }}</button>
42
+ </div>
43
+ <div class="btn-group ms-2">
44
+ <button class="btn btn-secondary dropdown-toggle" type="button" id="filter-type-btn" data-bs-toggle="dropdown" aria-expanded="false">{{ gettext("Filter by Type") }}</button>
45
+ <div class="dropdown-menu p-3" id="filter-type-menu"></div>
46
+ </div>
47
+ <div class="btn-group ms-2">
48
+ <button class="btn btn-secondary dropdown-toggle" type="button" id="filter-access-btn" data-bs-toggle="dropdown" aria-expanded="false">{{ gettext("Filter by Accessibility") }}</button>
49
+ <div class="dropdown-menu p-3" id="filter-access-menu"></div>
50
+ </div>
51
+ </div>
52
+ <table class="table table-striped table-bordered">
53
+ <thead>
54
+ <tr>
55
+ <th>{{ gettext("UUID") }}</th>
56
+ <th>{{ gettext("Name") }}</th>
57
+ <th>{{ gettext("Position") }}</th>
58
+ <th>{{ gettext("Accessible By") }}</th>
59
+ <th>{{ gettext("Type") }}</th>
60
+ <th>{{ gettext("Actions") }}</th>
61
+ </tr>
62
+ </thead>
63
+ <tbody id="locations-body"></tbody>
64
+ </table>
65
+ <div id="locations-pagination" class="d-flex justify-content-between align-items-center mb-3"></div>
66
+ <!-- Location Form -->
67
+ <div class="card mt-4" id="location-form-card" style="display:none">
68
+ <div class="card-body">
69
+ <h3 id="location-form-title"></h3>
70
+ <form id="location-form">
71
+ <input type="hidden" id="location-uuid">
72
+ <div class="mb-3">
73
+ <label for="location-name" class="form-label">{{ gettext("Name") }}</label>
74
+ <input type="text" class="form-control" id="location-name" required>
75
+ </div>
76
+ <div class="mb-3">
77
+ <label class="form-label">{{ gettext("Position") }}</label>
78
+ <div id="location-map" class="map"></div>
79
+ <input type="hidden" id="location-position">
80
+ </div>
81
+ <div class="mb-3">
82
+ <label for="location-type" class="form-label">{{ gettext("Type") }}</label>
83
+ <select class="form-select" id="location-type" required></select>
84
+ </div>
85
+ <div class="mb-3">
86
+ <label class="form-label">{{ gettext("Accessible By") }}</label>
87
+ <div id="location-accessible" class="d-flex flex-wrap"></div>
88
+ </div>
89
+ <button type="submit" class="btn btn-success">{{ gettext("Save") }}</button>
90
+ <button type="button" class="btn btn-secondary" id="location-cancel-btn">{{ gettext("Cancel") }}</button>
91
+ <button type="button" class="btn btn-danger" id="location-delete-btn" style="display:none">{{ gettext("Delete") }}</button>
92
+ </form>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ <!-- Suggestions Tab -->
97
+ <div class="tab-pane fade" id="suggestions" role="tabpanel" aria-labelledby="suggestions-tab">
98
+ <div class="d-flex align-items-center mb-3">
99
+ <h2 class="me-auto">{{ gettext("Suggestions") }}</h2>
100
+ <div class="btn-group">
101
+ <button class="btn btn-secondary dropdown-toggle" type="button" id="suggestions-filter-btn" data-bs-toggle="dropdown" aria-expanded="false">{{ gettext("Filter by Status") }}</button>
102
+ <div class="dropdown-menu p-3" id="suggestions-filter-menu"></div>
103
+ </div>
104
+ </div>
105
+ <div id="suggestions-map" class="map mb-3"></div>
106
+ <table class="table table-striped table-bordered">
107
+ <thead>
108
+ <tr>
109
+ <th>{{ gettext("UUID") }}</th>
110
+ <th>{{ gettext("Name") }}</th>
111
+ <th>{{ gettext("Position") }}</th>
112
+ <th>{{ gettext("Status") }}</th>
113
+ <th>{{ gettext("Actions") }}</th>
114
+ </tr>
115
+ </thead>
116
+ <tbody id="suggestions-body"></tbody>
117
+ </table>
118
+ <div id="suggestions-pagination" class="d-flex justify-content-between align-items-center mb-3"></div>
119
+ </div>
120
+ <!-- Reports Tab -->
121
+ <div class="tab-pane fade" id="reports" role="tabpanel" aria-labelledby="reports-tab">
122
+ <div class="d-flex align-items-center mb-3">
123
+ <h2 class="me-auto">{{ gettext("Reports") }}</h2>
124
+ <div class="btn-group me-2">
125
+ <button class="btn btn-secondary dropdown-toggle" type="button" id="reports-filter-status-btn" data-bs-toggle="dropdown" aria-expanded="false">{{ gettext("Filter by Status") }}</button>
126
+ <div class="dropdown-menu p-3" id="reports-filter-status-menu"></div>
127
+ </div>
128
+ <div class="btn-group">
129
+ <button class="btn btn-secondary dropdown-toggle" type="button" id="reports-filter-priority-btn" data-bs-toggle="dropdown" aria-expanded="false">{{ gettext("Filter by Priority") }}</button>
130
+ <div class="dropdown-menu p-3" id="reports-filter-priority-menu"></div>
131
+ </div>
132
+ </div>
133
+ <table class="table table-striped table-bordered">
134
+ <thead>
135
+ <tr>
136
+ <th>{{ gettext("UUID") }}</th>
137
+ <th>{{ gettext("Location ID") }}</th>
138
+ <th>{{ gettext("Description") }}</th>
139
+ <th>{{ gettext("Status") }}</th>
140
+ <th>{{ gettext("Priority") }}</th>
141
+ </tr>
142
+ </thead>
143
+ <tbody id="reports-body"></tbody>
144
+ </table>
145
+ <div id="reports-pagination" class="d-flex justify-content-between align-items-center mb-3"></div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Load Leaflet JS -->
151
+ <script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js" integrity="sha256-yDc0eil8GjWFKqN1OSzHSVCiuGghTosZCcRje4tj7iQ=" crossorigin=""></script>
152
+ <script>
153
+ document.addEventListener('DOMContentLoaded', function() {
154
+ let csrfToken = '{{ csrf_token() }}';
155
+
156
+ // Data containers
157
+ let locationsData = [];
158
+ let suggestionsData = [];
159
+ let reportsData = [];
160
+
161
+ // Category options
162
+ const categories = { accessible_by: [], type_of_place: [] };
163
+
164
+ // Maps
165
+ let locationMap, locationMarker;
166
+ let suggestionsMap, suggestionsTile;
167
+ // Default map center (Poland)
168
+ const POLAND_CENTER = [52.237, 21.017];
169
+ const POLAND_ZOOM = 6;
170
+
171
+ // Priority order for sorting reports
172
+ const priorityOrder = { critical: 1, high: 2, medium: 3, low: 4 };
173
+
174
+ // Filters state
175
+ const locFilters = { type: [], access: [] };
176
+ const suggFilters = { status: ['pending'] };
177
+ const repFilters = { status: ['pending'], priority: [] };
178
+ // Pagination and sorting state
179
+ const locState = { page: 1, per_page: 20, sort_by: null, sort_order: 'asc' };
180
+ const suggState = { page: 1, per_page: 20, sort_by: null, sort_order: 'asc' };
181
+ const repState = { page: 1, per_page: 20, sort_by: null, sort_order: 'asc' };
182
+
183
+ // Initialize UI
184
+ initCategories().then(() => {
185
+ buildLocationFilters();
186
+ buildLocationFormOptions();
187
+ buildSuggestionFilters();
188
+ buildReportFilters();
189
+ loadLocations();
190
+ loadSuggestions();
191
+ loadReports();
192
+ });
193
+
194
+ function escapeHTML(str) {
195
+ const div = document.createElement('div');
196
+ div.innerText = str;
197
+ return div.innerHTML;
198
+ }
199
+
200
+ // Category fetch
201
+ function initCategories() {
202
+ return Promise.all([
203
+ fetch('/api/category/accessible_by').then(r => r.json()),
204
+ fetch('/api/category/type_of_place').then(r => r.json())
205
+ ]).then(([accessOpts, typeOpts]) => {
206
+ categories.accessible_by = accessOpts.map(o => o[0]);
207
+ categories.type_of_place = typeOpts.map(o => o[0]);
208
+ });
209
+ }
210
+
211
+ // Location Filters
212
+ function buildLocationFilters() {
213
+ const typeMenu = document.getElementById('filter-type-menu');
214
+ categories.type_of_place.forEach(type => {
215
+ const div = document.createElement('div'); div.className = 'form-check';
216
+ const cb = document.createElement('input'); cb.className = 'form-check-input loc-filter-type'; cb.type = 'checkbox'; cb.value = type;
217
+ cb.id = 'filter-type-' + type; cb.addEventListener('change', applyLocationFilters);
218
+ const lbl = document.createElement('label'); lbl.className = 'form-check-label'; lbl.htmlFor = cb.id; lbl.innerText = type;
219
+ div.append(cb, lbl); typeMenu.append(div);
220
+ });
221
+ const accessMenu = document.getElementById('filter-access-menu');
222
+ categories.accessible_by.forEach(acc => {
223
+ const div = document.createElement('div'); div.className = 'form-check';
224
+ const cb = document.createElement('input'); cb.className = 'form-check-input loc-filter-access'; cb.type = 'checkbox'; cb.value = acc;
225
+ cb.id = 'filter-access-' + acc; cb.addEventListener('change', applyLocationFilters);
226
+ const lbl = document.createElement('label'); lbl.className = 'form-check-label'; lbl.htmlFor = cb.id; lbl.innerText = acc;
227
+ div.append(cb, lbl); accessMenu.append(div);
228
+ });
229
+ document.getElementById('add-location-btn').addEventListener('click', () => showLocationForm());
230
+ document.getElementById('location-cancel-btn').addEventListener('click', hideLocationForm);
231
+ }
232
+
233
+ function applyLocationFilters() {
234
+ locFilters.type = Array.from(document.querySelectorAll('.loc-filter-type:checked')).map(cb => cb.value);
235
+ locFilters.access = Array.from(document.querySelectorAll('.loc-filter-access:checked')).map(cb => cb.value);
236
+ locState.page = 1;
237
+ loadLocations();
238
+ }
239
+
240
+ // Location Form
241
+ function buildLocationFormOptions() {
242
+ const typeSel = document.getElementById('location-type');
243
+ categories.type_of_place.forEach(type => {
244
+ const opt = document.createElement('option'); opt.value = type; opt.innerText = type; typeSel.append(opt);
245
+ });
246
+ const acDiv = document.getElementById('location-accessible');
247
+ categories.accessible_by.forEach(acc => {
248
+ const div = document.createElement('div'); div.className = 'form-check me-3';
249
+ const cb = document.createElement('input'); cb.className = 'form-check-input'; cb.type = 'checkbox'; cb.id = 'loc-form-access-' + acc; cb.value = acc;
250
+ const lbl = document.createElement('label'); lbl.className = 'form-check-label'; lbl.htmlFor = cb.id; lbl.innerText = acc;
251
+ div.append(cb, lbl); acDiv.append(div);
252
+ });
253
+ document.getElementById('location-form').addEventListener('submit', submitLocationForm);
254
+ document.getElementById('location-delete-btn').addEventListener('click', deleteLocation);
255
+ }
256
+
257
+ function showLocationForm(loc) {
258
+ document.getElementById('location-form-title').innerText = loc ? '{{ gettext("Edit Location") }}' : '{{ gettext("Add Location") }}';
259
+ document.getElementById('location-uuid').value = loc ? loc.uuid : '';
260
+ document.getElementById('location-name').value = loc ? loc.name : '';
261
+ document.getElementById('location-type').value = loc ? loc.type_of_place : '';
262
+ categories.accessible_by.forEach(acc => {
263
+ document.getElementById('loc-form-access-' + acc).checked = loc && loc.accessible_by.includes(acc);
264
+ });
265
+ document.getElementById('location-delete-btn').style.display = loc ? 'inline-block' : 'none';
266
+ const posInput = document.getElementById('location-position');
267
+ const card = document.getElementById('location-form-card');
268
+ if (!locationMap) initLocationMap();
269
+ // Set form values (marker placement handled after resize)
270
+ if (loc) {
271
+ document.getElementById('location-uuid').value = loc.uuid;
272
+ document.getElementById('location-name').value = loc.name;
273
+ document.getElementById('location-type').value = loc.type_of_place;
274
+ categories.accessible_by.forEach(acc => {
275
+ document.getElementById('loc-form-access-' + acc).checked = loc.accessible_by.includes(acc);
276
+ });
277
+ posInput.value = JSON.stringify(loc.position);
278
+ } else {
279
+ // Reset form for new
280
+ document.getElementById('location-uuid').value = '';
281
+ document.getElementById('location-name').value = '';
282
+ document.getElementById('location-type').value = '';
283
+ categories.accessible_by.forEach(acc => {
284
+ document.getElementById('loc-form-access-' + acc).checked = false;
285
+ });
286
+ if (locationMarker) locationMap.removeLayer(locationMarker);
287
+ posInput.value = '';
288
+ }
289
+ // Show form
290
+ card.style.display = 'block';
291
+ // Allow layout then resize map to fit
292
+ setTimeout(() => {
293
+ locationMap.invalidateSize();
294
+ if (loc) {
295
+ const latlng = L.latLng(loc.position[0], loc.position[1]);
296
+ placeLocationMarker(latlng);
297
+ locationMap.setView(latlng, 15);
298
+ } else {
299
+ locationMap.setView(POLAND_CENTER, POLAND_ZOOM);
300
+ }
301
+ }, 0);
302
+ window.scrollTo(0, card.offsetTop);
303
+ }
304
+
305
+ function hideLocationForm() {
306
+ document.getElementById('location-form-card').style.display = 'none';
307
+ }
308
+
309
+ function initLocationMap() {
310
+ locationMap = L.map('location-map').setView(POLAND_CENTER, POLAND_ZOOM);
311
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{ attribution: '© OpenStreetMap contributors' }).addTo(locationMap);
312
+ locationMap.on('click', e => placeLocationMarker(e.latlng));
313
+ }
314
+
315
+ function placeLocationMarker(latlng) {
316
+ if (!locationMarker) {
317
+ locationMarker = L.marker(latlng,{ draggable:true }).addTo(locationMap);
318
+ locationMarker.on('dragend', e => updateLocationPosition(e.target.getLatLng()));
319
+ } else {
320
+ locationMarker.setLatLng(latlng);
321
+ }
322
+ updateLocationPosition(latlng);
323
+ }
324
+
325
+ function updateLocationPosition(latlng) {
326
+ document.getElementById('location-position').value = JSON.stringify([latlng.lat, latlng.lng]);
327
+ }
328
+
329
+ function submitLocationForm(e) {
330
+ e.preventDefault();
331
+ const uuid = document.getElementById('location-uuid').value;
332
+ const name = document.getElementById('location-name').value;
333
+ const position = JSON.parse(document.getElementById('location-position').value || '[]');
334
+ const type = document.getElementById('location-type').value;
335
+ const accessible_by = categories.accessible_by.filter(acc => document.getElementById('loc-form-access-' + acc).checked);
336
+ const payload = { name, position, type_of_place: type, accessible_by };
337
+ const method = uuid ? 'PUT' : 'POST';
338
+ const url = '/api/admin/locations' + (uuid ? '/' + uuid : '');
339
+ fetch(url, { method, headers: { 'Content-Type':'application/json','X-CSRFToken':csrfToken }, body: JSON.stringify(payload) })
340
+ .then(r => { if (r.ok) { hideLocationForm(); loadLocations(); } else r.json().then(err => alert(err.message)); });
341
+ }
342
+
343
+ function deleteLocation() {
344
+ const uuid = document.getElementById('location-uuid').value;
345
+ if (!uuid) return;
346
+ fetch('/api/admin/locations/' + uuid, { method: 'DELETE', headers:{ 'X-CSRFToken':csrfToken } })
347
+ .then(r => { if (r.ok) { hideLocationForm(); loadLocations(); } else alert('{{ gettext("Error deleting location") }}'); });
348
+ }
349
+
350
+ function loadLocations() {
351
+ const params = new URLSearchParams();
352
+ params.append('page', locState.page);
353
+ // Handle per_page parameter ('all' for no limit)
354
+ params.append('per_page', locState.per_page === null ? 'all' : locState.per_page);
355
+ if (locState.sort_by) {
356
+ params.append('sort_by', locState.sort_by);
357
+ params.append('sort_order', locState.sort_order);
358
+ }
359
+ locFilters.type.forEach(t => params.append('type_of_place', t));
360
+ locFilters.access.forEach(a => params.append('accessible_by', a));
361
+ fetch('/api/admin/locations?' + params.toString())
362
+ .then(r => r.json())
363
+ .then(data => {
364
+ if (data.items !== undefined) {
365
+ locationsData = data.items;
366
+ locState.total = data.total;
367
+ locState.total_pages = data.total_pages;
368
+ } else {
369
+ locationsData = data;
370
+ locState.total = locationsData.length;
371
+ locState.total_pages = 1;
372
+ }
373
+ renderLocations();
374
+ renderLocationPagination();
375
+ });
376
+ }
377
+
378
+ function renderLocations() {
379
+ const tbody = document.getElementById('locations-body'); tbody.innerHTML='';
380
+ locationsData.forEach(loc => {
381
+ const tr = document.createElement('tr');
382
+ tr.innerHTML = `<td>${loc.uuid}</td><td>${loc.name}</td><td>[${loc.position}]</td><td>${loc.accessible_by.join(', ')}</td><td>${loc.type_of_place}</td><td><button class="btn btn-sm btn-primary edit-loc-btn" data-id="${loc.uuid}">{{gettext("Edit")}}</button></td>`;
383
+ tbody.appendChild(tr);
384
+ tr.querySelector('.edit-loc-btn').addEventListener('click', () => { const l = locationsData.find(x => x.uuid == loc.uuid); showLocationForm(l); });
385
+ });
386
+ }
387
+ // Pagination for Locations
388
+ function renderLocationPagination() {
389
+ const pag = document.getElementById('locations-pagination');
390
+ pag.innerHTML = '';
391
+ const info = document.createElement('div');
392
+ if (locState.per_page === null) {
393
+ info.innerText = `Showing all ${locState.total} items`;
394
+ } else {
395
+ const start = (locState.page - 1) * locState.per_page + 1;
396
+ const end = Math.min(locState.page * locState.per_page, locState.total);
397
+ info.innerText = `Showing ${start}-${end} of ${locState.total}`;
398
+ }
399
+ pag.appendChild(info);
400
+ const nav = document.createElement('div');
401
+ nav.className = 'btn-group';
402
+ const prev = document.createElement('button');
403
+ prev.className = 'btn btn-sm btn-secondary';
404
+ prev.innerText = '{{ gettext("Previous") }}';
405
+ prev.disabled = locState.page <= 1;
406
+ prev.addEventListener('click', () => { if (locState.page > 1) { locState.page--; loadLocations(); } });
407
+ const next = document.createElement('button');
408
+ next.className = 'btn btn-sm btn-secondary';
409
+ next.innerText = '{{ gettext("Next") }}';
410
+ next.disabled = locState.page >= locState.total_pages;
411
+ next.addEventListener('click', () => { if (locState.page < locState.total_pages) { locState.page++; loadLocations(); } });
412
+ nav.append(prev, next);
413
+ pag.appendChild(nav);
414
+ const selDiv = document.createElement('div');
415
+ const lbl = document.createElement('label');
416
+ lbl.className = 'me-2';
417
+ lbl.innerText = '{{ gettext("Items per page:") }}';
418
+ const sel = document.createElement('select');
419
+ sel.className = 'form-select form-select-sm d-inline-block w-auto';
420
+ [['20','20'],['50','50'],['100','100'],['all','{{ gettext("All") }}']].forEach(([val,text]) => {
421
+ const opt = document.createElement('option');
422
+ opt.value = val;
423
+ opt.innerText = text;
424
+ if ((locState.per_page === null && val === 'all') || (locState.per_page == parseInt(val))) opt.selected = true;
425
+ sel.appendChild(opt);
426
+ });
427
+ sel.addEventListener('change', () => {
428
+ const v = sel.value;
429
+ locState.per_page = v === 'all' ? null : parseInt(v);
430
+ locState.page = 1;
431
+ loadLocations();
432
+ });
433
+ selDiv.append(lbl, sel);
434
+ pag.appendChild(selDiv);
435
+ }
436
+
437
+ // Suggestions
438
+ function buildSuggestionFilters() {
439
+ const menu = document.getElementById('suggestions-filter-menu');
440
+ ['pending','accepted','rejected'].forEach(status => {
441
+ const div = document.createElement('div'); div.className='form-check';
442
+ const cb = document.createElement('input'); cb.className='form-check-input sugg-filter-status'; cb.type='checkbox'; cb.value=status; cb.id='sugg-filter-'+status;
443
+ if (status==='pending') cb.checked=true; cb.addEventListener('change', applySuggestionFilters);
444
+ const lbl = document.createElement('label'); lbl.className='form-check-label'; lbl.htmlFor=cb.id; lbl.innerText=status;
445
+ div.append(cb,lbl); menu.append(div);
446
+ });
447
+ }
448
+
449
+ function applySuggestionFilters() {
450
+ suggFilters.status = Array.from(document.querySelectorAll('.sugg-filter-status:checked')).map(cb=>cb.value);
451
+ suggState.page = 1;
452
+ loadSuggestions();
453
+ }
454
+
455
+ function loadSuggestions() {
456
+ const params = new URLSearchParams();
457
+ params.append('page', suggState.page);
458
+ // Handle per_page parameter ('all' for no limit)
459
+ params.append('per_page', suggState.per_page === null ? 'all' : suggState.per_page);
460
+ if (suggState.sort_by) {
461
+ params.append('sort_by', suggState.sort_by);
462
+ params.append('sort_order', suggState.sort_order);
463
+ }
464
+ suggFilters.status.forEach(s => params.append('status', s));
465
+ fetch('/api/admin/suggestions?' + params.toString())
466
+ .then(r => r.json())
467
+ .then(data => {
468
+ if (data.items !== undefined) {
469
+ suggestionsData = data.items;
470
+ suggState.total = data.total;
471
+ suggState.total_pages = data.total_pages;
472
+ } else {
473
+ suggestionsData = data;
474
+ suggState.total = suggestionsData.length;
475
+ suggState.total_pages = 1;
476
+ }
477
+ renderSuggestions();
478
+ renderSuggestionPagination();
479
+ });
480
+ }
481
+
482
+ function initSuggestionsMap() {
483
+ suggestionsMap = L.map('suggestions-map').setView(POLAND_CENTER, POLAND_ZOOM);
484
+ suggestionsTile = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{ attribution:'© OpenStreetMap contributors' }).addTo(suggestionsMap);
485
+ }
486
+
487
+ function renderSuggestions() {
488
+ if (!suggestionsMap) initSuggestionsMap();
489
+ const filtered = suggestionsData.filter(s=>suggFilters.status.length===0 || suggFilters.status.includes(s.status));
490
+ suggestionsMap.eachLayer(layer=>{ if (layer!==suggestionsTile) suggestionsMap.removeLayer(layer); });
491
+ filtered.forEach(s=> {
492
+ const safeName = escapeHTML(s.name || '');
493
+ const m = L.marker([s.position[0], s.position[1]]).addTo(suggestionsMap);
494
+ m.bindPopup(`<strong>${safeName}</strong><br>${s.status}`);
495
+ });
496
+
497
+ const tbody = document.getElementById('suggestions-body'); tbody.innerHTML='';
498
+ filtered.forEach(s=> {
499
+ const safeName = escapeHTML(s.name || '');
500
+ const tr = document.createElement('tr');
501
+ tr.innerHTML = `<td>${s.uuid}</td>
502
+ <td>${safeName}</td>
503
+ <td>[${s.position}]</td>
504
+ <td>${s.status}</td>
505
+ <td>
506
+ <button class="btn btn-sm btn-success sugg-accept-btn" data-id="${s.uuid}">{{ gettext("Accept") }}</button>
507
+ <button class="btn btn-sm btn-danger sugg-reject-btn" data-id="${s.uuid}">{{ gettext("Reject") }}</button>
508
+ </td>`;
509
+ tbody.appendChild(tr);
510
+ tr.querySelector('.sugg-accept-btn').addEventListener('click', ()=> updateSuggestion(s.uuid,'accepted'));
511
+ tr.querySelector('.sugg-reject-btn').addEventListener('click', ()=> updateSuggestion(s.uuid,'rejected'));
512
+ });
513
+ }
514
+
515
+ function updateSuggestion(id,status) {
516
+ fetch('/api/admin/suggestions/'+id, { method:'PUT', headers:{'Content-Type':'application/json','X-CSRFToken':csrfToken}, body: JSON.stringify({status}) })
517
+ .then(r=>{ if (r.ok) loadSuggestions(); else r.json().then(err=>alert(err.message)); });
518
+ }
519
+ // Pagination for Suggestions
520
+ function renderSuggestionPagination() {
521
+ const pag = document.getElementById('suggestions-pagination');
522
+ pag.innerHTML = '';
523
+ const info = document.createElement('div');
524
+ if (suggState.per_page === null) {
525
+ info.innerText = `Showing all ${suggState.total} items`;
526
+ } else {
527
+ const start = (suggState.page - 1) * suggState.per_page + 1;
528
+ const end = Math.min(suggState.page * suggState.per_page, suggState.total);
529
+ info.innerText = `Showing ${start}-${end} of ${suggState.total}`;
530
+ }
531
+ pag.appendChild(info);
532
+ const nav = document.createElement('div');
533
+ nav.className = 'btn-group';
534
+ const prev = document.createElement('button');
535
+ prev.className = 'btn btn-sm btn-secondary';
536
+ prev.innerText = '{{ gettext("Previous") }}';
537
+ prev.disabled = suggState.page <= 1;
538
+ prev.addEventListener('click', () => { if (suggState.page > 1) { suggState.page--; loadSuggestions(); } });
539
+ const next = document.createElement('button');
540
+ next.className = 'btn btn-sm btn-secondary';
541
+ next.innerText = '{{ gettext("Next") }}';
542
+ next.disabled = suggState.page >= suggState.total_pages;
543
+ next.addEventListener('click', () => { if (suggState.page < suggState.total_pages) { suggState.page++; loadSuggestions(); } });
544
+ nav.append(prev, next);
545
+ pag.appendChild(nav);
546
+ const selDiv = document.createElement('div');
547
+ const lbl = document.createElement('label');
548
+ lbl.className = 'me-2';
549
+ lbl.innerText = '{{ gettext("Items per page:") }}';
550
+ const sel = document.createElement('select');
551
+ sel.className = 'form-select form-select-sm d-inline-block w-auto';
552
+ [['20','20'],['50','50'],['100','100'],['all','{{ gettext("All") }}']].forEach(([val,text]) => {
553
+ const opt = document.createElement('option');
554
+ opt.value = val;
555
+ opt.innerText = text;
556
+ if ((suggState.per_page === null && val === 'all') || (suggState.per_page == parseInt(val))) opt.selected = true;
557
+ sel.appendChild(opt);
558
+ });
559
+ sel.addEventListener('change', () => {
560
+ const v = sel.value;
561
+ suggState.per_page = v === 'all' ? null : parseInt(v);
562
+ suggState.page = 1;
563
+ loadSuggestions();
564
+ });
565
+ selDiv.append(lbl, sel);
566
+ pag.appendChild(selDiv);
567
+ }
568
+
569
+ // Reports
570
+ function buildReportFilters() {
571
+ const statMenu = document.getElementById('reports-filter-status-menu');
572
+ ['pending','resolved','rejected'].forEach(status => {
573
+ const div = document.createElement('div'); div.className='form-check';
574
+ const cb = document.createElement('input'); cb.className='form-check-input rep-filter-status'; cb.type='checkbox'; cb.value=status; cb.id='rep-filter-status-'+status;
575
+ if (status==='pending') cb.checked=true; cb.addEventListener('change', applyReportFilters);
576
+ const lbl = document.createElement('label'); lbl.className='form-check-label'; lbl.htmlFor=cb.id; lbl.innerText=status;
577
+ div.append(cb,lbl); statMenu.append(div);
578
+ });
579
+ const priMenu = document.getElementById('reports-filter-priority-menu');
580
+ ['critical','high','medium','low'].forEach(pr => {
581
+ const div = document.createElement('div'); div.className='form-check';
582
+ const cb = document.createElement('input'); cb.className='form-check-input rep-filter-priority'; cb.type='checkbox'; cb.value=pr; cb.id='rep-filter-priority-'+pr;
583
+ cb.addEventListener('change', applyReportFilters);
584
+ const lbl = document.createElement('label'); lbl.className='form-check-label'; lbl.htmlFor=cb.id; lbl.innerText=pr;
585
+ div.append(cb,lbl); priMenu.append(div);
586
+ });
587
+ }
588
+
589
+ function applyReportFilters() {
590
+ repFilters.status = Array.from(document.querySelectorAll('.rep-filter-status:checked')).map(cb=>cb.value);
591
+ repFilters.priority = Array.from(document.querySelectorAll('.rep-filter-priority:checked')).map(cb=>cb.value);
592
+ repState.page = 1;
593
+ loadReports();
594
+ }
595
+
596
+ function loadReports() {
597
+ const params = new URLSearchParams();
598
+ params.append('page', repState.page);
599
+ // Handle per_page parameter ('all' for no limit)
600
+ params.append('per_page', repState.per_page === null ? 'all' : repState.per_page);
601
+ if (repState.sort_by) {
602
+ params.append('sort_by', repState.sort_by);
603
+ params.append('sort_order', repState.sort_order);
604
+ }
605
+ repFilters.status.forEach(s => params.append('status', s));
606
+ repFilters.priority.forEach(p => params.append('priority', p));
607
+ fetch('/api/admin/reports?' + params.toString())
608
+ .then(r => r.json())
609
+ .then(data => {
610
+ if (data.items !== undefined) {
611
+ reportsData = data.items;
612
+ repState.total = data.total;
613
+ repState.total_pages = data.total_pages;
614
+ } else {
615
+ reportsData = data;
616
+ repState.total = reportsData.length;
617
+ repState.total_pages = 1;
618
+ }
619
+ renderReports();
620
+ renderReportPagination();
621
+ });
622
+ }
623
+
624
+ function renderReports() {
625
+ const tbody = document.getElementById('reports-body'); tbody.innerHTML='';
626
+ reportsData
627
+ .filter(r => (repFilters.status.length===0 || repFilters.status.includes(r.status)) && (repFilters.priority.length===0 || repFilters.priority.includes(r.priority)))
628
+ .sort((a,b)=> priorityOrder[a.priority] - priorityOrder[b.priority])
629
+ .forEach(r => {
630
+ const safeDescription = escapeHTML(r.description);
631
+ const tr = document.createElement('tr');
632
+ tr.innerHTML = `<td>${r.uuid}</td>
633
+ <td>${r.location_id}</td>
634
+ <td>${safeDescription}</td>
635
+ <td>
636
+ <select class="form-select form-select-sm rep-status-select" data-id="${r.uuid}">
637
+ <option value="pending"${r.status==='pending'?' selected':''}>{{ gettext("pending") }}</option>
638
+ <option value="resolved"${r.status==='resolved'?' selected':''}>{{ gettext("resolved") }}</option>
639
+ <option value="rejected"${r.status==='rejected'?' selected':''}>{{ gettext("rejected") }}</option>
640
+ </select>
641
+ </td>
642
+ <td>
643
+ <select class="form-select form-select-sm rep-priority-select" data-id="${r.uuid}">
644
+ <option value="critical"${r.priority==='critical'?' selected':''}>{{ gettext("critical") }}</option>
645
+ <option value="high"${r.priority==='high'?' selected':''}>{{ gettext("high") }}</option>
646
+ <option value="medium"${r.priority==='medium'?' selected':''}>{{ gettext("medium") }}</option>
647
+ <option value="low"${r.priority==='low'?' selected':''}>{{ gettext("low") }}</option>
648
+ </select>
649
+ </td>`;
650
+ tbody.appendChild(tr);
651
+ tr.querySelector('.rep-status-select').addEventListener('change', e=> updateReport(r.uuid,{ status: e.target.value }));
652
+ tr.querySelector('.rep-priority-select').addEventListener('change', e=> updateReport(r.uuid,{ priority: e.target.value }));
653
+ });
654
+ }
655
+
656
+ function updateReport(id, data) {
657
+ fetch('/api/admin/reports/'+id, { method:'PUT', headers:{'Content-Type':'application/json','X-CSRFToken':csrfToken}, body: JSON.stringify(data) })
658
+ .then(r=>{ if (r.ok) loadReports(); else r.json().then(err=>alert(err.message)); });
659
+ }
660
+ // Pagination for Reports
661
+ function renderReportPagination() {
662
+ const pag = document.getElementById('reports-pagination');
663
+ pag.innerHTML = '';
664
+ const info = document.createElement('div');
665
+ if (repState.per_page === null) {
666
+ info.innerText = `Showing all ${repState.total} items`;
667
+ } else {
668
+ const start = (repState.page - 1) * repState.per_page + 1;
669
+ const end = Math.min(repState.page * repState.per_page, repState.total);
670
+ info.innerText = `Showing ${start}-${end} of ${repState.total}`;
671
+ }
672
+ pag.appendChild(info);
673
+ const nav = document.createElement('div');
674
+ nav.className = 'btn-group';
675
+ const prev = document.createElement('button');
676
+ prev.className = 'btn btn-sm btn-secondary';
677
+ prev.innerText = '{{ gettext("Previous") }}';
678
+ prev.disabled = repState.page <= 1;
679
+ prev.addEventListener('click', () => { if (repState.page > 1) { repState.page--; loadReports(); } });
680
+ const next = document.createElement('button');
681
+ next.className = 'btn btn-sm btn-secondary';
682
+ next.innerText = '{{ gettext("Next") }}';
683
+ next.disabled = repState.page >= repState.total_pages;
684
+ next.addEventListener('click', () => { if (repState.page < repState.total_pages) { repState.page++; loadReports(); } });
685
+ nav.append(prev, next);
686
+ pag.appendChild(nav);
687
+ const selDiv = document.createElement('div');
688
+ const lbl = document.createElement('label');
689
+ lbl.className = 'me-2';
690
+ lbl.innerText = '{{ gettext("Items per page:") }}';
691
+ const sel = document.createElement('select');
692
+ sel.className = 'form-select form-select-sm d-inline-block w-auto';
693
+ [['20','20'],['50','50'],['100','100'],['all','{{ gettext("All") }}']].forEach(([val,text]) => {
694
+ const opt = document.createElement('option');
695
+ opt.value = val;
696
+ opt.innerText = text;
697
+ if ((repState.per_page === null && val === 'all') || (repState.per_page == parseInt(val))) opt.selected = true;
698
+ sel.appendChild(opt);
699
+ });
700
+ sel.addEventListener('change', () => {
701
+ const v = sel.value;
702
+ repState.per_page = v === 'all' ? null : parseInt(v);
703
+ repState.page = 1;
704
+ loadReports();
705
+ });
706
+ selDiv.append(lbl, sel);
707
+ pag.appendChild(selDiv);
708
+ }
709
+ // Handle tab hash in URL and map resizing on tab change
710
+ const tabButtons = document.querySelectorAll('button[data-bs-toggle="tab"]');
711
+ tabButtons.forEach(btn => {
712
+ btn.addEventListener('shown.bs.tab', e => {
713
+ const target = e.target.getAttribute('data-bs-target');
714
+ history.replaceState(null, '', target);
715
+ if (target === '#suggestions' && suggestionsMap) {
716
+ suggestionsMap.invalidateSize();
717
+ suggestionsMap.setView(POLAND_CENTER, POLAND_ZOOM);
718
+ }
719
+ });
720
+ });
721
+ // Activate tab from URL hash on load
722
+ const hash = window.location.hash;
723
+ if (hash) {
724
+ const triggerEl = document.querySelector('button[data-bs-target="' + hash + '"]');
725
+ if (triggerEl) {
726
+ const tab = new bootstrap.Tab(triggerEl);
727
+ tab.show();
728
+ }
729
+ }
730
+ });
731
+ </script>
732
+ <script>
733
+ window.APP_LANG = "{{ current_language }}";
734
+ window.SECONDARY_COLOR = "{{ secondary_color }}";
735
+ window.PRIMARY_COLOR = "{{ primary_color }}";
736
+
737
+ window.SHOW_SUGGEST_NEW_POINT_BUTTON = {{ feature_flags.SHOW_SUGGEST_NEW_POINT_BUTTON | default(false) | tojson }};
738
+ window.SHOW_SEARCH_BAR = {{ feature_flags.SHOW_SEARCH_BAR | default(false) | tojson }};
739
+ window.USE_LAZY_LOADING = {{ feature_flags.USE_LAZY_LOADING | default(false) | tojson }};
740
+ window.SHOW_ACCESSIBILITY_TABLE = {{ feature_flags.SHOW_ACCESSIBILITY_TABLE | default(false) | tojson }};
741
+ </script>
742
+ <script src="{{ goodmap_frontend_lib_url }}"></script>
743
+ {% endblock %}