commit 34d5671997695d6077b3afb58d444b0e48f6593d Author: Dotty Dotter Date: Mon Mar 30 23:49:13 2026 +0200 Initial commit: Antragsideen-App Skeleton - Backend (FastAPI) - Frontend - Docker Compose - README diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68be433 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.venv/ +*.db +node_modules/ +.env +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e393847 --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..88b7a97 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..845758f --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..fca8697 --- /dev/null +++ b/backend/server.js @@ -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}`); +}); diff --git a/data/migrate.js b/data/migrate.js new file mode 100644 index 0000000..bae0933 --- /dev/null +++ b/data/migrate.js @@ -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(); diff --git a/data/schema.sql b/data/schema.sql new file mode 100644 index 0000000..57fe422 --- /dev/null +++ b/data/schema.sql @@ -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'); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a84b7f1 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f8ef184 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..69346af --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Antragsideen Hagen + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..f7adb52 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b2a89ea --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2380945 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f370db7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css new file mode 100644 index 0000000..d3780a7 --- /dev/null +++ b/frontend/src/assets/main.css @@ -0,0 +1,5 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } diff --git a/frontend/src/components/AntragModal.vue b/frontend/src/components/AntragModal.vue new file mode 100644 index 0000000..80824a3 --- /dev/null +++ b/frontend/src/components/AntragModal.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..7b37d21 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..f907f71 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 } + ] +}) diff --git a/frontend/src/stores/antraege.js b/frontend/src/stores/antraege.js new file mode 100644 index 0000000..86df6f4 --- /dev/null +++ b/frontend/src/stores/antraege.js @@ -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 + } + } +}) diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue new file mode 100644 index 0000000..e629b55 --- /dev/null +++ b/frontend/src/views/GraphView.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/src/views/KanbanView.vue b/frontend/src/views/KanbanView.vue new file mode 100644 index 0000000..6ce14f0 --- /dev/null +++ b/frontend/src/views/KanbanView.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/views/TableView.vue b/frontend/src/views/TableView.vue new file mode 100644 index 0000000..b42fe89 --- /dev/null +++ b/frontend/src/views/TableView.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..38a0b8b --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,10 @@ +export default { + content: ['./index.html', './src/**/*.{vue,js}'], + theme: { + extend: { + colors: { + green: { DEFAULT: '#2E7D32', dark: '#1B5E20', light: '#4CAF50' } + } + } + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..baf1c5c --- /dev/null +++ b/frontend/vite.config.js @@ -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' + } + } +})