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