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:
4
innungsapp/apps/admin/app/api/auth/[...all]/route.ts
Normal file
4
innungsapp/apps/admin/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from '@/lib/auth'
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth)
|
||||
23
innungsapp/apps/admin/app/api/trpc/[trpc]/route.ts
Normal file
23
innungsapp/apps/admin/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||
import { appRouter } from '@/server/routers'
|
||||
import { createContext } from '@/server/context'
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createContext({ req, resHeaders: new Headers(), info: {} as never }),
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`[tRPC] Error on ${path ?? '<no-path>'}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
54
innungsapp/apps/admin/app/api/upload/route.ts
Normal file
54
innungsapp/apps/admin/app/api/upload/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
|
||||
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
const session = await auth.api.getSession({ headers: req.headers })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
return NextResponse.json({ error: 'File too large' }, { status: 413 })
|
||||
}
|
||||
|
||||
// Only allow safe file types
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'File type not allowed' }, { status: 415 })
|
||||
}
|
||||
|
||||
const ext = path.extname(file.name)
|
||||
const fileName = `${randomUUID()}${ext}`
|
||||
const uploadPath = path.join(process.cwd(), UPLOAD_DIR)
|
||||
|
||||
await mkdir(uploadPath, { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(path.join(uploadPath, fileName), buffer)
|
||||
|
||||
return NextResponse.json({
|
||||
storagePath: fileName,
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
url: `/uploads/${fileName}`,
|
||||
})
|
||||
}
|
||||
41
innungsapp/apps/admin/app/api/uploads/[...path]/route.ts
Normal file
41
innungsapp/apps/admin/app/api/uploads/[...path]/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), UPLOAD_DIR, ...params.path)
|
||||
|
||||
// Security: prevent path traversal
|
||||
const resolved = path.resolve(filePath)
|
||||
const uploadDir = path.resolve(path.join(process.cwd(), UPLOAD_DIR))
|
||||
if (!resolved.startsWith(uploadDir)) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const file = await readFile(resolved)
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return new NextResponse('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
47
innungsapp/apps/admin/app/globals.css
Normal file
47
innungsapp/apps/admin/app/globals.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--brand: #E63946;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.sidebar-link {
|
||||
@apply flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900;
|
||||
}
|
||||
|
||||
.sidebar-link-active {
|
||||
@apply bg-brand-50 text-brand-600;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply rounded-xl border bg-white p-6 shadow-sm;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
@apply bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
@apply px-4 py-3 text-sm text-gray-700;
|
||||
}
|
||||
|
||||
.data-table tr {
|
||||
@apply border-b border-gray-100 last:border-0;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
}
|
||||
25
innungsapp/apps/admin/app/layout.tsx
Normal file
25
innungsapp/apps/admin/app/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'InnungsApp Admin',
|
||||
description: 'Verwaltungsportal für Innungen',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
118
innungsapp/apps/admin/app/login/page.tsx
Normal file
118
innungsapp/apps/admin/app/login/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { magicLinkClient } from 'better-auth/client/plugins'
|
||||
|
||||
const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
|
||||
plugins: [magicLinkClient()],
|
||||
})
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const result = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: '/dashboard',
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
|
||||
} else {
|
||||
setSent(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-brand-500 rounded-2xl mb-4">
|
||||
<span className="text-white font-bold text-2xl">I</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">InnungsApp Admin</h1>
|
||||
<p className="text-gray-500 mt-1">Verwaltungsportal für Innungen</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border p-8">
|
||||
{sent ? (
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
E-Mail gesendet!
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Wir haben einen Login-Link an <strong>{email}</strong> gesendet.
|
||||
Bitte überprüfen Sie Ihr Postfach.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSent(false)}
|
||||
className="mt-6 text-brand-600 text-sm hover:underline"
|
||||
>
|
||||
Andere E-Mail verwenden
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">
|
||||
Anmelden
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@ihre-innung.de"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-brand-500 text-white py-2.5 px-4 rounded-lg font-medium hover:bg-brand-600 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Wird gesendet...' : 'Magic Link senden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
Kein Passwort nötig — Sie erhalten einen Link per E-Mail.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
innungsapp/apps/admin/app/page.tsx
Normal file
5
innungsapp/apps/admin/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
44
innungsapp/apps/admin/app/providers.tsx
Normal file
44
innungsapp/apps/admin/app/providers.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
import { useState } from 'react'
|
||||
import superjson from 'superjson'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== 'undefined') return ''
|
||||
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: superjson,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
)
|
||||
}
|
||||
29
innungsapp/apps/admin/components/layout/Header.tsx
Normal file
29
innungsapp/apps/admin/components/layout/Header.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const authClient = createAuthClient()
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSignOut() {
|
||||
await authClient.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
|
||||
<div />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
58
innungsapp/apps/admin/components/layout/Sidebar.tsx
Normal file
58
innungsapp/apps/admin/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Übersicht', icon: '🏠' },
|
||||
{ href: '/dashboard/mitglieder', label: 'Mitglieder', icon: '👥' },
|
||||
{ href: '/dashboard/news', label: 'News', icon: '📰' },
|
||||
{ href: '/dashboard/termine', label: 'Termine', icon: '📅' },
|
||||
{ href: '/dashboard/stellen', label: 'Lehrlingsbörse', icon: '🎓' },
|
||||
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: '⚙️' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-white border-r flex flex-col flex-shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">I</span>
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">InnungsApp</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/dashboard'
|
||||
? pathname === '/dashboard'
|
||||
: pathname.startsWith(item.href)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx('sidebar-link', isActive && 'sidebar-link-active')}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t">
|
||||
<p className="text-xs text-gray-400">InnungsApp v0.1.0</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
19
innungsapp/apps/admin/components/stats/StatsCards.tsx
Normal file
19
innungsapp/apps/admin/components/stats/StatsCards.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface Stat {
|
||||
label: string
|
||||
value: number
|
||||
icon: string
|
||||
}
|
||||
|
||||
export function StatsCards({ stats }: { stats: Stat[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="stat-card">
|
||||
<div className="text-2xl mb-2">{stat.icon}</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{stat.value}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
innungsapp/apps/admin/lib/auth.ts
Normal file
34
innungsapp/apps/admin/lib/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
||||
import { magicLink } from 'better-auth/plugins'
|
||||
import { admin as adminPlugin } from 'better-auth/plugins'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { sendMagicLinkEmail } from './email'
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: 'postgresql',
|
||||
}),
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL!,
|
||||
trustedOrigins: [
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
|
||||
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000',
|
||||
],
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
await sendMagicLinkEmail({ to: email, magicUrl: url })
|
||||
},
|
||||
}),
|
||||
adminPlugin(),
|
||||
],
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5, // 5 minutes cache
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type Auth = typeof auth
|
||||
90
innungsapp/apps/admin/lib/email.ts
Normal file
90
innungsapp/apps/admin/lib/email.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth:
|
||||
process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
to,
|
||||
magicUrl,
|
||||
}: {
|
||||
to: string
|
||||
magicUrl: string
|
||||
}) {
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: 'Ihr Login-Link für InnungsApp',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
|
||||
</div>
|
||||
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #111827; margin-top: 0;">Ihr persönlicher Login-Link</h2>
|
||||
<p style="color: #4b5563;">Klicken Sie auf den folgenden Button, um sich einzuloggen. Der Link ist 24 Stunden gültig.</p>
|
||||
<a href="${magicUrl}"
|
||||
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Jetzt einloggen
|
||||
</a>
|
||||
<p style="color: #9ca3af; font-size: 14px;">
|
||||
Wenn Sie diesen Link nicht angefordert haben, können Sie diese E-Mail ignorieren.
|
||||
</p>
|
||||
<hr style="border-color: #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
|
||||
InnungsApp · Die digitale Plattform für Innungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendInviteEmail({
|
||||
to,
|
||||
memberName,
|
||||
orgName,
|
||||
apiUrl,
|
||||
}: {
|
||||
to: string
|
||||
memberName: string
|
||||
orgName: string
|
||||
apiUrl: string
|
||||
}) {
|
||||
// Generate magic link for the invite
|
||||
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: `Einladung zur InnungsApp — ${orgName}`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
|
||||
</div>
|
||||
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #111827; margin-top: 0;">Hallo ${memberName},</h2>
|
||||
<p style="color: #4b5563;">
|
||||
Sie wurden von der <strong>${orgName}</strong> zur InnungsApp eingeladen.
|
||||
InnungsApp ist die digitale Plattform Ihrer Innung für News, Termine und das Mitgliederverzeichnis.
|
||||
</p>
|
||||
<p style="color: #4b5563;">Klicken Sie auf den Button, um Ihren Account zu aktivieren:</p>
|
||||
<a href="${signInUrl}"
|
||||
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Jetzt Zugang aktivieren
|
||||
</a>
|
||||
<p style="color: #9ca3af; font-size: 14px;">Kein Passwort nötig — Sie erhalten einen sicheren Login-Link per E-Mail.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
45
innungsapp/apps/admin/lib/notifications.ts
Normal file
45
innungsapp/apps/admin/lib/notifications.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'
|
||||
|
||||
/**
|
||||
* Send push notifications to all active members in an org who have a push token
|
||||
*/
|
||||
export async function sendPushNotifications(orgId: string, title: string, body?: string) {
|
||||
const members = await prisma.member.findMany({
|
||||
where: { orgId, status: 'aktiv', pushToken: { not: null } },
|
||||
select: { pushToken: true },
|
||||
})
|
||||
|
||||
const tokens = members
|
||||
.map((m) => m.pushToken)
|
||||
.filter((t): t is string => t !== null)
|
||||
|
||||
if (tokens.length === 0) return
|
||||
|
||||
// Expo Push API supports batches of up to 100
|
||||
const batches: string[][] = []
|
||||
for (let i = 0; i < tokens.length; i += 100) {
|
||||
batches.push(tokens.slice(i, i + 100))
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const messages = batch.map((token) => ({
|
||||
to: token,
|
||||
title,
|
||||
body: body ?? 'Neue Nachricht von Ihrer Innung',
|
||||
sound: 'default',
|
||||
data: { type: 'news' },
|
||||
}))
|
||||
|
||||
await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-encoding': 'gzip, deflate',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(messages),
|
||||
})
|
||||
}
|
||||
}
|
||||
6
innungsapp/apps/admin/lib/trpc-client.ts
Normal file
6
innungsapp/apps/admin/lib/trpc-client.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createTRPCReact } from '@trpc/react-query'
|
||||
import type { AppRouter } from '@/server/routers'
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>()
|
||||
29
innungsapp/apps/admin/middleware.ts
Normal file
29
innungsapp/apps/admin/middleware.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/api/auth', '/api/trpc/stellen.listPublic']
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname
|
||||
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
||||
|
||||
if (isPublic) return NextResponse.next()
|
||||
|
||||
const sessionToken =
|
||||
request.cookies.get('better-auth.session_token') ??
|
||||
request.cookies.get('__Secure-better-auth.session_token')
|
||||
|
||||
if (!sessionToken) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
loginUrl.searchParams.set('callbackUrl', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|uploads).*)',
|
||||
],
|
||||
}
|
||||
19
innungsapp/apps/admin/next.config.ts
Normal file
19
innungsapp/apps/admin/next.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@innungsapp/shared'],
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
// Serve uploaded files
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/uploads/:path*',
|
||||
destination: '/api/uploads/:path*',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
43
innungsapp/apps/admin/package.json
Normal file
43
innungsapp/apps/admin/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@innungsapp/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@innungsapp/shared": "workspace:*",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"next": "^15.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"zod": "^3.23.0",
|
||||
"superjson": "^2.2.1",
|
||||
"nodemailer": "^6.9.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"@uiw/react-md-editor": "^4.0.4",
|
||||
"lucide-react": "^0.460.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "^15.0.0"
|
||||
}
|
||||
}
|
||||
6
innungsapp/apps/admin/postcss.config.js
Normal file
6
innungsapp/apps/admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
14
innungsapp/apps/admin/server/context.ts
Normal file
14
innungsapp/apps/admin/server/context.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function createContext({ req }: FetchCreateContextFnOptions) {
|
||||
const session = await auth.api.getSession({ headers: req.headers })
|
||||
return {
|
||||
req,
|
||||
session,
|
||||
prisma,
|
||||
}
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>
|
||||
16
innungsapp/apps/admin/server/routers/index.ts
Normal file
16
innungsapp/apps/admin/server/routers/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { router } from '../trpc'
|
||||
import { membersRouter } from './members'
|
||||
import { newsRouter } from './news'
|
||||
import { termineRouter } from './termine'
|
||||
import { stellenRouter } from './stellen'
|
||||
import { organizationsRouter } from './organizations'
|
||||
|
||||
export const appRouter = router({
|
||||
members: membersRouter,
|
||||
news: newsRouter,
|
||||
termine: termineRouter,
|
||||
stellen: stellenRouter,
|
||||
organizations: organizationsRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
161
innungsapp/apps/admin/server/routers/members.ts
Normal file
161
innungsapp/apps/admin/server/routers/members.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { z } from 'zod'
|
||||
import { router, memberProcedure, adminProcedure } from '../trpc'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { sendInviteEmail } from '@/lib/email'
|
||||
|
||||
const MemberInput = z.object({
|
||||
name: z.string().min(2),
|
||||
betrieb: z.string().min(2),
|
||||
sparte: z.string().min(2),
|
||||
ort: z.string().min(2),
|
||||
telefon: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).default('aktiv'),
|
||||
istAusbildungsbetrieb: z.boolean().default(false),
|
||||
seit: z.number().int().min(1900).max(2100).optional(),
|
||||
})
|
||||
|
||||
export const membersRouter = router({
|
||||
/**
|
||||
* List all members in the user's org
|
||||
*/
|
||||
list: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
search: z.string().optional(),
|
||||
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).optional(),
|
||||
ausbildungsbetrieb: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const members = await ctx.prisma.member.findMany({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
...(input.status && { status: input.status }),
|
||||
...(input.ausbildungsbetrieb !== undefined && {
|
||||
istAusbildungsbetrieb: input.ausbildungsbetrieb,
|
||||
}),
|
||||
...(input.search && {
|
||||
OR: [
|
||||
{ name: { contains: input.search, mode: 'insensitive' } },
|
||||
{ betrieb: { contains: input.search, mode: 'insensitive' } },
|
||||
{ ort: { contains: input.search, mode: 'insensitive' } },
|
||||
{ sparte: { contains: input.search, mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
return members
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single member by ID
|
||||
*/
|
||||
byId: memberProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
})
|
||||
if (!member) throw new Error('Member not found')
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new member (admin only)
|
||||
*/
|
||||
create: adminProcedure.input(MemberInput).mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.create({
|
||||
data: {
|
||||
...input,
|
||||
orgId: ctx.orgId,
|
||||
},
|
||||
})
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create member + send invite email (admin only)
|
||||
*/
|
||||
invite: adminProcedure
|
||||
.input(MemberInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// 1. Create member record
|
||||
const member = await ctx.prisma.member.create({
|
||||
data: { ...input, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
// 2. Create/get User via better-auth admin
|
||||
try {
|
||||
await auth.api.createUser({
|
||||
body: {
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
role: 'user',
|
||||
password: undefined,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// User may already exist — that's ok
|
||||
}
|
||||
|
||||
// 3. Send magic link
|
||||
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
||||
where: { id: ctx.orgId },
|
||||
})
|
||||
await sendInviteEmail({
|
||||
to: input.email,
|
||||
memberName: input.name,
|
||||
orgName: org.name,
|
||||
apiUrl: process.env.BETTER_AUTH_URL!,
|
||||
})
|
||||
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update member (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), data: MemberInput.partial() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.updateMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
data: input.data,
|
||||
})
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send/resend invite to existing member (admin only)
|
||||
*/
|
||||
resendInvite: adminProcedure
|
||||
.input(z.object({ memberId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirstOrThrow({
|
||||
where: { id: input.memberId, orgId: ctx.orgId },
|
||||
})
|
||||
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
||||
where: { id: ctx.orgId },
|
||||
})
|
||||
await sendInviteEmail({
|
||||
to: member.email,
|
||||
memberName: member.name,
|
||||
orgName: org.name,
|
||||
apiUrl: process.env.BETTER_AUTH_URL!,
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get own member profile
|
||||
*/
|
||||
me: memberProcedure.query(async ({ ctx }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
include: { org: true },
|
||||
})
|
||||
return member
|
||||
}),
|
||||
})
|
||||
170
innungsapp/apps/admin/server/routers/news.ts
Normal file
170
innungsapp/apps/admin/server/routers/news.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { z } from 'zod'
|
||||
import { router, memberProcedure, adminProcedure } from '../trpc'
|
||||
import { sendPushNotifications } from '@/lib/notifications'
|
||||
|
||||
const NewsInput = z.object({
|
||||
title: z.string().min(3),
|
||||
body: z.string().min(10),
|
||||
kategorie: z.enum(['Wichtig', 'Pruefung', 'Foerderung', 'Veranstaltung', 'Allgemein']),
|
||||
publishedAt: z.string().datetime().optional().nullable(),
|
||||
})
|
||||
|
||||
export const newsRouter = router({
|
||||
/**
|
||||
* List published news for org members
|
||||
*/
|
||||
list: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
kategorie: z
|
||||
.enum(['Wichtig', 'Pruefung', 'Foerderung', 'Veranstaltung', 'Allgemein'])
|
||||
.optional(),
|
||||
includeUnpublished: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const news = await ctx.prisma.news.findMany({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
...(input.kategorie && { kategorie: input.kategorie }),
|
||||
...(!input.includeUnpublished && { publishedAt: { not: null } }),
|
||||
...(input.includeUnpublished &&
|
||||
ctx.role !== 'admin' && { publishedAt: { not: null } }),
|
||||
},
|
||||
include: {
|
||||
author: { select: { name: true } },
|
||||
attachments: true,
|
||||
reads: {
|
||||
where: { userId: ctx.session.user.id },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
return news.map((n) => ({
|
||||
...n,
|
||||
isRead: n.reads.length > 0,
|
||||
reads: undefined,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get single news article
|
||||
*/
|
||||
byId: memberProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const news = await ctx.prisma.news.findFirstOrThrow({
|
||||
where: {
|
||||
id: input.id,
|
||||
orgId: ctx.orgId,
|
||||
...(ctx.role !== 'admin' && { publishedAt: { not: null } }),
|
||||
},
|
||||
include: {
|
||||
author: { select: { name: true, betrieb: true } },
|
||||
attachments: true,
|
||||
},
|
||||
})
|
||||
return news
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark news as read
|
||||
*/
|
||||
markRead: memberProcedure
|
||||
.input(z.object({ newsId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.newsRead.upsert({
|
||||
where: {
|
||||
newsId_userId: { newsId: input.newsId, userId: ctx.session.user.id },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
newsId: input.newsId,
|
||||
userId: ctx.session.user.id,
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create news article (admin only)
|
||||
*/
|
||||
create: adminProcedure.input(NewsInput).mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
const news = await ctx.prisma.news.create({
|
||||
data: {
|
||||
orgId: ctx.orgId,
|
||||
authorId: member?.id,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
kategorie: input.kategorie,
|
||||
publishedAt: input.publishedAt ? new Date(input.publishedAt) : null,
|
||||
},
|
||||
})
|
||||
|
||||
// Trigger push notifications if publishing now
|
||||
if (news.publishedAt) {
|
||||
sendPushNotifications(ctx.orgId, news.title).catch(console.error)
|
||||
}
|
||||
|
||||
return news
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update news article (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), data: NewsInput.partial() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const wasUnpublished = await ctx.prisma.news.findFirst({
|
||||
where: { id: input.id, orgId: ctx.orgId, publishedAt: null },
|
||||
})
|
||||
|
||||
const news = await ctx.prisma.news.updateMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
data: {
|
||||
...input.data,
|
||||
publishedAt: input.data.publishedAt
|
||||
? new Date(input.data.publishedAt)
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Trigger push if just published
|
||||
if (wasUnpublished && input.data.publishedAt && input.data.title) {
|
||||
sendPushNotifications(ctx.orgId, input.data.title).catch(console.error)
|
||||
}
|
||||
|
||||
return news
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete news article (admin only)
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.news.deleteMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get read stats for admin
|
||||
*/
|
||||
readStats: adminProcedure
|
||||
.input(z.object({ newsId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [totalMembers, readers] = await Promise.all([
|
||||
ctx.prisma.member.count({ where: { orgId: ctx.orgId, status: 'aktiv' } }),
|
||||
ctx.prisma.newsRead.count({ where: { newsId: input.newsId } }),
|
||||
])
|
||||
return { totalMembers, readers, readRate: totalMembers ? readers / totalMembers : 0 }
|
||||
}),
|
||||
})
|
||||
89
innungsapp/apps/admin/server/routers/organizations.ts
Normal file
89
innungsapp/apps/admin/server/routers/organizations.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure, publicProcedure } from '../trpc'
|
||||
|
||||
export const organizationsRouter = router({
|
||||
/**
|
||||
* Get own organization details
|
||||
*/
|
||||
me: adminProcedure.query(async ({ ctx }) => {
|
||||
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
||||
where: { id: ctx.orgId },
|
||||
})
|
||||
return org
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update org settings (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(3).optional(),
|
||||
contactEmail: z.string().email().optional(),
|
||||
primaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const org = await ctx.prisma.organization.update({
|
||||
where: { id: ctx.orgId },
|
||||
data: input,
|
||||
})
|
||||
return org
|
||||
}),
|
||||
|
||||
/**
|
||||
* Accept AVV (Auftragsverarbeitungsvertrag)
|
||||
*/
|
||||
acceptAvv: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const org = await ctx.prisma.organization.update({
|
||||
where: { id: ctx.orgId },
|
||||
data: {
|
||||
avvAccepted: true,
|
||||
avvAcceptedAt: new Date(),
|
||||
},
|
||||
})
|
||||
return org
|
||||
}),
|
||||
|
||||
/**
|
||||
* Dashboard stats
|
||||
*/
|
||||
stats: adminProcedure.query(async ({ ctx }) => {
|
||||
const now = new Date()
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen] =
|
||||
await Promise.all([
|
||||
ctx.prisma.member.count({
|
||||
where: { orgId: ctx.orgId, status: 'aktiv' },
|
||||
}),
|
||||
ctx.prisma.news.count({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
publishedAt: { gte: weekAgo, not: null },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.termin.count({
|
||||
where: { orgId: ctx.orgId, datum: { gte: now } },
|
||||
}),
|
||||
ctx.prisma.stelle.count({
|
||||
where: { orgId: ctx.orgId, aktiv: true },
|
||||
}),
|
||||
])
|
||||
|
||||
return { activeMembers, newsThisWeek, upcomingTermine, activeStellen }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Public org info by slug (for mobile onboarding)
|
||||
*/
|
||||
bySlug: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const org = await ctx.prisma.organization.findUnique({
|
||||
where: { slug: input.slug },
|
||||
select: { id: true, name: true, slug: true, primaryColor: true, logoUrl: true },
|
||||
})
|
||||
return org
|
||||
}),
|
||||
})
|
||||
143
innungsapp/apps/admin/server/routers/stellen.ts
Normal file
143
innungsapp/apps/admin/server/routers/stellen.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { z } from 'zod'
|
||||
import { router, publicProcedure, memberProcedure, adminProcedure } from '../trpc'
|
||||
|
||||
const StelleInput = z.object({
|
||||
sparte: z.string().min(2),
|
||||
stellenAnz: z.number().int().min(1).default(1),
|
||||
verguetung: z.string().optional(),
|
||||
lehrjahr: z.string().optional(),
|
||||
beschreibung: z.string().optional(),
|
||||
kontaktEmail: z.string().email(),
|
||||
kontaktName: z.string().optional(),
|
||||
})
|
||||
|
||||
export const stellenRouter = router({
|
||||
/**
|
||||
* Public list — no auth required (Lehrlingsbörse)
|
||||
*/
|
||||
listPublic: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sparte: z.string().optional(),
|
||||
lehrjahr: z.string().optional(),
|
||||
orgSlug: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stellen = await ctx.prisma.stelle.findMany({
|
||||
where: {
|
||||
aktiv: true,
|
||||
...(input.sparte && { sparte: input.sparte }),
|
||||
...(input.lehrjahr && { lehrjahr: input.lehrjahr }),
|
||||
...(input.orgSlug && {
|
||||
org: { slug: input.orgSlug },
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
member: { select: { betrieb: true, ort: true } },
|
||||
org: { select: { name: true, slug: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return stellen
|
||||
}),
|
||||
|
||||
/**
|
||||
* List stellen for org (authenticated members)
|
||||
*/
|
||||
list: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
includeInaktiv: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stellen = await ctx.prisma.stelle.findMany({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
...(ctx.role !== 'admin' && !input.includeInaktiv && { aktiv: true }),
|
||||
...(ctx.role === 'admin' && !input.includeInaktiv && { aktiv: true }),
|
||||
},
|
||||
include: {
|
||||
member: { select: { name: true, betrieb: true, ort: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return stellen
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get single Stelle (public)
|
||||
*/
|
||||
byId: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stelle = await ctx.prisma.stelle.findFirstOrThrow({
|
||||
where: { id: input.id, aktiv: true },
|
||||
include: {
|
||||
member: { select: { betrieb: true, ort: true } },
|
||||
org: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
return stelle
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create Stelle (own member only)
|
||||
*/
|
||||
create: memberProcedure
|
||||
.input(StelleInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirstOrThrow({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
const stelle = await ctx.prisma.stelle.create({
|
||||
data: {
|
||||
orgId: ctx.orgId,
|
||||
memberId: member.id,
|
||||
...input,
|
||||
},
|
||||
})
|
||||
return stelle
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update own Stelle or admin update any
|
||||
*/
|
||||
update: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: StelleInput.partial().extend({ aktiv: z.boolean().optional() }),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
await ctx.prisma.stelle.updateMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
orgId: ctx.orgId,
|
||||
// Admin can update any, member only their own
|
||||
...(ctx.role !== 'admin' && member ? { memberId: member.id } : {}),
|
||||
},
|
||||
data: input.data,
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Deactivate Stelle (admin moderation)
|
||||
*/
|
||||
deactivate: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.stelle.updateMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
data: { aktiv: false },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
180
innungsapp/apps/admin/server/routers/termine.ts
Normal file
180
innungsapp/apps/admin/server/routers/termine.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { z } from 'zod'
|
||||
import { router, memberProcedure, adminProcedure } from '../trpc'
|
||||
|
||||
const TerminInput = z.object({
|
||||
titel: z.string().min(3),
|
||||
datum: z.string(), // ISO date string "YYYY-MM-DD"
|
||||
uhrzeit: z.string().optional(),
|
||||
endeDatum: z.string().optional().nullable(),
|
||||
endeUhrzeit: z.string().optional().nullable(),
|
||||
ort: z.string().optional(),
|
||||
adresse: z.string().optional(),
|
||||
typ: z.enum(['Pruefung', 'Versammlung', 'Kurs', 'Event', 'Sonstiges']),
|
||||
beschreibung: z.string().optional(),
|
||||
maxTeilnehmer: z.number().int().positive().optional().nullable(),
|
||||
})
|
||||
|
||||
export const termineRouter = router({
|
||||
/**
|
||||
* List all termine for org
|
||||
*/
|
||||
list: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
upcoming: z.boolean().optional(),
|
||||
nurAngemeldet: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const termine = await ctx.prisma.termin.findMany({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
...(input.upcoming && { datum: { gte: today } }),
|
||||
...(!input.upcoming &&
|
||||
input.upcoming !== undefined && { datum: { lt: today } }),
|
||||
...(input.nurAngemeldet &&
|
||||
member && {
|
||||
anmeldungen: { some: { memberId: member.id } },
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
anmeldungen: {
|
||||
select: { memberId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { datum: input.upcoming ? 'asc' : 'desc' },
|
||||
})
|
||||
|
||||
return termine.map((t) => ({
|
||||
...t,
|
||||
isAngemeldet: member
|
||||
? t.anmeldungen.some((a) => a.memberId === member.id)
|
||||
: false,
|
||||
teilnehmerAnzahl: t.anmeldungen.length,
|
||||
anmeldungen: undefined,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get single Termin
|
||||
*/
|
||||
byId: memberProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
const termin = await ctx.prisma.termin.findFirstOrThrow({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
include: {
|
||||
anmeldungen: {
|
||||
include: {
|
||||
member: { select: { name: true, betrieb: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const isAngemeldet = member
|
||||
? termin.anmeldungen.some((a) => a.memberId === member.id)
|
||||
: false
|
||||
|
||||
return {
|
||||
...termin,
|
||||
isAngemeldet,
|
||||
teilnehmerAnzahl: termin.anmeldungen.length,
|
||||
// Only expose member list to admins
|
||||
anmeldungen: ctx.role === 'admin' ? termin.anmeldungen : [],
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Anmelden / Abmelden
|
||||
*/
|
||||
toggleAnmeldung: memberProcedure
|
||||
.input(z.object({ terminId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirstOrThrow({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
const termin = await ctx.prisma.termin.findFirstOrThrow({
|
||||
where: { id: input.terminId, orgId: ctx.orgId },
|
||||
include: { anmeldungen: true },
|
||||
})
|
||||
|
||||
const existing = termin.anmeldungen.find(
|
||||
(a) => a.memberId === member.id
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
// Abmelden
|
||||
await ctx.prisma.terminAnmeldung.delete({ where: { id: existing.id } })
|
||||
return { angemeldet: false }
|
||||
} else {
|
||||
// Check capacity
|
||||
if (
|
||||
termin.maxTeilnehmer &&
|
||||
termin.anmeldungen.length >= termin.maxTeilnehmer
|
||||
) {
|
||||
throw new Error('Maximale Teilnehmerzahl erreicht')
|
||||
}
|
||||
await ctx.prisma.terminAnmeldung.create({
|
||||
data: { terminId: input.terminId, memberId: member.id },
|
||||
})
|
||||
return { angemeldet: true }
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create Termin (admin only)
|
||||
*/
|
||||
create: adminProcedure.input(TerminInput).mutation(async ({ ctx, input }) => {
|
||||
const termin = await ctx.prisma.termin.create({
|
||||
data: {
|
||||
orgId: ctx.orgId,
|
||||
...input,
|
||||
datum: new Date(input.datum),
|
||||
endeDatum: input.endeDatum ? new Date(input.endeDatum) : null,
|
||||
},
|
||||
})
|
||||
return termin
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update Termin (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), data: TerminInput.partial() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.termin.updateMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
data: {
|
||||
...input.data,
|
||||
...(input.data.datum && { datum: new Date(input.data.datum) }),
|
||||
...(input.data.endeDatum && { endeDatum: new Date(input.data.endeDatum) }),
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete Termin (admin only)
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.termin.deleteMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
82
innungsapp/apps/admin/server/trpc.ts
Normal file
82
innungsapp/apps/admin/server/trpc.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { initTRPC, TRPCError } from '@trpc/server'
|
||||
import superjson from 'superjson'
|
||||
import { ZodError } from 'zod'
|
||||
import { type Context } from './context'
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const router = t.router
|
||||
export const publicProcedure = t.procedure
|
||||
export const createCallerFactory = t.createCallerFactory
|
||||
|
||||
/**
|
||||
* Protected: user must be authenticated
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' })
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
session: { ...ctx.session, user: ctx.session.user },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Member: user must belong to an organization
|
||||
* Adds orgId + role to context
|
||||
*/
|
||||
export const memberProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const userRole = await ctx.prisma.userRole.findFirst({
|
||||
where: { userId: ctx.session.user.id },
|
||||
})
|
||||
if (!userRole) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You are not a member of any organization.',
|
||||
})
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
orgId: userRole.orgId,
|
||||
role: userRole.role,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Admin: user must be an admin of their organization
|
||||
*/
|
||||
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const userRole = await ctx.prisma.userRole.findFirst({
|
||||
where: { userId: ctx.session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Admin access required.',
|
||||
})
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
orgId: userRole.orgId,
|
||||
role: 'admin' as const,
|
||||
},
|
||||
})
|
||||
})
|
||||
33
innungsapp/apps/admin/tailwind.config.ts
Normal file
33
innungsapp/apps/admin/tailwind.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#fff1f1',
|
||||
100: '#ffe1e1',
|
||||
200: '#ffC7c7',
|
||||
300: '#ffa0a0',
|
||||
400: '#ff6b6b',
|
||||
500: '#E63946',
|
||||
600: '#d42535',
|
||||
700: '#b21e2c',
|
||||
800: '#931d29',
|
||||
900: '#7a1e27',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
23
innungsapp/apps/admin/tsconfig.json
Normal file
23
innungsapp/apps/admin/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user