Merge feature/1-anlegen: Neue Antragsideen anlegen

This commit is contained in:
Dotty Dotter 2026-03-31 16:42:05 +02:00
commit 26c0cb82ed
6 changed files with 3054 additions and 19 deletions

View File

@ -75,6 +75,36 @@ app.get('/api/antraege', (req, res) => {
res.json(antraege); res.json(antraege);
}); });
// Fuzzy-Suche für Duplikat-Prüfung (muss vor :id stehen)
app.get('/api/antraege/suche', (req, res) => {
const q = (req.query.q || '').trim().toLowerCase();
if (q.length < 2) return res.json([]);
const words = q.split(/\s+/).filter(w => w.length >= 2);
if (!words.length) return res.json([]);
const antraege = db.prepare(`
SELECT a.id, a.titel, a.kurzbeschreibung,
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
`).all();
const results = antraege
.map(a => {
const text = (a.titel + ' ' + (a.kurzbeschreibung || '')).toLowerCase();
const score = words.reduce((s, w) => s + (text.includes(w) ? 1 : 0), 0);
return { ...a, score };
})
.filter(a => a.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 5);
res.json(results);
});
// Einzelner Antrag // Einzelner Antrag
app.get('/api/antraege/:id', (req, res) => { app.get('/api/antraege/:id', (req, res) => {
const antrag = db.prepare(` const antrag = db.prepare(`
@ -109,12 +139,17 @@ app.get('/api/antraege/:id', (req, res) => {
// Neuer Antrag // Neuer Antrag
app.post('/api/antraege', (req, res) => { app.post('/api/antraege', (req, res) => {
const { titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id, ausschuesse, personen } = req.body; const { titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id,
dossier, antragstext, notizen, allris_referenzen, referenzen,
ausschuesse, personen } = req.body;
const result = db.prepare(` const result = db.prepare(`
INSERT INTO antraege (titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id) INSERT INTO antraege (titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id,
VALUES (?, ?, ?, ?, ?) dossier, antragstext, notizen, allris_referenzen, referenzen)
`).run(titel, kurzbeschreibung, prioritaet_id || 2, status_id || 1, bereich_id || 1); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(titel, kurzbeschreibung, prioritaet_id || 2, status_id || 1, bereich_id || 1,
dossier || null, antragstext || null, notizen || null,
allris_referenzen || null, referenzen || null);
const antragId = result.lastInsertRowid; const antragId = result.lastInsertRowid;

2702
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,18 +8,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.0", "@vueuse/core": "^10.7.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"cytoscape": "^3.28.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"@vueuse/core": "^10.7.0" "cytoscape": "^3.28.0",
"marked": "^17.0.5",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.0", "@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"postcss": "^8.4.0", "postcss": "^8.4.0",
"tailwindcss": "^3.4.0" "tailwindcss": "^3.4.0",
"vite": "^5.0.0"
} }
} }

View File

@ -7,7 +7,7 @@
<h1 class="text-2xl font-bold flex items-center gap-2"> <h1 class="text-2xl font-bold flex items-center gap-2">
🌱 Antragsideen Hagen 🌱 Antragsideen Hagen
</h1> </h1>
<nav class="flex gap-1"> <nav class="flex gap-1 items-center">
<router-link <router-link
v-for="view in views" :key="view.path" v-for="view in views" :key="view.path"
:to="view.path" :to="view.path"
@ -16,6 +16,13 @@
> >
{{ view.icon }} {{ view.name }} {{ view.icon }} {{ view.name }}
</router-link> </router-link>
<router-link
to="/neu"
class="ml-2 px-4 py-2 rounded-lg font-medium transition"
:class="$route.path === '/neu' ? 'bg-white text-[#0a4a23]' : 'bg-white/20 hover:bg-white/30'"
>
+ Neue Idee
</router-link>
</nav> </nav>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import GraphView from '../views/GraphView.vue' import GraphView from '../views/GraphView.vue'
import TableView from '../views/TableView.vue' import TableView from '../views/TableView.vue'
import KanbanView from '../views/KanbanView.vue' import KanbanView from '../views/KanbanView.vue'
import NeuAntragsidee from '../views/NeuAntragsidee.vue'
export default createRouter({ export default createRouter({
history: createWebHistory(), history: createWebHistory(),
@ -9,6 +10,7 @@ export default createRouter({
{ path: '/', redirect: '/graph' }, { path: '/', redirect: '/graph' },
{ path: '/graph', component: GraphView }, { path: '/graph', component: GraphView },
{ path: '/table', component: TableView }, { path: '/table', component: TableView },
{ path: '/kanban', component: KanbanView } { path: '/kanban', component: KanbanView },
{ path: '/neu', component: NeuAntragsidee }
] ]
}) })

View File

@ -0,0 +1,288 @@
<template>
<div class="max-w-4xl mx-auto p-6">
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-[#0a4a23] to-[#4CAF50] px-6 py-4">
<h2 class="text-xl font-bold text-white">Neue Antragsidee</h2>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 bg-gray-50">
<nav class="flex">
<button
v-for="tab in tabs" :key="tab.id"
@click="activeTab = tab.id"
class="px-5 py-3 text-sm font-medium border-b-2 transition"
:class="activeTab === tab.id
? 'border-[#0a4a23] text-[#0a4a23] bg-white'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
>
{{ tab.icon }} {{ tab.label }}
</button>
</nav>
</div>
<!-- Duplikat-Warnung -->
<div v-if="duplicates.length" class="mx-6 mt-4 p-3 bg-yellow-50 border border-yellow-300 rounded-lg">
<p class="text-sm font-medium text-yellow-800">Mögliche Duplikate gefunden:</p>
<ul class="mt-1 space-y-1">
<li v-for="d in duplicates" :key="d.id" class="text-sm text-yellow-700 flex items-center gap-2">
<span class="text-yellow-500"></span>
<span><strong>#{{ d.id }}</strong> {{ d.titel }} <span class="text-xs text-gray-500">({{ d.bereich }} / {{ d.status }})</span></span>
</li>
</ul>
</div>
<form @submit.prevent="submit" class="p-6">
<!-- Tab: Basisdaten -->
<div v-show="activeTab === 'basis'">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input
v-model="form.titel"
@input="checkDuplicates"
type="text"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none"
placeholder="Titel der Antragsidee"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Kurzbeschreibung</label>
<textarea
v-model="form.kurzbeschreibung"
rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none"
placeholder="Kurze Beschreibung der Idee"
/>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bereich</label>
<select v-model="form.bereich_id" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none">
<option v-for="b in stammdaten?.bereiche" :key="b.id" :value="b.id">{{ b.icon }} {{ b.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priorität</label>
<select v-model="form.prioritaet_id" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none">
<option v-for="p in stammdaten?.prioritaeten" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select v-model="form.status_id" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none">
<option v-for="s in stammdaten?.status_typen" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Tab: Projektbeschreibung -->
<div v-show="activeTab === 'projekt'">
<div class="space-y-4">
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-gray-700">Dossier (Markdown)</label>
<button type="button" @click="previewDossier = !previewDossier"
class="text-xs px-2 py-1 rounded border transition"
:class="previewDossier ? 'bg-[#0a4a23] text-white border-[#0a4a23]' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'"
>
{{ previewDossier ? 'Bearbeiten' : 'Vorschau' }}
</button>
</div>
<textarea
v-if="!previewDossier"
v-model="form.dossier"
rows="10"
class="w-full border border-gray-300 rounded-lg px-3 py-2 font-mono text-sm focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none"
placeholder="Projektbeschreibung in Markdown..."
/>
<div v-else class="border border-gray-300 rounded-lg px-4 py-3 prose prose-sm max-w-none min-h-[200px] bg-gray-50" v-html="renderedDossier" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Quellen / Referenzen</label>
<textarea
v-model="form.referenzen"
rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none"
placeholder="Links, Studien, Quellen..."
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">ALLRIS-Referenzen</label>
<textarea
v-model="form.allris_referenzen"
rows="2"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none"
placeholder="Drucksachen-Nr., Vorlagen..."
/>
</div>
</div>
</div>
<!-- Tab: Öffentlichkeitsarbeit -->
<div v-show="activeTab === 'oeffentlichkeit'">
<div class="space-y-4">
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-gray-700">Antragstext (Markdown)</label>
<button type="button" @click="previewAntragstext = !previewAntragstext"
class="text-xs px-2 py-1 rounded border transition"
:class="previewAntragstext ? 'bg-[#0a4a23] text-white border-[#0a4a23]' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'"
>
{{ previewAntragstext ? 'Bearbeiten' : 'Vorschau' }}
</button>
</div>
<textarea
v-if="!previewAntragstext"
v-model="form.antragstext"
rows="12"
class="w-full border border-gray-300 rounded-lg px-3 py-2 font-mono text-sm focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none"
placeholder="Antragstext in Markdown..."
/>
<div v-else class="border border-gray-300 rounded-lg px-4 py-3 prose prose-sm max-w-none min-h-[200px] bg-gray-50" v-html="renderedAntragstext" />
</div>
</div>
</div>
<!-- Tab: Zuständigkeiten -->
<div v-show="activeTab === 'zustaendig'">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Ausschüsse</label>
<div class="grid grid-cols-2 gap-2">
<label
v-for="a in stammdaten?.ausschuesse" :key="a.id"
class="flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition"
:class="form.ausschuesse.includes(a.id) ? 'bg-green-50 border-[#4CAF50]' : 'border-gray-200 hover:bg-gray-50'"
>
<input type="checkbox" :value="a.id" v-model="form.ausschuesse" class="accent-[#0a4a23]" />
<span class="text-sm">{{ a.name }} <span class="text-xs text-gray-400">({{ a.kuerzel }})</span></span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Personen</label>
<div class="grid grid-cols-2 gap-2">
<label
v-for="p in stammdaten?.personen" :key="p.id"
class="flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition"
:class="form.personen.includes(p.id) ? 'bg-green-50 border-[#4CAF50]' : 'border-gray-200 hover:bg-gray-50'"
>
<input type="checkbox" :value="p.id" v-model="form.personen" class="accent-[#0a4a23]" />
<span class="text-sm">{{ p.name }} <span class="text-xs text-gray-400">({{ p.rolle }})</span></span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
<textarea
v-model="form.notizen"
rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#4CAF50] focus:border-[#4CAF50] outline-none"
placeholder="Interne Notizen..."
/>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
<router-link to="/table" class="text-sm text-gray-500 hover:text-gray-700 transition">Abbrechen</router-link>
<button
type="submit"
:disabled="!form.titel.trim() || saving"
class="px-6 py-2 bg-[#0a4a23] text-white rounded-lg font-medium hover:bg-[#0d5c2d] disabled:opacity-50 disabled:cursor-not-allowed transition"
>
{{ saving ? 'Speichern...' : 'Antragsidee speichern' }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAntraegeStore } from '../stores/antraege'
import { marked } from 'marked'
import axios from 'axios'
const router = useRouter()
const store = useAntraegeStore()
const stammdaten = computed(() => store.stammdaten)
const tabs = [
{ id: 'basis', label: 'Basisdaten', icon: '📝' },
{ id: 'projekt', label: 'Projektbeschreibung', icon: '📄' },
{ id: 'oeffentlichkeit', label: 'Öffentlichkeitsarbeit', icon: '📢' },
{ id: 'zustaendig', label: 'Zuständigkeiten', icon: '👥' }
]
const activeTab = ref('basis')
const previewDossier = ref(false)
const previewAntragstext = ref(false)
const saving = ref(false)
const duplicates = ref([])
let searchTimeout = null
const form = reactive({
titel: '',
kurzbeschreibung: '',
bereich_id: 1,
prioritaet_id: 2,
status_id: 1,
dossier: '',
antragstext: '',
referenzen: '',
allris_referenzen: '',
notizen: '',
ausschuesse: [],
personen: []
})
marked.setOptions({ breaks: true, gfm: true })
const renderedDossier = computed(() => {
return form.dossier ? marked.parse(form.dossier) : '<p class="text-gray-400">Keine Vorschau verfügbar</p>'
})
const renderedAntragstext = computed(() => {
return form.antragstext ? marked.parse(form.antragstext) : '<p class="text-gray-400">Keine Vorschau verfügbar</p>'
})
function checkDuplicates() {
clearTimeout(searchTimeout)
const q = form.titel.trim()
if (q.length < 3) {
duplicates.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const res = await axios.get('/api/antraege/suche', { params: { q } })
duplicates.value = res.data
} catch {
duplicates.value = []
}
}, 300)
}
async function submit() {
if (!form.titel.trim()) return
saving.value = true
try {
const result = await store.create({ ...form })
router.push('/table')
store.select(result.id)
} catch (e) {
alert('Fehler beim Speichern: ' + (e.response?.data?.error || e.message))
} finally {
saving.value = false
}
}
</script>