push
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
213
innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/page.tsx
Normal file
213
innungsapp/apps/admin/app/[slug]/dashboard/mitglieder/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user