Rebuild as InnungsApp project: replace stadtwerke analysis with full documentation
- PRD: vollständige Produktspezifikation (5 Module, Scope, Akzeptanzkriterien) - ARCHITECTURE: Tech Stack, Ordnerstruktur, Multi-Tenancy, Push, Kosten - DATABASE_SCHEMA: Vollständiges SQL-Schema mit RLS Policies und Views - USER_STORIES: 40+ Stories nach Rolle (Admin, Mitglied, Azubi, Obermeister) - PERSONAS: 5 detaillierte Nutzerprofile mit Alltag, Zitaten und Erwartungen - BUSINESS_MODEL: Preistabellen, Unit Economics, Revenue-Projektionen, Distribution - ROADMAP: 6 Phasen, Sprint-Planung, Meilensteine und KPIs - COMPETITIVE_ANALYSIS: Wettbewerbsmatrix, USPs, Preispositionierung - API_DESIGN: Supabase Query Patterns, Edge Functions, Realtime Subscriptions - ONBOARDING_FLOWS: 7 User Flows von Setup bis Fehlerfall - GTM_STRATEGY: 3-Phasen-Vertrieb, Outreach-Sequenz, Einwandbehandlung - AZUBI_MODULE: Video-Feed, 1-Click-Apply, Chat, Berichtsheft, Quiz - DSGVO_KONZEPT: Rechtsgrundlagen, TOMs, AVV, Minderjährige, Incident Response - FEATURES_BACKLOG: 72 Features nach MoSCoW + Technische Schulden Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
115
innungsapp/apps/admin/app/dashboard/einstellungen/page.tsx
Normal file
115
innungsapp/apps/admin/app/dashboard/einstellungen/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function EinstellungenPage() {
|
||||
const { data: org, isLoading } = trpc.organizations.me.useQuery()
|
||||
const updateMutation = trpc.organizations.update.useMutation()
|
||||
const avvMutation = trpc.organizations.acceptAvv.useMutation()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
|
||||
if (isLoading) return <div className="text-gray-500">Wird geladen...</div>
|
||||
if (!org) return null
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Einstellungen</h1>
|
||||
|
||||
{/* Org Settings */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Innung</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
|
||||
<input
|
||||
defaultValue={org.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
defaultValue={org.contactEmail ?? ''}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ name: name || undefined, contactEmail: contactEmail || undefined })}
|
||||
disabled={updateMutation.isPending}
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
{updateMutation.isSuccess && (
|
||||
<p className="text-sm text-green-600">Einstellungen gespeichert ✓</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AVV */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Auftragsverarbeitungsvertrag (AVV)</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Der AVV regelt die Verarbeitung personenbezogener Daten im Auftrag Ihrer Innung
|
||||
durch InnungsApp GmbH gemäß Art. 28 DSGVO.
|
||||
</p>
|
||||
<a
|
||||
href="/avv.pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 text-sm text-brand-600 hover:underline"
|
||||
>
|
||||
📄 AVV als PDF herunterladen
|
||||
</a>
|
||||
{org.avvAccepted ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-700 font-medium">
|
||||
✓ AVV akzeptiert am {org.avvAcceptedAt?.toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm text-yellow-800 font-medium">
|
||||
⚠️ Der AVV muss vor dem Go-Live akzeptiert werden.
|
||||
</p>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="avv-check"
|
||||
className="mt-0.5 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Ich bestätige, dass ich den AVV gelesen habe und im Namen der Innung akzeptiere.
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
const cb = document.getElementById('avv-check') as HTMLInputElement
|
||||
if (!cb.checked) { alert('Bitte bestätigen Sie den AVV.'); return }
|
||||
avvMutation.mutate()
|
||||
}}
|
||||
disabled={avvMutation.isPending}
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
AVV verbindlich akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan Info */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-gray-900 mb-2">Plan</h2>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-brand-100 text-brand-700 capitalize">
|
||||
{org.plan}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Für Upgrades oder Fragen zum Plan: kontakt@innungsapp.de
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
innungsapp/apps/admin/app/dashboard/layout.tsx
Normal file
18
innungsapp/apps/admin/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
187
innungsapp/apps/admin/app/dashboard/mitglieder/neu/page.tsx
Normal file
187
innungsapp/apps/admin/app/dashboard/mitglieder/neu/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import Link from 'next/link'
|
||||
import { SPARTEN } from '@innungsapp/shared'
|
||||
|
||||
export default function MitgliedNeuPage() {
|
||||
const router = useRouter()
|
||||
const [sendInvite, setSendInvite] = useState(true)
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
betrieb: '',
|
||||
sparte: 'Elektrotechnik',
|
||||
ort: '',
|
||||
telefon: '',
|
||||
email: '',
|
||||
status: 'aktiv' as const,
|
||||
istAusbildungsbetrieb: false,
|
||||
seit: new Date().getFullYear(),
|
||||
})
|
||||
|
||||
const createMutation = trpc.members.create.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/mitglieder'),
|
||||
})
|
||||
const inviteMutation = trpc.members.invite.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/mitglieder'),
|
||||
})
|
||||
|
||||
const isPending = createMutation.isPending || inviteMutation.isPending
|
||||
const error = createMutation.error ?? inviteMutation.error
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (sendInvite) {
|
||||
inviteMutation.mutate(form)
|
||||
} else {
|
||||
createMutation.mutate(form)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/dashboard/mitglieder" className="text-gray-400 hover:text-gray-600">
|
||||
← Zurück
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mitglied anlegen</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb *</label>
|
||||
<input
|
||||
required
|
||||
value={form.betrieb}
|
||||
onChange={(e) => setForm({ ...form, betrieb: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
|
||||
<select
|
||||
value={form.sparte}
|
||||
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort *</label>
|
||||
<input
|
||||
required
|
||||
value={form.ort}
|
||||
onChange={(e) => setForm({ ...form, ort: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={form.telefon}
|
||||
onChange={(e) => setForm({ ...form, telefon: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.seit}
|
||||
onChange={(e) => setForm({ ...form, seit: Number(e.target.value) })}
|
||||
min="1900"
|
||||
max="2100"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="ruhend">Ruhend</option>
|
||||
<option value="ausgetreten">Ausgetreten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.istAusbildungsbetrieb}
|
||||
onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })}
|
||||
className="rounded border-gray-300 text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendInvite}
|
||||
onChange={(e) => setSendInvite(e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 font-medium">
|
||||
Einladungs-E-Mail senden
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
Das Mitglied erhält eine E-Mail mit einem Login-Link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{isPending ? 'Wird gespeichert...' : sendInvite ? 'Speichern & Einladung senden' : 'Speichern'}
|
||||
</button>
|
||||
<Link
|
||||
href="/dashboard/mitglieder"
|
||||
className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
innungsapp/apps/admin/app/dashboard/mitglieder/page.tsx
Normal file
150
innungsapp/apps/admin/app/dashboard/mitglieder/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
aktiv: 'bg-green-100 text-green-700',
|
||||
ruhend: 'bg-yellow-100 text-yellow-700',
|
||||
ausgetreten: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
export default async function MitgliederPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { q?: string; status?: string }
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
})
|
||||
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
|
||||
|
||||
const search = searchParams.q ?? ''
|
||||
const statusFilter = searchParams.status
|
||||
|
||||
const members = await prisma.member.findMany({
|
||||
where: {
|
||||
orgId: userRole.orgId,
|
||||
...(statusFilter && { status: statusFilter as never }),
|
||||
...(search && {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ betrieb: { contains: search, mode: 'insensitive' } },
|
||||
{ ort: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
|
||||
<p className="text-gray-500 mt-1">{members.length} Einträge</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/mitglieder/neu"
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
+ Mitglied anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-4 flex gap-4">
|
||||
<form className="flex gap-4 w-full">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={search}
|
||||
placeholder="Name, Betrieb, Ort suchen..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={statusFilter ?? ''}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="ruhend">Ruhend</option>
|
||||
<option value="ausgetreten">Ausgetreten</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Betrieb</th>
|
||||
<th>Sparte</th>
|
||||
<th>Ort</th>
|
||||
<th>Mitglied seit</th>
|
||||
<th>Status</th>
|
||||
<th>Eingeladen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{m.name}</p>
|
||||
<p className="text-xs text-gray-500">{m.betrieb}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td>{m.sparte}</td>
|
||||
<td>{m.ort}</td>
|
||||
<td>{m.seit ?? '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[m.status]}`}
|
||||
>
|
||||
{MEMBER_STATUS_LABELS[m.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{m.userId ? (
|
||||
<span className="text-xs text-green-600">✓ Aktiv</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Nicht eingeladen</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/dashboard/mitglieder/${m.id}`}
|
||||
className="text-sm text-brand-600 hover:underline"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{members.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Mitglieder gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
innungsapp/apps/admin/app/dashboard/news/neu/page.tsx
Normal file
158
innungsapp/apps/admin/app/dashboard/news/neu/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })
|
||||
|
||||
const KATEGORIEN = [
|
||||
{ value: 'Wichtig', label: 'Wichtig' },
|
||||
{ value: 'Pruefung', label: 'Prüfung' },
|
||||
{ value: 'Foerderung', label: 'Förderung' },
|
||||
{ value: 'Veranstaltung', label: 'Veranstaltung' },
|
||||
{ value: 'Allgemein', label: 'Allgemein' },
|
||||
]
|
||||
|
||||
export default function NewsNeuPage() {
|
||||
const router = useRouter()
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('## Inhalt\n\nHier können Sie Ihren Beitrag verfassen.')
|
||||
const [kategorie, setKategorie] = useState('Allgemein')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{ name: string; storagePath: string; sizeBytes: number; url: string }>
|
||||
>([])
|
||||
|
||||
const createMutation = trpc.news.create.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/news'),
|
||||
})
|
||||
|
||||
function handleSubmit(publishNow: boolean) {
|
||||
if (!title.trim() || !body.trim()) return
|
||||
createMutation.mutate({
|
||||
title,
|
||||
body,
|
||||
kategorie: kategorie as never,
|
||||
publishedAt: publishNow ? new Date().toISOString() : null,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
const data = await res.json()
|
||||
setAttachments((prev) => [...prev, data])
|
||||
} catch {
|
||||
alert('Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/dashboard/news" className="text-gray-400 hover:text-gray-600">
|
||||
← Zurück
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Beitrag erstellen</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||
<input
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Aussagekräftiger Titel..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={kategorie}
|
||||
onChange={(e) => setKategorie(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{KATEGORIEN.map((k) => (
|
||||
<option key={k.value} value={k.value}>{k.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Inhalt *</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={body}
|
||||
onChange={(v) => setBody(v ?? '')}
|
||||
height={400}
|
||||
preview="live"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anhänge (PDF)</label>
|
||||
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-colors">
|
||||
{uploading ? '⏳ Hochladen...' : '📎 Datei anhängen'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{attachments.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{attachments.map((a, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span>📄</span>
|
||||
<span>{a.name}</span>
|
||||
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2 border-t">
|
||||
<button
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={createMutation.isPending}
|
||||
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
Jetzt publizieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={createMutation.isPending}
|
||||
className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 border hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Als Entwurf speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
innungsapp/apps/admin/app/dashboard/news/page.tsx
Normal file
123
innungsapp/apps/admin/app/dashboard/news/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const KATEGORIE_COLORS: Record<string, string> = {
|
||||
Wichtig: 'bg-red-100 text-red-700',
|
||||
Pruefung: 'bg-blue-100 text-blue-700',
|
||||
Foerderung: 'bg-green-100 text-green-700',
|
||||
Veranstaltung: 'bg-purple-100 text-purple-700',
|
||||
Allgemein: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
export default async function NewsPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) redirect('/dashboard')
|
||||
|
||||
const news = await prisma.news.findMany({
|
||||
where: { orgId: userRole.orgId },
|
||||
include: { author: { select: { name: true } } },
|
||||
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
const published = news.filter((n) => n.publishedAt)
|
||||
const drafts = news.filter((n) => !n.publishedAt)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">News</h1>
|
||||
<p className="text-gray-500 mt-1">{published.length} publiziert · {drafts.length} Entwürfe</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/news/neu"
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
+ Beitrag erstellen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{drafts.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Entwürfe
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<tbody>
|
||||
{drafts.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td className="w-full">
|
||||
<p className="font-medium text-gray-900">{n.title}</p>
|
||||
<p className="text-xs text-gray-400">Erstellt {format(n.createdAt, 'dd. MMM yyyy', { locale: de })}</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Publiziert
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Autor</th>
|
||||
<th>Datum</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{published.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td className="font-medium text-gray-900">{n.title}</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-gray-500">{n.author?.name ?? '—'}</td>
|
||||
<td className="text-gray-500">
|
||||
{n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
innungsapp/apps/admin/app/dashboard/page.tsx
Normal file
125
innungsapp/apps/admin/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatsCards } from '@/components/stats/StatsCards'
|
||||
import Link from 'next/link'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
include: { org: true },
|
||||
})
|
||||
if (!userRole) redirect('/login')
|
||||
|
||||
const orgId = userRole.orgId
|
||||
const now = new Date()
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] =
|
||||
await Promise.all([
|
||||
prisma.member.count({ where: { orgId, status: 'aktiv' } }),
|
||||
prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }),
|
||||
prisma.termin.count({ where: { orgId, datum: { gte: now } } }),
|
||||
prisma.stelle.count({ where: { orgId, aktiv: true } }),
|
||||
prisma.news.findMany({
|
||||
where: { orgId, publishedAt: { not: null } },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 5,
|
||||
include: { author: { select: { name: true } } },
|
||||
}),
|
||||
prisma.termin.findMany({
|
||||
where: { orgId, datum: { gte: now } },
|
||||
orderBy: { datum: 'asc' },
|
||||
take: 3,
|
||||
}),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Übersicht</h1>
|
||||
<p className="text-gray-500 mt-1">{userRole.org.name}</p>
|
||||
</div>
|
||||
|
||||
<StatsCards
|
||||
stats={[
|
||||
{ label: 'Aktive Mitglieder', value: activeMembers, icon: '👥' },
|
||||
{ label: 'News diese Woche', value: newsThisWeek, icon: '📰' },
|
||||
{ label: 'Bevorstehende Termine', value: upcomingTermine, icon: '📅' },
|
||||
{ label: 'Aktive Stellen', value: activeStellen, icon: '🎓' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent News */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
|
||||
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentNews.map((n) => (
|
||||
<div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{n.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{n.publishedAt
|
||||
? format(n.publishedAt, 'dd. MMM yyyy', { locale: de })
|
||||
: 'Entwurf'}{' '}
|
||||
· {n.author?.name ?? 'Unbekannt'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Termine */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Nächste Termine</h2>
|
||||
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{nextTermine.length === 0 && (
|
||||
<p className="text-sm text-gray-500">Keine bevorstehenden Termine</p>
|
||||
)}
|
||||
{nextTermine.map((t) => (
|
||||
<div key={t.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
||||
<div className="text-center min-w-[40px]">
|
||||
<p className="text-lg font-bold text-brand-500 leading-none">
|
||||
{format(t.datum, 'dd', { locale: de })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 uppercase">
|
||||
{format(t.datum, 'MMM', { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{t.titel}</p>
|
||||
<p className="text-xs text-gray-500">{t.ort ?? 'Kein Ort angegeben'}</p>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{TERMIN_TYP_LABELS[t.typ]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export function DeactivateButton({ id }: { id: string }) {
|
||||
const router = useRouter()
|
||||
const mutation = trpc.stellen.deactivate.useMutation({
|
||||
onSuccess: () => router.refresh(),
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => mutation.mutate({ id })}
|
||||
disabled={mutation.isPending}
|
||||
className="text-sm text-red-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Deaktivieren
|
||||
</button>
|
||||
)
|
||||
}
|
||||
78
innungsapp/apps/admin/app/dashboard/stellen/page.tsx
Normal file
78
innungsapp/apps/admin/app/dashboard/stellen/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { DeactivateButton } from './DeactivateButton'
|
||||
|
||||
export default async function StellenPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) redirect('/dashboard')
|
||||
|
||||
const stellen = await prisma.stelle.findMany({
|
||||
where: { orgId: userRole.orgId },
|
||||
include: { member: { select: { name: true, betrieb: true } } },
|
||||
orderBy: [{ aktiv: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Lehrlingsbörse</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
{stellen.filter((s) => s.aktiv).length} aktive Angebote
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Betrieb</th>
|
||||
<th>Sparte</th>
|
||||
<th>Stellen</th>
|
||||
<th>Lehrjahr</th>
|
||||
<th>Vergütung</th>
|
||||
<th>Eingestellt</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stellen.map((s) => (
|
||||
<tr key={s.id} className={!s.aktiv ? 'opacity-50' : ''}>
|
||||
<td>
|
||||
<p className="font-medium text-gray-900">{s.member.betrieb}</p>
|
||||
<p className="text-xs text-gray-500">{s.member.name}</p>
|
||||
</td>
|
||||
<td>{s.sparte}</td>
|
||||
<td className="text-center">{s.stellenAnz}</td>
|
||||
<td>{s.lehrjahr ?? '—'}</td>
|
||||
<td>{s.verguetung ?? '—'}</td>
|
||||
<td>{format(s.createdAt, 'dd.MM.yyyy', { locale: de })}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${s.aktiv ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}
|
||||
>
|
||||
{s.aktiv ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{s.aktiv && <DeactivateButton id={s.id} />}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{stellen.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Noch keine Stellenangebote</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
innungsapp/apps/admin/app/dashboard/termine/neu/page.tsx
Normal file
144
innungsapp/apps/admin/app/dashboard/termine/neu/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import Link from 'next/link'
|
||||
|
||||
const TYPEN = [
|
||||
{ value: 'Pruefung', label: 'Prüfung' },
|
||||
{ value: 'Versammlung', label: 'Versammlung' },
|
||||
{ value: 'Kurs', label: 'Kurs' },
|
||||
{ value: 'Event', label: 'Event' },
|
||||
{ value: 'Sonstiges', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
export default function TerminNeuPage() {
|
||||
const router = useRouter()
|
||||
const [form, setForm] = useState({
|
||||
titel: '',
|
||||
datum: '',
|
||||
uhrzeit: '',
|
||||
endeDatum: '',
|
||||
endeUhrzeit: '',
|
||||
ort: '',
|
||||
adresse: '',
|
||||
typ: 'Versammlung',
|
||||
beschreibung: '',
|
||||
maxTeilnehmer: '',
|
||||
})
|
||||
|
||||
const createMutation = trpc.termine.create.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/termine'),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
createMutation.mutate({
|
||||
titel: form.titel,
|
||||
datum: form.datum,
|
||||
uhrzeit: form.uhrzeit || undefined,
|
||||
endeDatum: form.endeDatum || undefined,
|
||||
endeUhrzeit: form.endeUhrzeit || undefined,
|
||||
ort: form.ort || undefined,
|
||||
adresse: form.adresse || undefined,
|
||||
typ: form.typ as never,
|
||||
beschreibung: form.beschreibung || undefined,
|
||||
maxTeilnehmer: form.maxTeilnehmer ? Number(form.maxTeilnehmer) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const F = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||
setForm({ ...form, [field]: e.target.value })
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500'
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/dashboard/termine" className="text-gray-400 hover:text-gray-600">
|
||||
← Zurück
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Termin anlegen</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||
<input required value={form.titel} onChange={F('titel')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
|
||||
<select value={form.typ} onChange={F('typ')} className={inputClass}>
|
||||
{TYPEN.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max. Teilnehmer</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.maxTeilnehmer}
|
||||
onChange={F('maxTeilnehmer')}
|
||||
placeholder="Leer = unbegrenzt"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datum *</label>
|
||||
<input required type="date" value={form.datum} onChange={F('datum')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Uhrzeit (von)</label>
|
||||
<input type="time" value={form.uhrzeit} onChange={F('uhrzeit')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Datum</label>
|
||||
<input type="date" value={form.endeDatum} onChange={F('endeDatum')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Uhrzeit</label>
|
||||
<input type="time" value={form.endeUhrzeit} onChange={F('endeUhrzeit')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
||||
<input value={form.ort} onChange={F('ort')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input value={form.adresse} onChange={F('adresse')} className={inputClass} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={form.beschreibung}
|
||||
onChange={F('beschreibung')}
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{createMutation.isPending ? 'Wird gespeichert...' : 'Termin anlegen'}
|
||||
</button>
|
||||
<Link href="/dashboard/termine" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
innungsapp/apps/admin/app/dashboard/termine/page.tsx
Normal file
124
innungsapp/apps/admin/app/dashboard/termine/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const TYP_COLORS: Record<string, string> = {
|
||||
Pruefung: 'bg-blue-100 text-blue-700',
|
||||
Versammlung: 'bg-purple-100 text-purple-700',
|
||||
Kurs: 'bg-green-100 text-green-700',
|
||||
Event: 'bg-yellow-100 text-yellow-700',
|
||||
Sonstiges: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
export default async function TerminePage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) redirect('/dashboard')
|
||||
|
||||
const now = new Date()
|
||||
const [upcoming, past] = await Promise.all([
|
||||
prisma.termin.findMany({
|
||||
where: { orgId: userRole.orgId, datum: { gte: now } },
|
||||
include: { anmeldungen: { select: { id: true } } },
|
||||
orderBy: { datum: 'asc' },
|
||||
}),
|
||||
prisma.termin.findMany({
|
||||
where: { orgId: userRole.orgId, datum: { lt: now } },
|
||||
include: { anmeldungen: { select: { id: true } } },
|
||||
orderBy: { datum: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
])
|
||||
|
||||
const TerminRow = ({ t }: { t: typeof upcoming[0] }) => (
|
||||
<tr>
|
||||
<td>
|
||||
<div className="text-center w-10">
|
||||
<p className="font-bold text-brand-500">{format(t.datum, 'dd', { locale: de })}</p>
|
||||
<p className="text-xs text-gray-400 uppercase">{format(t.datum, 'MMM', { locale: de })}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<p className="font-medium text-gray-900">{t.titel}</p>
|
||||
{t.ort && <p className="text-xs text-gray-500">{t.ort}</p>}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${TYP_COLORS[t.typ]}`}>
|
||||
{TERMIN_TYP_LABELS[t.typ]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{t.maxTeilnehmer
|
||||
? `${t.anmeldungen.length} / ${t.maxTeilnehmer}`
|
||||
: t.anmeldungen.length}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/termine/${t.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Termine</h1>
|
||||
<Link
|
||||
href="/dashboard/termine/neu"
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
+ Termin anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Bevorstehend ({upcoming.length})
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Titel</th>
|
||||
<th>Typ</th>
|
||||
<th>Anmeldungen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{upcoming.map((t) => <TerminRow key={t.id} t={t} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
{upcoming.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine bevorstehenden Termine</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{past.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Vergangen
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden opacity-70">
|
||||
<table className="w-full data-table">
|
||||
<tbody>
|
||||
{past.map((t) => <TerminRow key={t.id} t={t} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user