feat: Neue Antragsidee anlegen (Issue #1)
- Formular mit 4 Tabs: Basisdaten, Projektbeschreibung, Öffentlichkeitsarbeit, Zuständigkeiten - Markdown-Preview (marked) für Dossier und Antragstext mit Toggle - Fuzzy-Duplikatprüfung beim Tippen des Titels (GET /api/antraege/suche) - POST /api/antraege erweitert um dossier, antragstext, notizen, allris_referenzen, referenzen - Multi-Select für Ausschüsse und Personen - Navigation: "+ Neue Idee" Button im Header - Route /neu mit NeuAntragsidee.vue Komponente Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
34d5671997
commit
06f6568daa
@ -75,6 +75,36 @@ app.get('/api/antraege', (req, res) => {
|
||||
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
|
||||
app.get('/api/antraege/:id', (req, res) => {
|
||||
const antrag = db.prepare(`
|
||||
@ -109,25 +139,30 @@ app.get('/api/antraege/:id', (req, res) => {
|
||||
|
||||
// Neuer Antrag
|
||||
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(`
|
||||
INSERT INTO antraege (titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(titel, kurzbeschreibung, prioritaet_id || 2, status_id || 1, bereich_id || 1);
|
||||
|
||||
INSERT INTO antraege (titel, kurzbeschreibung, prioritaet_id, status_id, bereich_id,
|
||||
dossier, antragstext, notizen, allris_referenzen, referenzen)
|
||||
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;
|
||||
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
2702
frontend/package-lock.json
generated
Normal file
2702
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -8,18 +8,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0",
|
||||
"pinia": "^2.1.0",
|
||||
"cytoscape": "^3.28.0",
|
||||
"@vueuse/core": "^10.7.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": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0"
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||
🌱 Antragsideen Hagen
|
||||
</h1>
|
||||
<nav class="flex gap-1">
|
||||
<router-link
|
||||
<nav class="flex gap-1 items-center">
|
||||
<router-link
|
||||
v-for="view in views" :key="view.path"
|
||||
:to="view.path"
|
||||
class="px-4 py-2 rounded-lg transition"
|
||||
@ -16,6 +16,13 @@
|
||||
>
|
||||
{{ view.icon }} {{ view.name }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import GraphView from '../views/GraphView.vue'
|
||||
import TableView from '../views/TableView.vue'
|
||||
import KanbanView from '../views/KanbanView.vue'
|
||||
import NeuAntragsidee from '../views/NeuAntragsidee.vue'
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
@ -9,6 +10,7 @@ export default createRouter({
|
||||
{ path: '/', redirect: '/graph' },
|
||||
{ path: '/graph', component: GraphView },
|
||||
{ path: '/table', component: TableView },
|
||||
{ path: '/kanban', component: KanbanView }
|
||||
{ path: '/kanban', component: KanbanView },
|
||||
{ path: '/neu', component: NeuAntragsidee }
|
||||
]
|
||||
})
|
||||
|
||||
288
frontend/src/views/NeuAntragsidee.vue
Normal file
288
frontend/src/views/NeuAntragsidee.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user