Initial commit: Antragsideen-App Skeleton
- Backend (FastAPI) - Frontend - Docker Compose - README
This commit is contained in:
commit
34d5671997
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
*.db
|
||||
node_modules/
|
||||
.env
|
||||
.DS_Store
|
||||
42
README.md
Normal file
42
README.md
Normal 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
13
backend/Dockerfile
Normal 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
14
backend/package.json
Normal 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
282
backend/server.js
Normal 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
116
data/migrate.js
Normal 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
192
data/schema.sql
Normal 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
32
docker-compose.yml
Normal 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
14
frontend/Dockerfile
Normal 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
12
frontend/index.html
Normal 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
19
frontend/nginx.conf
Normal 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
25
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
frontend/postcss.config.js
Normal file
3
frontend/postcss.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} }
|
||||
}
|
||||
47
frontend/src/App.vue
Normal file
47
frontend/src/App.vue
Normal 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>
|
||||
5
frontend/src/assets/main.css
Normal file
5
frontend/src/assets/main.css
Normal file
@ -0,0 +1,5 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||
168
frontend/src/components/AntragModal.vue
Normal file
168
frontend/src/components/AntragModal.vue
Normal 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">×</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
7
frontend/src/main.js
Normal 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')
|
||||
14
frontend/src/router/index.js
Normal file
14
frontend/src/router/index.js
Normal 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 }
|
||||
]
|
||||
})
|
||||
71
frontend/src/stores/antraege.js
Normal file
71
frontend/src/stores/antraege.js
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
157
frontend/src/views/GraphView.vue
Normal file
157
frontend/src/views/GraphView.vue
Normal 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>
|
||||
35
frontend/src/views/KanbanView.vue
Normal file
35
frontend/src/views/KanbanView.vue
Normal 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>
|
||||
44
frontend/src/views/TableView.vue
Normal file
44
frontend/src/views/TableView.vue
Normal 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>
|
||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal 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
11
frontend/vite.config.js
Normal 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user