This commit is contained in:
Timo Knuth
2026-02-27 15:19:24 +01:00
parent b7f8221095
commit 253c3c1c6d
134 changed files with 11188 additions and 1871 deletions

View File

@@ -0,0 +1,271 @@
'use client'
import { use } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc-client'
import { getTrpcErrorMessage } from '@/lib/trpc-error'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared'
import { Trash2 } from 'lucide-react'
export default function MitgliedEditPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = use(params)
const router = useRouter()
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
const updateMutation = trpc.members.update.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const deleteMutation = trpc.members.delete.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const resendMutation = trpc.members.resendInvite.useMutation()
const [form, setForm] = useState({
name: '',
betrieb: '',
sparte: '',
ort: '',
telefon: '',
email: '',
status: 'aktiv' as 'aktiv' | 'ruhend' | 'ausgetreten',
istAusbildungsbetrieb: false,
seit: undefined as number | undefined,
role: 'member' as 'member' | 'admin',
password: '',
})
const [isChangingPassword, setIsChangingPassword] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
useEffect(() => {
if (member) {
setForm({
name: member.name || '',
betrieb: member.betrieb || '',
sparte: member.sparte || '',
ort: member.ort || '',
telefon: member.telefon ?? '',
email: member.email || '',
status: (member.status as 'aktiv' | 'ruhend' | 'ausgetreten') || 'aktiv',
istAusbildungsbetrieb: member.istAusbildungsbetrieb || false,
seit: member.seit ?? undefined,
// @ts-ignore
role: member.role || 'member',
password: '',
})
}
}, [member])
if (isLoading) return <div className="text-gray-500">Wird geladen...</div>
if (!member) return null
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
updateMutation.mutate({ id, data: form })
}
const inputClass =
'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent'
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/mitglieder" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
Zurück
</Link>
<span className="text-gray-200">/</span>
<h1 className="text-2xl font-bold text-gray-900">Mitglied bearbeiten</h1>
</div>
{/* Invite Status */}
<div className="bg-white rounded-lg border p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-700">App-Zugang</p>
<p className="text-xs text-gray-500 mt-0.5">
{member.userId
? 'Mitglied hat sich eingeloggt'
: 'Noch nicht eingeladen / eingeloggt'}
</p>
</div>
{!member.userId && (
<button
onClick={() => resendMutation.mutate({ memberId: id })}
disabled={resendMutation.isPending}
className="text-sm text-brand-600 hover:underline disabled:opacity-50"
>
{resendMutation.isPending ? 'Sende...' : resendMutation.isSuccess ? 'Gesendet' : 'Einladung senden'}
</button>
)}
</div>
<div className="space-y-6 pb-20">
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
{/* Section: Stammdaten */}
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stammdaten</p>
<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 value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
</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={inputClass}>
{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 value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
</div>
</div>
</div>
{/* Section: Kontakt */}
<div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
</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={inputClass} />
</div>
</div>
</div>
{/* Section: Status */}
<div className="border-t pt-5">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Status</p>
<div className="grid grid-cols-2 gap-4">
<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={inputClass}>
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rolle</label>
<select value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value as 'member' | 'admin' })} className={inputClass}>
<option value="member">Mitglied</option>
<option value="admin">Administrator</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
{isChangingPassword ? (
<div className="flex gap-2">
<input
type="password"
placeholder="Neues Passwort festlegen"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className={inputClass}
/>
<button
type="button"
onClick={() => { setIsChangingPassword(false); setForm({ ...form, password: '' }) }}
className="text-xs text-gray-400 hover:text-gray-600 px-2"
>
Abbrechen
</button>
</div>
) : (
<div className="flex gap-2">
<input
type="text"
readOnly
value="••••••••"
className={`${inputClass} bg-gray-50 text-gray-400 cursor-default`}
/>
<button
type="button"
onClick={() => setIsChangingPassword(true)}
className="text-xs text-brand-600 hover:underline px-2 whitespace-nowrap"
>
{member.userId ? 'Ändern' : 'Setzen'}
</button>
</div>
)}
</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: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
</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>
{(updateMutation.error || deleteMutation.error) && (
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
{getTrpcErrorMessage(updateMutation.error || deleteMutation.error)}
</p>
)}
<div className="flex gap-3 pt-2 border-t">
<button type="submit" disabled={updateMutation.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">
{updateMutation.isPending ? 'Wird gespeichert...' : '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>
{/* Danger Zone */}
<div className="bg-red-50 rounded-lg border border-red-100 p-6 flex items-center justify-between">
<div>
<p className="text-sm font-bold text-red-900">Mitglied löschen</p>
<p className="text-xs text-red-700 mt-1 max-w-sm">
Dies entfernt das Mitglied permanent. Der App-Zugang wird ebenfalls entzogen.
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
</div>
{showConfirmDelete ? (
<div className="flex gap-2">
<button
onClick={() => deleteMutation.mutate({ id })}
disabled={deleteMutation.isPending}
className="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 transition-colors shadow-sm disabled:opacity-50"
>
{deleteMutation.isPending ? 'Lösche...' : 'Endgültig löschen'}
</button>
<button
onClick={() => setShowConfirmDelete(false)}
className="bg-white text-gray-700 px-4 py-2 rounded-lg text-sm font-medium border border-gray-200 hover:bg-gray-50 transition-colors"
>
Abbrechen
</button>
</div>
) : (
<button
onClick={() => setShowConfirmDelete(true)}
className="text-red-600 hover:text-red-700 font-medium text-sm flex items-center gap-1 bg-white px-4 py-2 rounded-lg border border-red-200 hover:bg-red-50 transition-all shadow-sm"
>
<Trash2 className="w-4 h-4" />
Löschen
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
'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 [form, setForm] = useState({
name: '',
betrieb: '',
sparte: 'Elektrotechnik',
ort: '',
telefon: '',
email: '',
status: 'aktiv' as const,
istAusbildungsbetrieb: false,
seit: new Date().getFullYear(),
role: 'member' as 'member' | 'admin',
password: '',
})
const createMutation = trpc.members.create.useMutation({
onSuccess: () => router.push('/dashboard/mitglieder'),
})
const isPending = createMutation.isPending
const error = createMutation.error
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
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>
<label className="block text-sm font-medium text-gray-700 mb-1">Rolle</label>
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
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="member">Mitglied</option>
<option value="admin">Administrator</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
<input
type="password"
placeholder="Mind. 8 Zeichen"
value={form.password}
onChange={(e) => setForm({ ...form, password: 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="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>
{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...' : '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>
)
}

View File

@@ -0,0 +1,213 @@
import { prisma } from '@innungsapp/shared'
import { auth, getSanitizedHeaders } 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: Record<string, string> = {
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(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const searchParams = await props.searchParams
const search = typeof searchParams.q === 'string' ? searchParams.q : ''
const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined
const sanitizedHeaders = await getSanitizedHeaders()
const session = await auth.api.getSession({ headers: sanitizedHeaders })
if (!session?.user) redirect('/login')
const userRole = await prisma.userRole.findFirst({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
})
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
const members = await prisma.member.findMany({
where: {
orgId: userRole.orgId,
...(statusFilter && { status: statusFilter as never }),
...(search && {
OR: [
{ name: { contains: search } },
{ betrieb: { contains: search } },
{ ort: { contains: search } },
],
}),
},
orderBy: { name: 'asc' },
})
// Also fetch admins to display them in the list if no status filter or status matches "aktiv"
const admins = await prisma.userRole.findMany({
where: {
orgId: userRole.orgId,
role: 'admin',
...(search && {
user: {
OR: [
{ name: { contains: search } },
{ email: { contains: search } },
]
}
})
},
include: {
user: true
}
})
const adminUserIds = new Set(admins.map(a => a.userId))
// Map userId → member record so admin entries show real member data
const memberByUserId = new Map(members.filter(m => m.userId).map(m => [m.userId!, m]))
const combinedList = [
// Include admins only if there's no status filter, or if filtering for 'aktiv'
...(!statusFilter || statusFilter === 'aktiv' ? admins.map(a => {
const m = memberByUserId.get(a.user.id)
return {
id: m ? m.id : `admin-${a.user.id}`,
name: m?.name ?? a.user.name,
betrieb: m?.betrieb ?? a.user.email,
sparte: m?.sparte ?? 'Sonderfunktion',
ort: m?.ort ?? '—',
seit: m?.seit ?? null as number | null,
status: m?.status ?? 'aktiv',
userId: a.user.id,
isAdmin: true,
realId: m ? m.id : a.user.id,
role: 'Administrator',
}
}) : []),
...members.filter(m => !adminUserIds.has(m.userId ?? '')).map(m => ({
id: m.id,
name: m.name,
betrieb: m.betrieb,
sparte: m.sparte,
ort: m.ort,
seit: m.seit,
status: m.status,
userId: m.userId,
isAdmin: false,
realId: m.id,
role: 'Mitglied',
}))
]
combinedList.sort((a, b) => a.name.localeCompare(b.name))
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">{combinedList.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-lg border 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-lg border overflow-hidden">
<table className="w-full data-table">
<thead>
<tr>
<th>Name / Betrieb</th>
<th>Rolle</th>
<th>Ort</th>
<th>Mitglied seit</th>
<th>Status</th>
<th>Eingeladen</th>
<th></th>
</tr>
</thead>
<tbody>
{combinedList.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>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${m.role === 'Administrator' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-700'}`}>
{m.role}
</span>
</td>
<td>{m.ort}</td>
<td>{m.seit ?? '—'}</td>
<td>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium ${STATUS_COLORS[m.status]}`}
>
{MEMBER_STATUS_LABELS[m.status as keyof typeof MEMBER_STATUS_LABELS] || 'Aktiv'}
</span>
</td>
<td>
{m.userId ? (
<span className="text-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">Aktiv</span>
) : (
<span className="text-[11px] text-gray-400"></span>
)}
</td>
<td>
<Link
href={`/dashboard/mitglieder/${m.realId}`}
className="text-sm text-brand-600 hover:underline"
>
Bearbeiten
</Link>
</td>
</tr>
))}
</tbody>
</table>
{combinedList.length === 0 && (
<div className="text-center py-12 text-gray-500">
Keine Mitglieder gefunden
</div>
)}
</div>
</div>
)
}