Initial commit: Antragsideen-App Skeleton

- Backend (FastAPI)
- Frontend
- Docker Compose
- README
This commit is contained in:
Dotty Dotter 2026-03-30 23:49:13 +02:00
commit 34d5671997
24 changed files with 1340 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
__pycache__/
*.pyc
.venv/
*.db
node_modules/
.env
.DS_Store

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# Antragsideen Hagen
Web-Applikation zur Verwaltung und Visualisierung der Grünen Antragsideen.
## Quick Start
```bash
# Starten
docker-compose up -d --build
# Öffnen
open http://localhost:8080
# Logs
docker-compose logs -f
# Stoppen
docker-compose down
```
## Features
- **Graph-View**: Interaktive Netzwerk-Darstellung mit Cytoscape.js
- **Tabellen-View**: Sortierbare Übersicht
- **Kanban-View**: Status-basierte Spaltenansicht
- **CRUD**: Vollständige Bearbeitung der Anträge
- **Verbindungen**: Automatisch (Ausschuss/Person) oder manuell
## Datenbank
SQLite unter `data/antragsideen.db`
## API
- `GET /api/antraege` — Liste aller Anträge
- `GET /api/antraege/:id` — Einzelner Antrag
- `POST /api/antraege` — Neuer Antrag
- `PUT /api/antraege/:id` — Update
- `DELETE /api/antraege/:id` — Löschen
- `GET /api/graph` — Graph-Daten (Nodes + Edges)
- `PUT /api/graph/positions` — Positionen speichern
- `GET /api/stammdaten` — Ausschüsse, Personen, etc.

13
backend/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM node:20-alpine
RUN apk add --no-cache python3 make g++ wget
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

14
backend/package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "antragsideen-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"cors": "^2.8.5",
"express": "^4.18.2"
}
}

282
backend/server.js Normal file
View File

@ -0,0 +1,282 @@
import express from 'express';
import cors from 'cors';
import Database from 'better-sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3000;
const DB_PATH = process.env.DB_PATH || join(__dirname, '../data/antragsideen.db');
app.use(cors());
app.use(express.json({ limit: '10mb' }));
// DB Connection
const db = new Database(DB_PATH);
db.pragma('foreign_keys = ON');
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// ============================================
// ANTRÄGE
// ============================================
// Liste aller Anträge (mit Joins)
app.get('/api/antraege', (req, res) => {
const antraege = db.prepare(`
SELECT
a.*,
p.name as prioritaet,
p.farbe as prioritaet_farbe,
s.name as status,
s.farbe as status_farbe,
b.name as bereich,
b.farbe as bereich_farbe,
b.icon as bereich_icon
FROM antraege a
LEFT JOIN prioritaeten p ON a.prioritaet_id = p.id
LEFT JOIN status_typen s ON a.status_id = s.id
LEFT JOIN bereiche b ON a.bereich_id = b.id
ORDER BY p.reihenfolge, a.id
`).all();
// Ausschüsse und Personen hinzufügen
const ausschuesse = db.prepare(`
SELECT aa.antrag_id, aus.id, aus.name, aus.kuerzel
FROM antrag_ausschuesse aa
JOIN ausschuesse aus ON aa.ausschuss_id = aus.id
`).all();
const personen = db.prepare(`
SELECT ap.antrag_id, p.id, p.name, p.rolle
FROM antrag_personen ap
JOIN personen p ON ap.person_id = p.id
`).all();
const ausschuesseMap = {};
ausschuesse.forEach(a => {
if (!ausschuesseMap[a.antrag_id]) ausschuesseMap[a.antrag_id] = [];
ausschuesseMap[a.antrag_id].push({ id: a.id, name: a.name, kuerzel: a.kuerzel });
});
const personenMap = {};
personen.forEach(p => {
if (!personenMap[p.antrag_id]) personenMap[p.antrag_id] = [];
personenMap[p.antrag_id].push({ id: p.id, name: p.name, rolle: p.rolle });
});
antraege.forEach(a => {
a.ausschuesse = ausschuesseMap[a.id] || [];
a.personen = personenMap[a.id] || [];
});
res.json(antraege);
});
// Einzelner Antrag
app.get('/api/antraege/:id', (req, res) => {
const antrag = db.prepare(`
SELECT
a.*,
p.name as prioritaet,
s.name as status,
b.name as bereich
FROM antraege a
LEFT JOIN prioritaeten p ON a.prioritaet_id = p.id
LEFT JOIN status_typen s ON a.status_id = s.id
LEFT JOIN bereiche b ON a.bereich_id = b.id
WHERE a.id = ?
`).get(req.params.id);
if (!antrag) return res.status(404).json({ error: 'Nicht gefunden' });
antrag.ausschuesse = db.prepare(`
SELECT aus.id, aus.name FROM antrag_ausschuesse aa
JOIN ausschuesse aus ON aa.ausschuss_id = aus.id
WHERE aa.antrag_id = ?
`).all(req.params.id);
antrag.personen = db.prepare(`
SELECT p.id, p.name FROM antrag_personen ap
JOIN personen p ON ap.person_id = p.id
WHERE ap.antrag_id = ?
`).all(req.params.id);
res.json(antrag);
});
// Neuer Antrag
app.post('/api/antraege', (req, res) => {
const { titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id, ausschuesse, personen } = req.body;
const result = db.prepare(`
INSERT INTO antraege (titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id)
VALUES (?, ?, ?, ?, ?)
`).run(titel, kurzbeschreibung, prioritaet_id || 2, status_id || 1, bereich_id || 1);
const antragId = result.lastInsertRowid;
if (ausschuesse?.length) {
const insertAus = db.prepare('INSERT OR IGNORE INTO antrag_ausschuesse (antrag_id, ausschuss_id) VALUES (?, ?)');
ausschuesse.forEach(id => insertAus.run(antragId, id));
}
if (personen?.length) {
const insertPers = db.prepare('INSERT OR IGNORE INTO antrag_personen (antrag_id, person_id) VALUES (?, ?)');
personen.forEach(id => insertPers.run(antragId, id));
}
res.status(201).json({ id: antragId });
});
// Antrag aktualisieren
app.put('/api/antraege/:id', (req, res) => {
const { titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id,
dossier, antragstext, notizen, ausschuesse, personen, position_x, position_y } = req.body;
db.prepare(`
UPDATE antraege SET
titel = COALESCE(?, titel),
kurzbeschreibung = COALESCE(?, kurzbeschreibung),
prioritaet_id = COALESCE(?, prioritaet_id),
status_id = COALESCE(?, status_id),
bereich_id = COALESCE(?, bereich_id),
dossier = COALESCE(?, dossier),
antragstext = COALESCE(?, antragstext),
notizen = COALESCE(?, notizen),
position_x = COALESCE(?, position_x),
position_y = COALESCE(?, position_y),
aktualisiert_am = date('now')
WHERE id = ?
`).run(titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id,
dossier, antragstext, notizen, position_x, position_y, req.params.id);
// Ausschüsse aktualisieren
if (ausschuesse !== undefined) {
db.prepare('DELETE FROM antrag_ausschuesse WHERE antrag_id = ?').run(req.params.id);
const insertAus = db.prepare('INSERT INTO antrag_ausschuesse (antrag_id, ausschuss_id) VALUES (?, ?)');
ausschuesse.forEach(id => insertAus.run(req.params.id, id));
}
// Personen aktualisieren
if (personen !== undefined) {
db.prepare('DELETE FROM antrag_personen WHERE antrag_id = ?').run(req.params.id);
const insertPers = db.prepare('INSERT INTO antrag_personen (antrag_id, person_id) VALUES (?, ?)');
personen.forEach(id => insertPers.run(req.params.id, id));
}
res.json({ success: true });
});
// Antrag löschen
app.delete('/api/antraege/:id', (req, res) => {
db.prepare('DELETE FROM antraege WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ============================================
// GRAPH-DATEN
// ============================================
app.get('/api/graph', (req, res) => {
const nodes = db.prepare(`
SELECT
a.id, a.titel, a.kurzbeschreibung, a.position_x, a.position_y,
p.name as prioritaet, p.farbe as prioritaet_farbe, p.reihenfolge as prio_order,
b.name as bereich, b.farbe as bereich_farbe, b.icon as bereich_icon,
CASE WHEN length(a.dossier) > 100 THEN 1 ELSE 0 END as hat_dossier
FROM antraege a
LEFT JOIN prioritaeten p ON a.prioritaet_id = p.id
LEFT JOIN bereiche b ON a.bereich_id = b.id
`).all();
// Kanten basierend auf gemeinsamen Ausschüssen/Personen
const edgesByAusschuss = db.prepare(`
SELECT DISTINCT aa1.antrag_id as source, aa2.antrag_id as target, 'ausschuss' as typ
FROM antrag_ausschuesse aa1
JOIN antrag_ausschuesse aa2 ON aa1.ausschuss_id = aa2.ausschuss_id
WHERE aa1.antrag_id < aa2.antrag_id
`).all();
const edgesByPerson = db.prepare(`
SELECT DISTINCT ap1.antrag_id as source, ap2.antrag_id as target, 'person' as typ
FROM antrag_personen ap1
JOIN antrag_personen ap2 ON ap1.person_id = ap2.person_id
WHERE ap1.antrag_id < ap2.antrag_id
`).all();
const manualEdges = db.prepare(`
SELECT von_antrag_id as source, nach_antrag_id as target, typ, gewicht
FROM verbindungen
`).all();
res.json({
nodes,
edges: {
ausschuss: edgesByAusschuss,
person: edgesByPerson,
manuell: manualEdges
}
});
});
// Positionen speichern
app.put('/api/graph/positions', (req, res) => {
const { positions } = req.body; // { id: { x, y }, ... }
const update = db.prepare('UPDATE antraege SET position_x = ?, position_y = ? WHERE id = ?');
db.exec('BEGIN');
for (const [id, pos] of Object.entries(positions)) {
update.run(pos.x, pos.y, id);
}
db.exec('COMMIT');
res.json({ success: true });
});
// ============================================
// VERBINDUNGEN
// ============================================
app.get('/api/verbindungen', (req, res) => {
const verbindungen = db.prepare('SELECT * FROM verbindungen').all();
res.json(verbindungen);
});
app.post('/api/verbindungen', (req, res) => {
const { von_antrag_id, nach_antrag_id, typ, gewicht, notiz } = req.body;
const result = db.prepare(`
INSERT INTO verbindungen (von_antrag_id, nach_antrag_id, typ, gewicht, notiz)
VALUES (?, ?, ?, ?, ?)
`).run(von_antrag_id, nach_antrag_id, typ || 'manuell', gewicht || 1, notiz);
res.status(201).json({ id: result.lastInsertRowid });
});
app.delete('/api/verbindungen/:id', (req, res) => {
db.prepare('DELETE FROM verbindungen WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ============================================
// STAMMDATEN
// ============================================
app.get('/api/stammdaten', (req, res) => {
res.json({
prioritaeten: db.prepare('SELECT * FROM prioritaeten ORDER BY reihenfolge').all(),
status_typen: db.prepare('SELECT * FROM status_typen ORDER BY reihenfolge').all(),
bereiche: db.prepare('SELECT * FROM bereiche').all(),
ausschuesse: db.prepare('SELECT * FROM ausschuesse ORDER BY name').all(),
personen: db.prepare('SELECT * FROM personen ORDER BY name').all()
});
});
// ============================================
// START
// ============================================
app.listen(PORT, () => {
console.log(`🚀 API läuft auf http://localhost:${PORT}`);
});

116
data/migrate.js Normal file
View File

@ -0,0 +1,116 @@
const Database = require('better-sqlite3');
const path = require('path');
const oldDb = new Database(path.join(__dirname, '../../antragsideen.db'), { readonly: true });
const newDb = new Database(path.join(__dirname, 'antragsideen.db'));
// Ausschuss-Normalisierung
const ausschussMap = {
'Umweltausschuss': 1,
'Infrastrukturausschuss': 2, 'Infrastruktur': 2,
'HFA': 3, 'Hauptausschuss': 3, 'Haupt- und Finanzausschuss': 3,
'Stadtentwicklungsausschuss': 4, 'Stadtentwicklung': 4,
'Sozialausschuss': 5, 'Soziales': 5,
'Wirtschaftsausschuss': 6, 'Wirtschaft': 6,
'Sport- und Freizeitausschuss': 7, 'Sport': 7,
'Schulausschuss': 8,
'Ausschuss für Bürgerbeteiligung': 9, 'Bürgerbeteiligung': 9
};
// Personen-Map
const personenMap = {
'Rüdiger Ludwig': 1,
'Heike Heuer': 2,
'Jörg Fritzsche': 3,
'Karin Köppen': 4,
'Daniel Adam': 5,
'Nicole Pfefferer': 6
};
// Priorität-Map
const prioMap = { 'Hoch': 1, 'Mittel': 2, 'Niedrig': 3, 'Abgeschlossen': 4 };
// Status-Map
const statusMap = {
'Ideenspeicher': 1, 'Recherche': 2, 'Entwurf': 3,
'Eingereicht': 4, 'Beschlossen': 5, 'Abgelehnt': 6, 'Abgeschlossen': 7
};
// Bereich-Map
const bereichMap = { 'Umwelt': 1, 'Infra': 2, 'HFA': 3, 'Soziales': 4, 'Stadt': 5, 'Wirtschaft': 6 };
// Anträge migrieren
const antraege = oldDb.prepare('SELECT * FROM antraege').all();
const insertAntrag = newDb.prepare(`
INSERT INTO antraege (id, titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id,
dossier, antragstext, notizen, allris_referenzen, erstellt_am, aktualisiert_am)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertAntragAusschuss = newDb.prepare(`
INSERT OR IGNORE INTO antrag_ausschuesse (antrag_id, ausschuss_id) VALUES (?, ?)
`);
const insertAntragPerson = newDb.prepare(`
INSERT OR IGNORE INTO antrag_personen (antrag_id, person_id) VALUES (?, ?)
`);
newDb.exec('BEGIN TRANSACTION');
for (const a of antraege) {
// Antrag einfügen
insertAntrag.run(
a.id,
a.titel,
a.kurzbeschreibung,
prioMap[a.prioritaet] || 2,
statusMap[a.status] || 1,
bereichMap[a.bereich] || 1,
a.dossier,
a.antragstext,
a.notizen,
a.allris_referenzen,
a.erstellt_am,
a.aktualisiert_am
);
// Ausschüsse verknüpfen
if (a.ausschuesse) {
try {
const ausschuesse = JSON.parse(a.ausschuesse);
for (const aus of ausschuesse) {
const ausId = ausschussMap[aus];
if (ausId) insertAntragAusschuss.run(a.id, ausId);
}
} catch (e) {}
}
// Personen verknüpfen
if (a.personen) {
try {
const personen = JSON.parse(a.personen);
for (const pers of personen) {
const persId = personenMap[pers];
if (persId) insertAntragPerson.run(a.id, persId);
}
} catch (e) {}
}
}
newDb.exec('COMMIT');
console.log(`${antraege.length} Anträge migriert`);
// Statistik
const stats = newDb.prepare(`
SELECT
(SELECT COUNT(*) FROM antraege) as antraege,
(SELECT COUNT(*) FROM antrag_ausschuesse) as ausschuss_links,
(SELECT COUNT(*) FROM antrag_personen) as personen_links
`).get();
console.log(`${stats.ausschuss_links} Ausschuss-Verknüpfungen`);
console.log(`${stats.personen_links} Personen-Verknüpfungen`);
oldDb.close();
newDb.close();

192
data/schema.sql Normal file
View File

@ -0,0 +1,192 @@
-- ============================================
-- ANTRAGSIDEEN HAGEN - Relationales Schema
-- Version: 2.0
-- Erstellt: 2026-03-06
-- ============================================
PRAGMA foreign_keys = ON;
-- ============================================
-- STAMMDATEN
-- ============================================
CREATE TABLE IF NOT EXISTS prioritaeten (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
reihenfolge INTEGER,
farbe TEXT
);
CREATE TABLE IF NOT EXISTS status_typen (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
reihenfolge INTEGER,
farbe TEXT
);
CREATE TABLE IF NOT EXISTS bereiche (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
kuerzel TEXT,
farbe TEXT,
icon TEXT
);
CREATE TABLE IF NOT EXISTS ausschuesse (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
kuerzel TEXT,
farbe TEXT
);
CREATE TABLE IF NOT EXISTS personen (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
rolle TEXT,
email TEXT,
telefon TEXT
);
-- ============================================
-- HAUPTTABELLE
-- ============================================
CREATE TABLE IF NOT EXISTS antraege (
id INTEGER PRIMARY KEY,
titel TEXT NOT NULL,
kurzbeschreibung TEXT,
prioritaet_id INTEGER REFERENCES prioritaeten(id),
status_id INTEGER REFERENCES status_typen(id),
bereich_id INTEGER REFERENCES bereiche(id),
dossier TEXT,
antragstext TEXT,
omnifocus_projekt TEXT,
referenzen TEXT,
kontakte TEXT,
notizen TEXT,
allris_referenzen TEXT,
position_x REAL,
position_y REAL,
erstellt_am DATE DEFAULT (date('now')),
aktualisiert_am DATE DEFAULT (date('now'))
);
-- ============================================
-- VERKNÜPFUNGEN
-- ============================================
CREATE TABLE IF NOT EXISTS antrag_ausschuesse (
antrag_id INTEGER REFERENCES antraege(id) ON DELETE CASCADE,
ausschuss_id INTEGER REFERENCES ausschuesse(id) ON DELETE CASCADE,
ist_federfuehrend INTEGER DEFAULT 0,
PRIMARY KEY (antrag_id, ausschuss_id)
);
CREATE TABLE IF NOT EXISTS antrag_personen (
antrag_id INTEGER REFERENCES antraege(id) ON DELETE CASCADE,
person_id INTEGER REFERENCES personen(id) ON DELETE CASCADE,
rolle TEXT,
PRIMARY KEY (antrag_id, person_id)
);
CREATE TABLE IF NOT EXISTS verbindungen (
id INTEGER PRIMARY KEY,
von_antrag_id INTEGER REFERENCES antraege(id) ON DELETE CASCADE,
nach_antrag_id INTEGER REFERENCES antraege(id) ON DELETE CASCADE,
typ TEXT CHECK(typ IN ('manuell', 'thematisch', 'abhaengig', 'siehe_auch')),
gewicht INTEGER DEFAULT 1,
notiz TEXT,
erstellt_am DATE DEFAULT (date('now')),
UNIQUE(von_antrag_id, nach_antrag_id)
);
-- ============================================
-- KOMMUNIKATION
-- ============================================
CREATE TABLE IF NOT EXISTS kommunikation (
id INTEGER PRIMARY KEY,
antrag_id INTEGER REFERENCES antraege(id) ON DELETE CASCADE,
typ TEXT CHECK(typ IN ('Pressemitteilung', 'Instagram', 'Facebook', 'Twitter', 'Website')),
titel TEXT NOT NULL,
inhalt TEXT,
zielgruppe TEXT,
status TEXT CHECK(status IN ('Entwurf', 'Review', 'Freigegeben', 'Veröffentlicht')),
erstellt_am DATE DEFAULT (date('now')),
veroeffentlicht_am DATE
);
-- ============================================
-- VIEW-KONFIGURATIONEN
-- ============================================
CREATE TABLE IF NOT EXISTS view_configs (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
typ TEXT CHECK(typ IN ('graph', 'table', 'kanban', 'timeline')),
config TEXT,
ist_default INTEGER DEFAULT 0,
erstellt_am DATE DEFAULT (date('now'))
);
-- ============================================
-- INDIZES
-- ============================================
CREATE INDEX IF NOT EXISTS idx_antraege_prioritaet ON antraege(prioritaet_id);
CREATE INDEX IF NOT EXISTS idx_antraege_status ON antraege(status_id);
CREATE INDEX IF NOT EXISTS idx_antraege_bereich ON antraege(bereich_id);
CREATE INDEX IF NOT EXISTS idx_verbindungen_von ON verbindungen(von_antrag_id);
CREATE INDEX IF NOT EXISTS idx_verbindungen_nach ON verbindungen(nach_antrag_id);
CREATE INDEX IF NOT EXISTS idx_kommunikation_antrag ON kommunikation(antrag_id);
-- ============================================
-- STAMMDATEN EINFÜGEN
-- ============================================
INSERT OR IGNORE INTO prioritaeten (id, name, reihenfolge, farbe) VALUES
(1, 'Hoch', 1, '#E53935'),
(2, 'Mittel', 2, '#FB8C00'),
(3, 'Niedrig', 3, '#43A047'),
(4, 'Abgeschlossen', 4, '#78909C');
INSERT OR IGNORE INTO status_typen (id, name, reihenfolge, farbe) VALUES
(1, 'Ideenspeicher', 1, '#90CAF9'),
(2, 'Recherche', 2, '#FFF59D'),
(3, 'Entwurf', 3, '#FFCC80'),
(4, 'Eingereicht', 4, '#A5D6A7'),
(5, 'Beschlossen', 5, '#81C784'),
(6, 'Abgelehnt', 6, '#EF9A9A'),
(7, 'Abgeschlossen', 7, '#B0BEC5');
INSERT OR IGNORE INTO bereiche (id, name, kuerzel, farbe, icon) VALUES
(1, 'Umwelt', 'UMW', '#4CAF50', '🌿'),
(2, 'Infra', 'INF', '#2196F3', '🚗'),
(3, 'HFA', 'HFA', '#FFC107', '💰'),
(4, 'Soziales', 'SOZ', '#E91E63', '🤝'),
(5, 'Stadt', 'STD', '#9C27B0', '🏙️'),
(6, 'Wirtschaft', 'WIR', '#FF5722', '💼');
INSERT OR IGNORE INTO ausschuesse (id, name, kuerzel) VALUES
(1, 'Umweltausschuss', 'UKM'),
(2, 'Infrastrukturausschuss', 'IFA'),
(3, 'Haupt- und Finanzausschuss', 'HFA'),
(4, 'Stadtentwicklungsausschuss', 'STA'),
(5, 'Sozialausschuss', 'SOZ'),
(6, 'Wirtschaftsausschuss', 'WIA'),
(7, 'Sport- und Freizeitausschuss', 'SPO'),
(8, 'Schulausschuss', 'SCH'),
(9, 'Ausschuss für Bürgerbeteiligung', 'BÜR');
INSERT OR IGNORE INTO personen (id, name, rolle) VALUES
(1, 'Rüdiger Ludwig', 'Vorsitz Umweltausschuss'),
(2, 'Heike Heuer', 'Infrastruktur'),
(3, 'Jörg Fritzsche', 'HFA / Digitales'),
(4, 'Karin Köppen', 'Soziales'),
(5, 'Daniel Adam', 'Bürgerbeteiligung'),
(6, 'Nicole Pfefferer', 'Schule');

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
services:
backend:
build: ./backend
container_name: antragsideen-api
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
- DB_PATH=/app/data/antragsideen.db
- PORT=3000
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build: ./frontend
container_name: antragsideen-ui
restart: unless-stopped
ports:
- "8088:80"
depends_on:
- backend
networks:
default:
name: antragsideen-network

14
frontend/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
# Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Antragsideen Hagen</title>
</head>
<body class="bg-gray-100">
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

19
frontend/nginx.conf Normal file
View File

@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

25
frontend/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "antragsideen-ui",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"cytoscape": "^3.28.0",
"axios": "^1.6.0",
"@vueuse/core": "^10.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0"
}
}

View File

@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} }
}

47
frontend/src/App.vue Normal file
View File

@ -0,0 +1,47 @@
<template>
<div class="min-h-screen bg-gray-100">
<!-- Header -->
<header class="bg-gradient-to-r from-green-dark to-green text-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold flex items-center gap-2">
🌱 Antragsideen Hagen
</h1>
<nav class="flex gap-1">
<router-link
v-for="view in views" :key="view.path"
:to="view.path"
class="px-4 py-2 rounded-lg transition"
:class="$route.path === view.path ? 'bg-white/20' : 'hover:bg-white/10'"
>
{{ view.icon }} {{ view.name }}
</router-link>
</nav>
</div>
</div>
</header>
<!-- Main -->
<main class="max-w-full">
<router-view />
</main>
<!-- Modal -->
<AntragModal v-if="store.selectedId" @close="store.select(null)" />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useAntraegeStore } from './stores/antraege'
import AntragModal from './components/AntragModal.vue'
const store = useAntraegeStore()
const views = [
{ path: '/graph', name: 'Graph', icon: '🕸️' },
{ path: '/table', name: 'Tabelle', icon: '📋' },
{ path: '/kanban', name: 'Kanban', icon: '📊' }
]
onMounted(() => store.fetchAll())
</script>

View File

@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }

View File

@ -0,0 +1,168 @@
<template>
<div class="fixed inset-0 bg-black/50 z-50 flex items-start justify-center p-4 overflow-y-auto" @click.self="$emit('close')">
<div class="bg-white rounded-xl shadow-2xl max-w-3xl w-full my-8">
<!-- Header -->
<div class="flex items-start justify-between p-5 border-b">
<div>
<span class="text-sm font-mono text-green bg-green/10 px-2 py-1 rounded">#{{ String(antrag.id).padStart(2,'0') }}</span>
<h2 class="text-xl font-semibold mt-2">{{ antrag.titel }}</h2>
</div>
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600 text-2xl">&times;</button>
</div>
<!-- Tabs -->
<div class="flex border-b bg-gray-50">
<button
v-for="t in tabs" :key="t.id"
@click="tab = t.id"
class="px-6 py-3 font-medium transition border-b-2"
:class="tab === t.id ? 'border-green text-green bg-white' : 'border-transparent text-gray-500 hover:text-gray-700'"
>
{{ t.icon }} {{ t.name }}
</button>
</div>
<!-- Content -->
<div class="p-5">
<!-- Übersicht -->
<div v-if="tab === 'overview'" class="space-y-4">
<div>
<label class="text-xs uppercase text-gray-500 font-semibold">Kurzbeschreibung</label>
<p class="mt-1">{{ antrag.kurzbeschreibung || '—' }}</p>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="text-xs uppercase text-gray-500 font-semibold">Priorität</label>
<span class="block mt-1 px-3 py-1 rounded text-white text-sm inline-block" :style="{ background: antrag.prioritaet_farbe }">
{{ antrag.prioritaet }}
</span>
</div>
<div>
<label class="text-xs uppercase text-gray-500 font-semibold">Status</label>
<p class="mt-1">{{ antrag.status }}</p>
</div>
<div>
<label class="text-xs uppercase text-gray-500 font-semibold">Bereich</label>
<p class="mt-1">{{ antrag.bereich_icon }} {{ antrag.bereich }}</p>
</div>
</div>
<div>
<label class="text-xs uppercase text-gray-500 font-semibold">Ausschüsse</label>
<div class="flex flex-wrap gap-2 mt-1">
<span v-for="a in antrag.ausschuesse" :key="a.id" class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm">
{{ a.name }}
</span>
</div>
</div>
<div>
<label class="text-xs uppercase text-gray-500 font-semibold">Personen</label>
<div class="flex flex-wrap gap-2 mt-1">
<span v-for="p in antrag.personen" :key="p.id" class="bg-gray-100 px-2 py-1 rounded text-sm">
{{ p.name }}
</span>
</div>
</div>
</div>
<!-- Dossier -->
<div v-else-if="tab === 'dossier'" class="prose max-w-none">
<div v-if="antrag.dossier" v-html="renderMd(antrag.dossier)"></div>
<p v-else class="text-gray-400">Kein Dossier vorhanden.</p>
</div>
<!-- Antragstext -->
<div v-else-if="tab === 'antrag'" class="prose max-w-none">
<div v-if="antrag.antragstext" v-html="renderMd(antrag.antragstext)"></div>
<p v-else class="text-gray-400">Kein Antragstext vorhanden.</p>
</div>
<!-- Bearbeiten -->
<div v-else-if="tab === 'edit'" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Titel</label>
<input v-model="form.titel" class="w-full border rounded-lg p-2">
</div>
<div>
<label class="block text-sm font-medium mb-1">Kurzbeschreibung</label>
<textarea v-model="form.kurzbeschreibung" rows="3" class="w-full border rounded-lg p-2"></textarea>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Priorität</label>
<select v-model="form.prioritaet_id" class="w-full border rounded-lg p-2">
<option v-for="p in store.stammdaten?.prioritaeten" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Status</label>
<select v-model="form.status_id" class="w-full border rounded-lg p-2">
<option v-for="s in store.stammdaten?.status_typen" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Bereich</label>
<select v-model="form.bereich_id" class="w-full border rounded-lg p-2">
<option v-for="b in store.stammdaten?.bereiche" :key="b.id" :value="b.id">{{ b.icon }} {{ b.name }}</option>
</select>
</div>
</div>
<button @click="save" class="bg-green text-white px-6 py-2 rounded-lg hover:bg-green-dark">
💾 Speichern
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useAntraegeStore } from '../stores/antraege'
const emit = defineEmits(['close'])
const store = useAntraegeStore()
const antrag = computed(() => store.selected)
const tab = ref('overview')
const tabs = [
{ id: 'overview', name: 'Übersicht', icon: '📋' },
{ id: 'dossier', name: 'Dossier', icon: '📄' },
{ id: 'antrag', name: 'Antragstext', icon: '📝' },
{ id: 'edit', name: 'Bearbeiten', icon: '✏️' }
]
const form = ref({})
watch(antrag, (a) => {
if (a) form.value = { ...a }
}, { immediate: true })
async function save() {
await store.update(antrag.value.id, form.value)
tab.value = 'overview'
}
function renderMd(md) {
return md
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/^- (.*)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
}
</script>
<style>
.prose h1 { font-size: 1.5rem; font-weight: 600; margin: 1rem 0 0.5rem; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem; }
.prose h2 { font-size: 1.25rem; font-weight: 600; margin: 1rem 0 0.5rem; color: #1B5E20; }
.prose h3 { font-size: 1.1rem; font-weight: 600; margin: 0.75rem 0 0.25rem; }
.prose p { margin: 0.5rem 0; }
.prose ul { padding-left: 1.5rem; margin: 0.5rem 0; }
.prose li { margin: 0.25rem 0; }
</style>

7
frontend/src/main.js Normal file
View File

@ -0,0 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/main.css'
createApp(App).use(createPinia()).use(router).mount('#app')

View File

@ -0,0 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
import GraphView from '../views/GraphView.vue'
import TableView from '../views/TableView.vue'
import KanbanView from '../views/KanbanView.vue'
export default createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/graph' },
{ path: '/graph', component: GraphView },
{ path: '/table', component: TableView },
{ path: '/kanban', component: KanbanView }
]
})

View File

@ -0,0 +1,71 @@
import { defineStore } from 'pinia'
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
export const useAntraegeStore = defineStore('antraege', {
state: () => ({
antraege: [],
stammdaten: null,
graphData: null,
loading: false,
selectedId: null
}),
getters: {
selected: (state) => state.antraege.find(a => a.id === state.selectedId),
byPrioritaet: (state) => (prio) => state.antraege.filter(a => a.prioritaet === prio),
byStatus: (state) => (status) => state.antraege.filter(a => a.status === status)
},
actions: {
async fetchAll() {
this.loading = true
try {
const [antraegeRes, stammdatenRes] = await Promise.all([
api.get('/antraege'),
api.get('/stammdaten')
])
this.antraege = antraegeRes.data
this.stammdaten = stammdatenRes.data
} finally {
this.loading = false
}
},
async fetchGraph() {
const res = await api.get('/graph')
this.graphData = res.data
return res.data
},
async create(data) {
const res = await api.post('/antraege', data)
await this.fetchAll()
return res.data
},
async update(id, data) {
await api.put(`/antraege/${id}`, data)
await this.fetchAll()
},
async delete(id) {
await api.delete(`/antraege/${id}`)
await this.fetchAll()
},
async savePositions(positions) {
await api.put('/graph/positions', { positions })
},
async createVerbindung(von, nach, typ = 'manuell') {
await api.post('/verbindungen', { von_antrag_id: von, nach_antrag_id: nach, typ })
await this.fetchGraph()
},
select(id) {
this.selectedId = id
}
}
})

View File

@ -0,0 +1,157 @@
<template>
<div class="h-[calc(100vh-80px)] flex">
<!-- Sidebar -->
<aside class="w-64 bg-white border-r p-4 overflow-y-auto">
<h3 class="font-semibold mb-3">Verbindungen</h3>
<label class="flex items-center gap-2 mb-2">
<input type="checkbox" v-model="edgeConfig.ausschuss" @change="updateGraph" class="rounded">
Nach Ausschuss
</label>
<label class="flex items-center gap-2 mb-2">
<input type="checkbox" v-model="edgeConfig.person" @change="updateGraph" class="rounded">
Nach Person
</label>
<label class="flex items-center gap-2 mb-4">
<input type="checkbox" v-model="edgeConfig.manuell" @change="updateGraph" class="rounded">
Manuell
</label>
<h3 class="font-semibold mb-3 mt-6">Layout</h3>
<select v-model="layout" @change="applyLayout" class="w-full border rounded p-2">
<option value="cose">Force (Automatisch)</option>
<option value="grid">Grid</option>
<option value="circle">Kreis</option>
<option value="preset">Manuell</option>
</select>
<h3 class="font-semibold mb-3 mt-6">Farbe nach</h3>
<select v-model="colorBy" @change="updateStyles" class="w-full border rounded p-2">
<option value="bereich">Bereich</option>
<option value="prioritaet">Priorität</option>
</select>
<button @click="savePositions" class="w-full mt-6 bg-green text-white py-2 rounded hover:bg-green-dark">
💾 Positionen speichern
</button>
</aside>
<!-- Graph -->
<div ref="cyContainer" class="flex-1 bg-gray-50"></div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import cytoscape from 'cytoscape'
import { useAntraegeStore } from '../stores/antraege'
const store = useAntraegeStore()
const cyContainer = ref(null)
let cy = null
const edgeConfig = reactive({ ausschuss: true, person: false, manuell: true })
const layout = ref('cose')
const colorBy = ref('bereich')
const prioColors = { Hoch: '#E53935', Mittel: '#FB8C00', Niedrig: '#43A047', Abgeschlossen: '#78909C' }
const bereichColors = { Umwelt: '#4CAF50', Infra: '#2196F3', HFA: '#FFC107', Soziales: '#E91E63', Stadt: '#9C27B0', Wirtschaft: '#FF5722' }
async function initGraph() {
const data = await store.fetchGraph()
const nodes = data.nodes.map(n => ({
data: {
id: String(n.id),
label: `#${String(n.id).padStart(2,'0')} ${n.titel.substring(0,20)}...`,
titel: n.titel,
prioritaet: n.prioritaet,
bereich: n.bereich,
prioColor: prioColors[n.prioritaet] || '#999',
bereichColor: bereichColors[n.bereich] || '#999',
hatDossier: n.hat_dossier
},
position: n.position_x ? { x: n.position_x, y: n.position_y } : undefined
}))
cy = cytoscape({
container: cyContainer.value,
elements: { nodes },
style: getStyles(),
layout: { name: layout.value }
})
cy.on('tap', 'node', (e) => store.select(Number(e.target.id())))
cy.on('dragfree', 'node', () => layout.value = 'preset')
updateGraph()
}
function getStyles() {
const colorField = colorBy.value === 'bereich' ? 'bereichColor' : 'prioColor'
return [
{ selector: 'node', style: {
'background-color': `data(${colorField})`,
'label': 'data(label)',
'font-size': '10px',
'text-valign': 'bottom',
'text-margin-y': '5px',
'width': 'mapData(hatDossier, 0, 1, 30, 50)',
'height': 'mapData(hatDossier, 0, 1, 30, 50)'
}},
{ selector: 'edge', style: {
'width': 1,
'line-color': '#ccc',
'curve-style': 'bezier',
'opacity': 0.6
}},
{ selector: 'edge[typ="ausschuss"]', style: { 'line-color': '#90CAF9' }},
{ selector: 'edge[typ="person"]', style: { 'line-color': '#FFE082' }},
{ selector: 'edge[typ="manuell"]', style: { 'line-color': '#E53935', 'width': 2 }}
]
}
function updateGraph() {
if (!cy || !store.graphData) return
cy.edges().remove()
const edges = []
if (edgeConfig.ausschuss) {
store.graphData.edges.ausschuss.forEach(e =>
edges.push({ data: { source: String(e.source), target: String(e.target), typ: 'ausschuss' }}))
}
if (edgeConfig.person) {
store.graphData.edges.person.forEach(e =>
edges.push({ data: { source: String(e.source), target: String(e.target), typ: 'person' }}))
}
if (edgeConfig.manuell) {
store.graphData.edges.manuell.forEach(e =>
edges.push({ data: { source: String(e.source), target: String(e.target), typ: 'manuell' }}))
}
cy.add(edges)
}
function updateStyles() {
if (!cy) return
cy.style(getStyles()).update()
}
function applyLayout() {
if (!cy) return
if (layout.value === 'preset') return
cy.layout({ name: layout.value, animate: true }).run()
}
function savePositions() {
const positions = {}
cy.nodes().forEach(n => {
const pos = n.position()
positions[n.id()] = { x: pos.x, y: pos.y }
})
store.savePositions(positions)
alert('Positionen gespeichert!')
}
onMounted(initGraph)
</script>

View File

@ -0,0 +1,35 @@
<template>
<div class="p-6 overflow-x-auto">
<div class="flex gap-4 min-w-max">
<div
v-for="status in store.stammdaten?.status_typen" :key="status.id"
class="w-72 bg-gray-100 rounded-lg p-3"
>
<h3 class="font-semibold mb-3 flex items-center gap-2">
<span class="w-3 h-3 rounded-full" :style="{ background: status.farbe }"></span>
{{ status.name }}
<span class="text-gray-400 text-sm">({{ getByStatus(status.name).length }})</span>
</h3>
<div class="space-y-2">
<div
v-for="a in getByStatus(status.name)" :key="a.id"
@click="store.select(a.id)"
class="bg-white p-3 rounded shadow cursor-pointer hover:shadow-md transition"
:style="{ borderLeft: '4px solid ' + a.prioritaet_farbe }"
>
<div class="text-xs text-gray-400 mb-1">#{{ String(a.id).padStart(2,'0') }}</div>
<div class="font-medium text-sm">{{ a.titel }}</div>
<div class="text-xs text-gray-500 mt-1">{{ a.bereich_icon }} {{ a.bereich }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useAntraegeStore } from '../stores/antraege'
const store = useAntraegeStore()
const getByStatus = (status) => store.antraege.filter(a => a.status === status)
</script>

View File

@ -0,0 +1,44 @@
<template>
<div class="p-6">
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3 text-left text-sm font-semibold">#</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Titel</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Priorität</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Bereich</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Status</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Dossier</th>
</tr>
</thead>
<tbody class="divide-y">
<tr
v-for="a in store.antraege" :key="a.id"
@click="store.select(a.id)"
class="hover:bg-gray-50 cursor-pointer"
>
<td class="px-4 py-3 text-sm font-mono">{{ String(a.id).padStart(2,'0') }}</td>
<td class="px-4 py-3 font-medium">{{ a.titel }}</td>
<td class="px-4 py-3">
<span class="px-2 py-1 rounded text-xs text-white" :style="{ background: a.prioritaet_farbe }">
{{ a.prioritaet }}
</span>
</td>
<td class="px-4 py-3">{{ a.bereich_icon }} {{ a.bereich }}</td>
<td class="px-4 py-3 text-sm">{{ a.status }}</td>
<td class="px-4 py-3">
<span v-if="a.dossier?.length > 100" class="text-green"></span>
<span v-else class="text-gray-300"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { useAntraegeStore } from '../stores/antraege'
const store = useAntraegeStore()
</script>

View File

@ -0,0 +1,10 @@
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {
colors: {
green: { DEFAULT: '#2E7D32', dark: '#1B5E20', light: '#4CAF50' }
}
}
}
}

11
frontend/vite.config.js Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:3000'
}
}
})