1 Commits

5 changed files with 61 additions and 265 deletions

View File

@@ -35,7 +35,7 @@ COMMENT ON TABLE municipalities IS 'Configuration Per Municipality (Tenant) usin
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Block 3: Table "contributions" -- Block 3: Table "contributions"
-- Citizen and Administration Contributions as Points, Lines, and -- Aitizen and Administration Contributions as Points, Lines, and
-- Polygons stored together in one mixed-geometry Column. -- Polygons stored together in one mixed-geometry Column.
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
CREATE TABLE contributions ( CREATE TABLE contributions (

View File

@@ -1,181 +0,0 @@
-- =====================================================================
-- Migration 009: Tasks Module — Tasks with Reward System
-- =====================================================================
-- ---------------------------------------------------------------------
-- Block 1: Tasks Table
-- Stores Tasks with Geometry, Moderation and Completion.
-- Status Flow from pending to rejected or approved to completed to verified
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS tasks (
task_id SERIAL PRIMARY KEY,
municipality_id INTEGER NOT NULL REFERENCES municipalities(municipality_id),
geom GEOMETRY(Geometry, 4326) NOT NULL,
geom_type VARCHAR(10) NOT NULL CHECK (geom_type IN ('point', 'line', 'polygon')),
category VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT DEFAULT '',
points_reward INTEGER NOT NULL DEFAULT 25,
author_name VARCHAR(100) NOT NULL,
browser_id VARCHAR(36),
photo_path VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'rejected', 'approved', 'completed', 'verified')),
address VARCHAR(255),
-- Completion Fields empty before completed
completed_by_name VARCHAR(100),
completed_by_browser VARCHAR(36),
completion_photo VARCHAR(255),
completion_comment TEXT,
completed_at TIMESTAMP,
-- Counters updated via Triggers
likes_count INTEGER NOT NULL DEFAULT 0,
dislikes_count INTEGER NOT NULL DEFAULT 0,
comment_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_municipality ON tasks(municipality_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_category ON tasks(category);
-- ---------------------------------------------------------------------
-- Block 2: Citizen Points Table
-- One Row per Completion. Leaderboard via SUM and GROUP BY.
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS user_points (
points_id SERIAL PRIMARY KEY,
municipality_id INTEGER NOT NULL REFERENCES municipalities(municipality_id),
user_name VARCHAR(100) NOT NULL,
points INTEGER NOT NULL DEFAULT 25,
task_id INTEGER NOT NULL REFERENCES tasks(task_id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_points_municipality ON user_points(municipality_id);
CREATE INDEX idx_user_points_user ON user_points(user_name);
-- ---------------------------------------------------------------------
-- Block 3: Adapts Votes Table for Tasks
-- Either contribution_id OR task_id
-- ---------------------------------------------------------------------
ALTER TABLE votes
ADD COLUMN task_id INTEGER REFERENCES tasks(task_id) ON DELETE CASCADE;
CREATE INDEX idx_votes_task ON votes(task_id);
-- Unique Vote per Browser per Task
ALTER TABLE votes
ADD CONSTRAINT votes_task_browser_unique
UNIQUE (task_id, browser_id);
-- ---------------------------------------------------------------------
-- Block 4: Adapts Comments Table for Tasks
-- Either contribution_id OR task_id
-- ---------------------------------------------------------------------
ALTER TABLE comments
ADD COLUMN task_id INTEGER REFERENCES tasks(task_id) ON DELETE CASCADE;
CREATE INDEX idx_comments_task ON comments(task_id);
-- ---------------------------------------------------------------------
-- Block 5: Trigger Updated Timestamp for Tasks
-- ---------------------------------------------------------------------
CREATE TRIGGER set_tasks_updated_at
BEFORE UPDATE ON tasks
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------
-- Block 6: Trigger Vote Counts for Tasks
-- Mirrors Pattern from Contributions.
-- ---------------------------------------------------------------------
CREATE OR REPLACE FUNCTION update_task_vote_counts()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
IF NEW.task_id IS NOT NULL THEN
UPDATE tasks SET
likes_count = (SELECT COUNT(*) FROM votes WHERE task_id = NEW.task_id AND vote_type = 'like'),
dislikes_count = (SELECT COUNT(*) FROM votes WHERE task_id = NEW.task_id AND vote_type = 'dislike')
WHERE task_id = NEW.task_id;
END IF;
END IF;
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.task_id IS NOT NULL) THEN
UPDATE tasks SET
likes_count = (SELECT COUNT(*) FROM votes WHERE task_id = OLD.task_id AND vote_type = 'like'),
dislikes_count = (SELECT COUNT(*) FROM votes WHERE task_id = OLD.task_id AND vote_type = 'dislike')
WHERE task_id = OLD.task_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_task_vote_counts
AFTER INSERT OR DELETE OR UPDATE ON votes
FOR EACH ROW
WHEN (NEW.task_id IS NOT NULL OR OLD.task_id IS NOT NULL)
EXECUTE FUNCTION update_task_vote_counts();
-- ---------------------------------------------------------------------
-- Block 7: Trigger Comment Count for Tasks
-- Mirrors Pattern from Contributions.
-- ---------------------------------------------------------------------
CREATE OR REPLACE FUNCTION update_task_comment_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
IF NEW.task_id IS NOT NULL THEN
UPDATE tasks
SET comment_count = (
SELECT COUNT(*) FROM comments
WHERE task_id = NEW.task_id AND status = 'approved'
)
WHERE task_id = NEW.task_id;
END IF;
END IF;
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND OLD.task_id IS NOT NULL) THEN
UPDATE tasks
SET comment_count = (
SELECT COUNT(*) FROM comments
WHERE task_id = OLD.task_id AND status = 'approved'
)
WHERE task_id = OLD.task_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_task_comment_count
AFTER INSERT OR DELETE OR UPDATE OF status ON comments
FOR EACH ROW
WHEN (NEW.task_id IS NOT NULL OR OLD.task_id IS NOT NULL)
EXECUTE FUNCTION update_task_comment_count();
-- ---------------------------------------------------------------------
-- Block 8: Views for QGIS
-- ---------------------------------------------------------------------
CREATE OR REPLACE VIEW tasks_points AS
SELECT * FROM tasks WHERE geom_type = 'point';
CREATE OR REPLACE VIEW tasks_lines AS
SELECT * FROM tasks WHERE geom_type = 'line';
CREATE OR REPLACE VIEW tasks_polygons AS
SELECT * FROM tasks WHERE geom_type = 'polygon';

View File

@@ -131,9 +131,9 @@ $news_items = $stmt->fetchAll();
<div class="leaflet-sidebar-tabs"> <div class="leaflet-sidebar-tabs">
<ul role="tablist"> <ul role="tablist">
<li><a href="#tab-home" role="tab"><i class="fa-solid fa-house"></i></a></li> <li><a href="#tab-home" role="tab"><i class="fa-solid fa-house"></i></a></li>
<li><a href="#tab-help" role="tab"><i class="fa-solid fa-circle-question"></i></a></li>
<li><a href="#tab-list" role="tab"><i class="fa-solid fa-list"></i></a></li> <li><a href="#tab-list" role="tab"><i class="fa-solid fa-list"></i></a></li>
<li><a href="#tab-news" role="tab"><i class="fa-solid fa-newspaper"></i></a></li> <li><a href="#tab-news" role="tab"><i class="fa-solid fa-newspaper"></i></a></li>
<li><a href="#tab-help" role="tab"><i class="fa-solid fa-circle-question"></i></a></li>
</ul> </ul>
</div> </div>
@@ -148,17 +148,12 @@ $news_items = $stmt->fetchAll();
</h2> </h2>
<div class="sidebar-body"> <div class="sidebar-body">
<p>Willkommen beim Bürgerbeteiligungsportal <strong><?= htmlspecialchars($municipality['name']) ?></strong>.</p> <p>Willkommen beim Bürgerbeteiligungsportal <strong><?= htmlspecialchars($municipality['name']) ?></strong>.</p>
<p>Verwenden Sie die Karte, um Hinweise und Aufgaben für die Stadtverwaltung hinzuzufügen oder bestehende Beiträge der Bürgerschaft zu betrachten.</p> <p>Verwenden Sie die Karte, um Hinweise für die Stadtverwaltung hinzuzufügen oder bestehende Beiträge zu betrachten, zu bewerten und zu kommentieren.</p>
<h3>Kategorien</h3> <h3>Kategorien</h3>
<div id="category-filter"> <div id="category-filter">
<!-- Category Filter Checkboxes — populated by app.js --> <!-- Category Filter Checkboxes — populated by app.js -->
</div> </div>
<h3>Statistik</h3>
<div id="stats-container">
<!-- Contribution Statistics — populated by app.js -->
</div>
</div> </div>
</div> </div>
@@ -178,7 +173,42 @@ $news_items = $stmt->fetchAll();
</div> </div>
</div> </div>
<!-- Help Tab --> <!-- News Tab -->
<div class="leaflet-sidebar-pane" id="tab-news">
<h2 class="leaflet-sidebar-header">
Neuigkeiten
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<!-- News Search -->
<div class="list-search">
<input type="text" id="news-search-input" placeholder="Neuigkeiten durchsuchen..." class="form-input" oninput="filterNews()">
</div>
<!-- News Items Container -->
<div id="news-list">
<?php if (empty($news_items)): ?>
<p class="empty-state">Noch keine Neuigkeiten veröffentlicht.</p>
<?php else: ?>
<?php foreach ($news_items as $news): ?>
<div class="news-item"
data-title="<?= htmlspecialchars(strtolower($news['title'])) ?>"
data-content="<?= htmlspecialchars(strtolower($news['content'])) ?>"
data-author="<?= htmlspecialchars(strtolower($news['author_name'])) ?>">
<h3><?= htmlspecialchars($news['title']) ?></h3>
<p><?= nl2br(htmlspecialchars($news['content'])) ?></p>
<span class="news-date">
<?= htmlspecialchars($news['author_name']) ?>
· <?= date('d.m.Y', strtotime($news['published_at'])) ?>
</span>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<!-- Help Tab -->
<div class="leaflet-sidebar-pane" id="tab-help"> <div class="leaflet-sidebar-pane" id="tab-help">
<h2 class="leaflet-sidebar-header"> <h2 class="leaflet-sidebar-header">
Hilfe Hilfe
@@ -207,43 +237,6 @@ $news_items = $stmt->fetchAll();
<h3><i class="fa-solid fa-magnifying-glass"></i> Suchen</h3> <h3><i class="fa-solid fa-magnifying-glass"></i> Suchen</h3>
<p>Verwenden Sie die Adresssuche rechts, um schnell den richtigen Ort auf der Mitmachkarte zu finden.</p> <p>Verwenden Sie die Adresssuche rechts, um schnell den richtigen Ort auf der Mitmachkarte zu finden.</p>
</div>
</div>
<!-- News Tab -->
<div class="leaflet-sidebar-pane" id="tab-news">
<h2 class="leaflet-sidebar-header">
Neuigkeiten
<span class="leaflet-sidebar-close"><i class="fa-solid fa-xmark"></i></span>
</h2>
<div class="sidebar-body">
<!-- News Search -->
<div class="list-search">
<input type="text" id="news-search-input" placeholder="Neuigkeiten durchsuchen..." class="form-input" oninput="filterNews()">
</div>
<!-- News Items Container -->
<div id="news-list">
<?php if (empty($news_items)): ?>
<p style="text-align:center;color:#999;padding:20px;">Noch keine Neuigkeiten veröffentlicht.</p>
<?php else: ?>
<?php foreach ($news_items as $news): ?>
<div class="news-item"
data-title="<?= htmlspecialchars(strtolower($news['title'])) ?>"
data-content="<?= htmlspecialchars(strtolower($news['content'])) ?>"
data-author="<?= htmlspecialchars(strtolower($news['author_name'])) ?>">
<h3><?= htmlspecialchars($news['title']) ?></h3>
<p><?= nl2br(htmlspecialchars($news['content'])) ?></p>
<span class="news-date">
<?= htmlspecialchars($news['author_name']) ?>
· <?= date('d.m.Y', strtotime($news['published_at'])) ?>
</span>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div> </div>
</div> </div>

View File

@@ -346,7 +346,7 @@ function loadContributions() {
layerControl.addOverlay(contributionsLayer, '<i class="fa-solid fa-map-pin" style="color:#C00000;"></i> Beiträge'); layerControl.addOverlay(contributionsLayer, '<i class="fa-solid fa-map-pin" style="color:#C00000;"></i> Beiträge');
// Update Sidebar List and Statistics // Update Sidebar List and Statistics
updateContributionsList(); updateContributionsList();
updateStatistics(); buildCategoryFilter();
}); });
} }
@@ -766,7 +766,7 @@ function updateContributionsList() {
// Builds HTML // Builds HTML
if (filtered.length === 0) { if (filtered.length === 0) {
container.innerHTML = '<p style="text-align:center;color:#999;padding:20px;">Keine Beiträge gefunden.</p>'; container.innerHTML = '<p class="empty-state">Keine Beiträge gefunden.</p>';
return; return;
} }
@@ -830,22 +830,33 @@ document.getElementById('list-search-input').addEventListener('input', function
// Block 12: Sidebar Category Filter and Statistics // Block 12: Sidebar Category Filter and Statistics
// ===================================================================== // =====================================================================
// Builds Category Filter Checkboxes // Builds Category Filter Checkboxes with Counts
function buildCategoryFilter() { function buildCategoryFilter() {
const container = document.getElementById('category-filter'); const container = document.getElementById('category-filter');
const counts = {};
contributionsData.forEach(function (f) {
const cat = f.properties.category;
counts[cat] = (counts[cat] || 0) + 1;
});
const total = contributionsData.length;
let html = ''; let html = '';
for (const key in CATEGORIES) { for (const key in CATEGORIES) {
const cat = CATEGORIES[key]; const cat = CATEGORIES[key];
const checked = activeFilters.indexOf(key) !== -1 ? 'checked' : ''; const checked = activeFilters.indexOf(key) !== -1 ? 'checked' : '';
const count = counts[key] || 0;
html += '' + html += '<label style="display:flex;align-items:center;gap:8px;margin-bottom:6px;cursor:pointer;font-size:0.85rem;color:var(--color-text-secondary)">' +
'<label style="display:flex;align-items:center;gap:8px;margin-bottom:6px;cursor:pointer;">' + '<input type="checkbox" value="' + key + '" ' + checked + ' onchange="toggleCategoryFilter(this)">' +
'<input type="checkbox" value="' + key + '" ' + checked + ' onchange="toggleCategoryFilter(this)">' + categoryIcon(cat) +
'<span>' + categoryIcon(cat) + ' ' + cat.label + '</span>' + '<span>' + cat.label + ' (' + count + ')</span>' +
'</label>'; '</label>';
} }
html += '<p style="margin-top:10px;font-size:0.85rem;color:var(--color-text-secondary)"><strong>' + total + '</strong> Beiträge insgesamt</p>';
container.innerHTML = html; container.innerHTML = html;
} }
@@ -892,34 +903,6 @@ function toggleCategoryFilter(checkbox) {
updateContributionsList(); updateContributionsList();
} }
// Updates Statistics in Home Tab
function updateStatistics() {
const container = document.getElementById('stats-container');
const total = contributionsData.length;
// Counts per Category
const counts = {};
contributionsData.forEach(function (f) {
const cat = f.properties.category;
counts[cat] = (counts[cat] || 0) + 1;
});
let html = '<p style="font-size:0.9rem;"><strong>' + total + '</strong> Beiträge insgesamt</p>';
for (const key in CATEGORIES) {
const cat = CATEGORIES[key];
const count = counts[key] || 0;
if (count > 0) {
html += '<div style="display:flex;align-items:center;gap:8px;margin:4px 0;font-size:0.85rem;">' +
categoryIcon(cat) + ' ' +
cat.label + ': ' + count +
'</div>';
}
}
container.innerHTML = html;
}
// ===================================================================== // =====================================================================
// Block 13: Modals — Welcome, Login, Info, Privacy, Imprint // Block 13: Modals — Welcome, Login, Info, Privacy, Imprint
@@ -1034,7 +1017,7 @@ function escapeHtml(text) {
// Returns a colored Font Awesome Icon HTML String for a Category // Returns a colored Font Awesome Icon HTML String for a Category
function categoryIcon(cat) { function categoryIcon(cat) {
return '<i class="fa-solid ' + cat.faIcon + '" style="color:' + cat.color + ';"></i>'; return '<i class="fa-solid ' + cat.faIcon + ' fa-fw" style="color:' + cat.color + ';"></i>';
} }
// Reverse Geocodes Coordinates and saves Address to Contribution via API // Reverse Geocodes Coordinates and saves Address to Contribution via API

View File

@@ -464,6 +464,7 @@ select.form-input { cursor: pointer; }
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
line-height: 1.5; line-height: 1.5;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 0.85rem;
} }