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:
Timo Knuth
2026-02-18 19:03:37 +01:00
parent fc68285cf1
commit fca42db4d2
116 changed files with 9329 additions and 6479 deletions

View File

@@ -0,0 +1,4 @@
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { POST, GET } = toNextJsHandler(auth)

View 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 }

View 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}`,
})
}

View 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 })
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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;
}
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function RootPage() {
redirect('/dashboard')
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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

View 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>
`,
})
}

View 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),
})
}
}

View File

@@ -0,0 +1,6 @@
'use client'
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers'
export const trpc = createTRPCReact<AppRouter>()

View 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).*)',
],
}

View 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

View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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>>

View 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

View 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
}),
})

View 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 }
}),
})

View 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
}),
})

View 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 }
}),
})

View 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 }
}),
})

View 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,
},
})
})

View 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

View 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"]
}

View File

@@ -0,0 +1,59 @@
{
"expo": {
"name": "InnungsApp",
"slug": "innungsapp",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "innungsapp",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#E63946"
},
"ios": {
"supportsTablet": false,
"bundleIdentifier": "de.innungsapp.mobile",
"infoPlist": {
"NSCameraUsageDescription": "Für Profilfotos",
"NSCalendarsUsageDescription": "Termine in Ihren Kalender übernehmen"
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#E63946"
},
"package": "de.innungsapp.mobile",
"permissions": ["RECEIVE_BOOT_COMPLETED", "SCHEDULE_EXACT_ALARM"]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-font",
"expo-system-ui",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#E63946",
"sounds": []
}
],
[
"expo-calendar",
{
"calendarPermission": "Die App benötigt Zugriff auf Ihren Kalender."
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@@ -0,0 +1,78 @@
import { Tabs } from 'expo-router'
import { useAuthStore } from '@/store/auth.store'
import { Redirect } from 'expo-router'
import { Platform } from 'react-native'
function TabIcon({ emoji }: { emoji: string }) {
return null // Replaced by tabBarIcon in options
}
export default function AppLayout() {
const session = useAuthStore((s) => s.session)
if (!session) {
return <Redirect href="/(auth)/login" />
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#E63946',
tabBarInactiveTintColor: '#6b7280',
tabBarStyle: {
borderTopColor: '#e5e7eb',
backgroundColor: 'white',
paddingBottom: Platform.OS === 'ios' ? 8 : 4,
height: Platform.OS === 'ios' ? 82 : 60,
},
headerStyle: { backgroundColor: 'white' },
headerTitleStyle: { fontWeight: '700', color: '#111827' },
headerShadowVisible: false,
}}
>
<Tabs.Screen
name="news"
options={{
title: 'News',
tabBarIcon: ({ color }) => (
/* Replace with actual icons after @expo/vector-icons setup */
<TabIcon emoji="📰" />
),
headerShown: false,
}}
/>
<Tabs.Screen
name="members"
options={{
title: 'Mitglieder',
tabBarIcon: () => <TabIcon emoji="👥" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="termine"
options={{
title: 'Termine',
tabBarIcon: () => <TabIcon emoji="📅" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="stellen"
options={{
title: 'Stellen',
tabBarIcon: () => <TabIcon emoji="🎓" />,
headerShown: false,
}}
/>
<Tabs.Screen
name="profil"
options={{
title: 'Profil',
tabBarIcon: () => <TabIcon emoji="👤" />,
headerShown: false,
}}
/>
</Tabs>
)
}

View File

@@ -0,0 +1,107 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { Avatar } from '@/components/ui/Avatar'
export default function MemberDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: member, isLoading } = trpc.members.byId.useQuery({ id })
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!member) return null
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
</View>
<ScrollView>
{/* Profile Header */}
<View className="bg-white px-6 py-8 items-center border-b border-gray-100">
<Avatar
name={member.name}
imageUrl={member.avatarUrl ?? undefined}
size={80}
/>
<Text className="text-2xl font-bold text-gray-900 mt-4">{member.name}</Text>
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
{member.istAusbildungsbetrieb && (
<View className="mt-2 bg-green-100 px-3 py-1 rounded-full">
<Text className="text-green-700 text-xs font-medium">
🎓 Ausbildungsbetrieb
</Text>
</View>
)}
</View>
{/* Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<InfoRow label="Sparte" value={member.sparte} />
<InfoRow label="Ort" value={member.ort} />
{member.seit && (
<InfoRow label="Mitglied seit" value={String(member.seit)} />
)}
</View>
{/* Contact Buttons */}
<View className="mx-4 mt-4 gap-3">
{member.telefon && (
<TouchableOpacity
onPress={() => Linking.openURL(`tel:${member.telefon}`)}
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-white text-xl">📞</Text>
<Text className="text-white font-semibold text-base">
Anrufen
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={() =>
Linking.openURL(
`mailto:${member.email}?subject=InnungsApp%20Anfrage`
)
}
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-gray-900 text-xl"></Text>
<Text className="text-gray-900 font-semibold text-base">
E-Mail senden
</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-32">{label}</Text>
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,101 @@
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { useMembersFilterStore } from '@/store/members.store'
import { MemberCard } from '@/components/members/MemberCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
export default function MembersScreen() {
const router = useRouter()
const search = useMembersFilterStore((s) => s.search)
const nurAusbildungsbetriebe = useMembersFilterStore((s) => s.nurAusbildungsbetriebe)
const setSearch = useMembersFilterStore((s) => s.setSearch)
const setNurAusbildungsbetriebe = useMembersFilterStore((s) => s.setNurAusbildungsbetriebe)
const { data, isLoading, refetch, isRefetching } = trpc.members.list.useQuery({
search: search || undefined,
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
status: 'aktiv',
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 pt-3 pb-2 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900 mb-3">Mitglieder</Text>
{/* Search */}
<View className="flex-row items-center bg-gray-100 rounded-xl px-3 py-2 mb-2">
<Text className="text-gray-400 mr-2">🔍</Text>
<TextInput
className="flex-1 text-sm text-gray-900"
placeholder="Name, Betrieb, Ort, Sparte..."
placeholderTextColor="#9ca3af"
value={search}
onChangeText={setSearch}
clearButtonMode="while-editing"
/>
</View>
{/* Filter: Ausbildungsbetriebe */}
<TouchableOpacity
onPress={() => setNurAusbildungsbetriebe(!nurAusbildungsbetriebe)}
className="flex-row items-center gap-2 py-1"
>
<View
className={`w-5 h-5 rounded border-2 items-center justify-center ${
nurAusbildungsbetriebe
? 'bg-brand-500 border-brand-500'
: 'border-gray-300 bg-white'
}`}
>
{nurAusbildungsbetriebe && (
<Text className="text-white text-xs"></Text>
)}
</View>
<Text className="text-sm text-gray-600">Nur Ausbildungsbetriebe</Text>
</TouchableOpacity>
</View>
{/* List */}
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<MemberCard
member={item}
onPress={() => router.push(`/(app)/members/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="👥"
title="Keine Mitglieder"
subtitle={search ? 'Keine Treffer für Ihre Suche' : 'Noch keine Mitglieder'}
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,87 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useEffect } from 'react'
import { trpc } from '@/lib/trpc'
import { useNewsReadStore } from '@/store/news.store'
import { AttachmentRow } from '@/components/news/AttachmentRow'
import { Badge } from '@/components/ui/Badge'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
export default function NewsDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const markRead = useNewsReadStore((s) => s.markRead)
const markReadMutation = trpc.news.markRead.useMutation()
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
useEffect(() => {
if (news) {
markRead(id)
markReadMutation.mutate({ newsId: id })
}
}, [news?.id])
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!news) return null
return (
<SafeAreaView className="flex-1 bg-white" edges={['top']}>
{/* Header */}
<View className="flex-row items-center px-4 py-3 border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
<Text className="font-semibold text-gray-900 flex-1" numberOfLines={1}>
{news.title}
</Text>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }}>
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
<Text className="text-2xl font-bold text-gray-900 mt-3 mb-2">
{news.title}
</Text>
<Text className="text-sm text-gray-500 mb-6">
{news.author?.name ?? 'InnungsApp'} ·{' '}
{news.publishedAt
? format(new Date(news.publishedAt), 'dd. MMMM yyyy', { locale: de })
: ''}
</Text>
{/* Simple Markdown renderer — plain text for MVP */}
<Text className="text-base text-gray-700 leading-7">
{news.body.replace(/^#+\s/gm, '').replace(/\*\*(.*?)\*\*/g, '$1')}
</Text>
{/* Attachments */}
{news.attachments.length > 0 && (
<View className="mt-8 border-t border-gray-100 pt-4">
<Text className="font-semibold text-gray-900 mb-3">Anhänge</Text>
{news.attachments.map((a) => (
<AttachmentRow key={a.id} attachment={a} />
))}
</View>
)}
</ScrollView>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,100 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
ScrollView,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { trpc } from '@/lib/trpc'
import { useRouter } from 'expo-router'
import { NewsCard } from '@/components/news/NewsCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
const FILTER_OPTIONS = [
{ value: undefined, label: 'Alle' },
{ value: 'Wichtig', label: 'Wichtig' },
{ value: 'Pruefung', label: 'Prüfung' },
{ value: 'Foerderung', label: 'Förderung' },
{ value: 'Veranstaltung', label: 'Veranstaltung' },
]
export default function NewsScreen() {
const router = useRouter()
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
const { data, isLoading, refetch, isRefetching } = trpc.news.list.useQuery({
kategorie: kategorie as never,
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900">News</Text>
</View>
{/* Kategorie Filter */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="bg-white border-b border-gray-100"
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 10, gap: 8 }}
>
{FILTER_OPTIONS.map((opt) => (
<TouchableOpacity
key={String(opt.value)}
onPress={() => setKategorie(opt.value)}
className={`px-4 py-1.5 rounded-full border ${
kategorie === opt.value
? 'bg-brand-500 border-brand-500'
: 'bg-white border-gray-200'
}`}
>
<Text
className={`text-sm font-medium ${
kategorie === opt.value ? 'text-white' : 'text-gray-600'
}`}
>
{opt.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* List */}
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<NewsCard
news={item}
onPress={() => router.push(`/(app)/news/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="📰"
title="Keine News"
subtitle="Noch keine Beiträge für diese Kategorie"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,97 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { trpc } from '@/lib/trpc'
import { useAuth } from '@/hooks/useAuth'
import { Avatar } from '@/components/ui/Avatar'
export default function ProfilScreen() {
const { signOut } = useAuth()
const { data: member, isLoading } = trpc.members.me.useQuery()
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900">Mein Profil</Text>
</View>
<ScrollView>
{isLoading ? (
<View className="py-16 items-center">
<ActivityIndicator color="#E63946" />
</View>
) : member ? (
<>
{/* Profile */}
<View className="bg-white px-4 py-8 items-center border-b border-gray-100">
<Avatar name={member.name} size={72} />
<Text className="text-xl font-bold text-gray-900 mt-4">{member.name}</Text>
<Text className="text-gray-500 mt-1">{member.betrieb}</Text>
<Text className="text-gray-400 text-sm mt-0.5">{member.org.name}</Text>
</View>
{/* Member Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<InfoRow label="E-Mail" value={member.email} />
{member.telefon && <InfoRow label="Telefon" value={member.telefon} />}
<InfoRow label="Sparte" value={member.sparte} />
<InfoRow label="Ort" value={member.ort} />
{member.seit && <InfoRow label="Mitglied seit" value={String(member.seit)} />}
</View>
<View className="mx-4 mt-2">
<Text className="text-xs text-gray-400 px-1">
Änderungen an Ihren Daten nehmen Sie über die Innungsgeschäftsstelle vor.
</Text>
</View>
</>
) : null}
{/* Links */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<TouchableOpacity
onPress={() => Linking.openURL('https://innungsapp.de/datenschutz')}
className="flex-row items-center justify-between px-4 py-3.5 border-b border-gray-50"
>
<Text className="text-gray-700">Datenschutzerklärung</Text>
<Text className="text-gray-400"></Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => Linking.openURL('https://innungsapp.de/impressum')}
className="flex-row items-center justify-between px-4 py-3.5"
>
<Text className="text-gray-700">Impressum</Text>
<Text className="text-gray-400"></Text>
</TouchableOpacity>
</View>
{/* Logout */}
<View className="mx-4 mt-4">
<TouchableOpacity
onPress={signOut}
className="bg-red-50 border border-red-200 rounded-2xl py-4 items-center"
>
<Text className="text-red-600 font-semibold">Abmelden</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-28">{label}</Text>
<Text className="text-sm text-gray-900 flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,94 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
export default function StelleDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: stelle, isLoading } = trpc.stellen.byId.useQuery({ id })
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!stelle) return null
const betreffVorlage = `Bewerbung als Auszubildender bei ${stelle.member.betrieb}`
const bewerbungsUrl = `mailto:${stelle.kontaktEmail}?subject=${encodeURIComponent(betreffVorlage)}`
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
</View>
<ScrollView>
{/* Header */}
<View className="bg-white px-4 py-6 border-b border-gray-100">
<View className="bg-brand-50 rounded-xl w-16 h-16 items-center justify-center mb-4">
<Text className="text-3xl">🎓</Text>
</View>
<Text className="text-2xl font-bold text-gray-900">{stelle.member.betrieb}</Text>
<Text className="text-gray-500 mt-1">{stelle.member.ort}</Text>
<Text className="text-gray-500">{stelle.org.name}</Text>
</View>
{/* Details */}
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
<DetailRow label="Sparte" value={stelle.sparte} />
<DetailRow label="Anzahl Stellen" value={String(stelle.stellenAnz)} />
{stelle.lehrjahr && <DetailRow label="Lehrjahr" value={stelle.lehrjahr} />}
{stelle.verguetung && <DetailRow label="Vergütung" value={stelle.verguetung} />}
</View>
{stelle.beschreibung && (
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
<Text className="font-semibold text-gray-900 mb-2">Über die Stelle</Text>
<Text className="text-gray-600 leading-6">{stelle.beschreibung}</Text>
</View>
)}
{/* CTA */}
<View className="mx-4 mt-6">
<TouchableOpacity
onPress={() => Linking.openURL(bewerbungsUrl)}
className="bg-brand-500 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-white text-xl"></Text>
<Text className="text-white font-semibold text-base">Jetzt bewerben</Text>
</TouchableOpacity>
{stelle.kontaktName && (
<Text className="text-center text-sm text-gray-400 mt-3">
Ansprechperson: {stelle.kontaktName}
</Text>
)}
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<View className="flex-row items-center px-4 py-3 border-b border-gray-50 last:border-0">
<Text className="text-sm text-gray-500 w-32">{label}</Text>
<Text className="text-sm text-gray-900 font-medium flex-1">{value}</Text>
</View>
)
}

View File

@@ -0,0 +1,74 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { StelleCard } from '@/components/stellen/StelleCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
import { useAuth } from '@/hooks/useAuth'
export default function StellenScreen() {
const router = useRouter()
const { isAuthenticated } = useAuth()
const { data, isLoading, refetch, isRefetching } = trpc.stellen.listPublic.useQuery({})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 pt-3 pb-3 border-b border-gray-100">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-xl font-bold text-gray-900">Lehrlingsbörse</Text>
<Text className="text-sm text-gray-500 mt-0.5">
{data?.length ?? 0} Angebote
</Text>
</View>
{isAuthenticated && (
<TouchableOpacity
onPress={() => router.push('/(app)/stellen/neu')}
className="bg-brand-500 px-4 py-2 rounded-xl"
>
<Text className="text-white text-sm font-medium">+ Stelle anbieten</Text>
</TouchableOpacity>
)}
</View>
</View>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<StelleCard
stelle={item}
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="🎓"
title="Keine Angebote"
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,152 @@
import {
View,
Text,
ScrollView,
TouchableOpacity,
Linking,
ActivityIndicator,
Alert,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { AnmeldeButton } from '@/components/termine/AnmeldeButton'
import { Badge } from '@/components/ui/Badge'
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import * as Calendar from 'expo-calendar'
export default function TerminDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const router = useRouter()
const { data: termin, isLoading } = trpc.termine.byId.useQuery({ id })
const toggleMutation = trpc.termine.toggleAnmeldung.useMutation({
onSuccess: () => {
// Invalidate queries
},
})
async function addToCalendar() {
if (!termin) return
const { status } = await Calendar.requestCalendarPermissionsAsync()
if (status !== 'granted') {
Alert.alert('Keine Berechtigung', 'Bitte erlauben Sie den Kalender-Zugriff in den Einstellungen.')
return
}
const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT)
const defaultCal = calendars.find((c) => c.isPrimary) ?? calendars[0]
if (!defaultCal) {
Alert.alert('Kein Kalender', 'Es wurde kein Kalender gefunden.')
return
}
const startDate = new Date(termin.datum)
if (termin.uhrzeit) {
const [h, m] = termin.uhrzeit.split(':').map(Number)
startDate.setHours(h, m)
}
await Calendar.createEventAsync(defaultCal.id, {
title: termin.titel,
startDate,
endDate: startDate,
location: termin.adresse ?? termin.ort ?? undefined,
notes: termin.beschreibung ?? undefined,
})
Alert.alert('Termin gespeichert', 'Der Termin wurde in Ihren Kalender eingetragen.')
}
if (isLoading) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#E63946" />
</SafeAreaView>
)
}
if (!termin) return null
const datumFormatted = format(new Date(termin.datum), 'EEEE, dd. MMMM yyyy', { locale: de })
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-100">
<TouchableOpacity onPress={() => router.back()} className="mr-3">
<Text className="text-brand-500 text-base"> Zurück</Text>
</TouchableOpacity>
</View>
<ScrollView>
<View className="bg-white px-4 py-6 border-b border-gray-100">
<Badge label={TERMIN_TYP_LABELS[termin.typ]} typ={termin.typ} />
<Text className="text-2xl font-bold text-gray-900 mt-3">{termin.titel}</Text>
<Text className="text-gray-500 mt-2 capitalize">{datumFormatted}</Text>
{termin.uhrzeit && (
<Text className="text-gray-500">
{termin.uhrzeit}{termin.endeUhrzeit ? ` ${termin.endeUhrzeit}` : ''} Uhr
</Text>
)}
</View>
<View className="bg-white mx-4 mt-4 rounded-2xl overflow-hidden border border-gray-100">
{termin.ort && (
<TouchableOpacity
onPress={() =>
termin.adresse &&
Linking.openURL(
`https://maps.google.com/?q=${encodeURIComponent(termin.adresse)}`
)
}
className="flex-row items-center px-4 py-3 border-b border-gray-50"
>
<Text className="text-2xl mr-3">📍</Text>
<View>
<Text className="font-medium text-gray-900">{termin.ort}</Text>
{termin.adresse && (
<Text className="text-sm text-brand-500 mt-0.5">{termin.adresse}</Text>
)}
</View>
</TouchableOpacity>
)}
<View className="flex-row items-center px-4 py-3">
<Text className="text-2xl mr-3">👥</Text>
<Text className="text-gray-700">
{termin.teilnehmerAnzahl} Anmeldungen
{termin.maxTeilnehmer ? ` / ${termin.maxTeilnehmer} Plätze` : ''}
</Text>
</View>
</View>
{termin.beschreibung && (
<View className="bg-white mx-4 mt-4 rounded-2xl p-4 border border-gray-100">
<Text className="font-semibold text-gray-900 mb-2">Beschreibung</Text>
<Text className="text-gray-600 leading-6">{termin.beschreibung}</Text>
</View>
)}
{/* Actions */}
<View className="mx-4 mt-4 gap-3">
<AnmeldeButton
terminId={id}
isAngemeldet={termin.isAngemeldet}
onToggle={() => toggleMutation.mutate({ terminId: id })}
isLoading={toggleMutation.isPending}
/>
<TouchableOpacity
onPress={addToCalendar}
className="bg-white border border-gray-200 rounded-2xl py-4 flex-row items-center justify-center gap-2"
>
<Text className="text-gray-900 text-xl">📅</Text>
<Text className="text-gray-900 font-semibold">Zum Kalender hinzufügen</Text>
</TouchableOpacity>
</View>
<View className="h-8" />
</ScrollView>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,82 @@
import {
View,
Text,
FlatList,
TouchableOpacity,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { TerminCard } from '@/components/termine/TerminCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
export default function TermineScreen() {
const router = useRouter()
const [tab, setTab] = useState<'kommend' | 'vergangen'>('kommend')
const { data, isLoading, refetch, isRefetching } = trpc.termine.list.useQuery({
upcoming: tab === 'kommend',
})
return (
<SafeAreaView className="flex-1 bg-gray-50" edges={['top']}>
{/* Header */}
<View className="bg-white px-4 py-3 border-b border-gray-100">
<Text className="text-xl font-bold text-gray-900 mb-3">Termine</Text>
{/* Tabs */}
<View className="flex-row gap-1 bg-gray-100 p-1 rounded-xl">
{(['kommend', 'vergangen'] as const).map((t) => (
<TouchableOpacity
key={t}
onPress={() => setTab(t)}
className={`flex-1 py-2 rounded-lg items-center ${
tab === t ? 'bg-white shadow-sm' : ''
}`}
>
<Text
className={`text-sm font-medium ${
tab === t ? 'text-gray-900' : 'text-gray-500'
}`}
>
{t === 'kommend' ? 'Bevorstehend' : 'Vergangen'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<TerminCard
termin={item}
onPress={() => router.push(`/(app)/termine/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="📅"
title={tab === 'kommend' ? 'Keine Termine' : 'Keine vergangenen Termine'}
subtitle="Es sind aktuell keine Termine eingetragen"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,12 @@
import { Stack } from 'expo-router'
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
/>
)
}

View File

@@ -0,0 +1,44 @@
import { View, Text, TouchableOpacity } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function CheckEmailScreen() {
const router = useRouter()
const { email } = useLocalSearchParams<{ email: string }>()
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 justify-center items-center px-6">
{/* Envelope Illustration */}
<View className="w-24 h-24 bg-brand-50 rounded-full items-center justify-center mb-6">
<Text className="text-5xl">📧</Text>
</View>
<Text className="text-2xl font-bold text-gray-900 text-center mb-3">
Schau in dein Postfach
</Text>
<Text className="text-gray-500 text-center leading-6 mb-2">
Wir haben einen Login-Link an
</Text>
<Text className="font-semibold text-gray-900 text-center mb-6">
{email}
</Text>
<Text className="text-gray-500 text-center leading-6">
Klicken Sie auf den Link in der E-Mail, um sich einzuloggen.
Der Link ist 24 Stunden gültig.
</Text>
<View className="mt-10 space-y-3 w-full">
<TouchableOpacity
onPress={() => router.back()}
className="py-3 items-center"
>
<Text className="text-brand-500 font-medium">
Andere E-Mail verwenden
</Text>
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,111 @@
import {
View,
Text,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native'
import { useState } from 'react'
import { useRouter } from 'expo-router'
import { authClient } from '@/lib/auth-client'
import { SafeAreaView } from 'react-native-safe-area-context'
export default function LoginScreen() {
const router = useRouter()
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSendLink() {
if (!email.trim()) return
setLoading(true)
setError('')
const result = await authClient.signIn.magicLink({
email: email.trim().toLowerCase(),
callbackURL: '/news',
})
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
} else {
router.push({ pathname: '/(auth)/check-email', params: { email } })
}
}
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<View className="flex-1 justify-center px-6">
{/* Logo */}
<View className="items-center mb-10">
<View className="w-20 h-20 bg-brand-500 rounded-2xl items-center justify-center mb-4">
<Text className="text-white font-bold text-3xl">I</Text>
</View>
<Text className="text-2xl font-bold text-gray-900">InnungsApp</Text>
<Text className="text-gray-500 mt-1 text-center">
Die digitale Plattform Ihrer Innung
</Text>
</View>
{/* Form */}
<View className="space-y-4">
<View>
<Text className="text-sm font-medium text-gray-700 mb-2">
E-Mail-Adresse
</Text>
<TextInput
className="border border-gray-300 rounded-xl px-4 py-3.5 text-base text-gray-900 bg-gray-50"
placeholder="ihre@email.de"
placeholderTextColor="#9ca3af"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
value={email}
onChangeText={setEmail}
onSubmitEditing={handleSendLink}
returnKeyType="go"
/>
</View>
{error ? (
<View className="bg-red-50 rounded-xl px-4 py-3">
<Text className="text-red-600 text-sm">{error}</Text>
</View>
) : null}
<TouchableOpacity
onPress={handleSendLink}
disabled={loading || !email.trim()}
className={`rounded-xl py-4 items-center ${
loading || !email.trim() ? 'bg-gray-200' : 'bg-brand-500'
}`}
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<Text
className={`font-semibold text-base ${
loading || !email.trim() ? 'text-gray-400' : 'text-white'
}`}
>
Magic Link senden
</Text>
)}
</TouchableOpacity>
<Text className="text-center text-sm text-gray-400">
Kein Passwort nötig Zugang nur per Admin-Einladung
</Text>
</View>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
)
}

View File

@@ -0,0 +1,37 @@
import '../global.css'
import { useEffect } from 'react'
import { Stack } from 'expo-router'
import { SplashScreen } from 'expo-router'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/trpc'
import { TRPCProvider } from '@/lib/trpc'
import { useAuthStore } from '@/store/auth.store'
import { setupPushNotifications } from '@/lib/notifications'
SplashScreen.preventAutoHideAsync()
export default function RootLayout() {
const initAuth = useAuthStore((s) => s.initialize)
const isInitialized = useAuthStore((s) => s.isInitialized)
useEffect(() => {
initAuth().finally(() => SplashScreen.hideAsync())
}, [initAuth])
useEffect(() => {
setupPushNotifications().catch(console.error)
}, [])
if (!isInitialized) return null
return (
<TRPCProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" options={{ animation: 'fade' }} />
<Stack.Screen name="(app)" options={{ animation: 'fade' }} />
<Stack.Screen name="stellen-public" />
</Stack>
</TRPCProvider>
)
}

View File

@@ -0,0 +1,7 @@
import { Redirect } from 'expo-router'
import { useAuthStore } from '@/store/auth.store'
export default function Index() {
const session = useAuthStore((s) => s.session)
return <Redirect href={session ? '/(app)/news' : '/(auth)/login'} />
}

View File

@@ -0,0 +1,62 @@
/**
* Public Lehrlingsbörse — accessible without login
* Can be embedded in a WebView or shared as a link
*/
import {
View,
Text,
FlatList,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { trpc } from '@/lib/trpc'
import { StelleCard } from '@/components/stellen/StelleCard'
import { EmptyState } from '@/components/ui/EmptyState'
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
export default function StellenPublicScreen() {
const router = useRouter()
const { data, isLoading, refetch, isRefetching } = trpc.stellen.listPublic.useQuery({})
return (
<SafeAreaView className="flex-1 bg-gray-50">
<View className="bg-brand-500 px-4 py-4">
<Text className="text-white text-xl font-bold">Lehrlingsbörse</Text>
<Text className="text-white/80 text-sm mt-0.5">
Aktuelle Ausbildungsplätze
</Text>
</View>
{isLoading ? (
<LoadingSpinner />
) : (
<FlatList
data={data ?? []}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 12, gap: 8 }}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor="#E63946"
/>
}
renderItem={({ item }) => (
<StelleCard
stelle={item}
onPress={() => router.push(`/(app)/stellen/${item.id}`)}
/>
)}
ListEmptyComponent={
<EmptyState
icon="🎓"
title="Keine Angebote"
subtitle="Aktuell sind keine Ausbildungsplätze verfügbar"
/>
}
/>
)}
</SafeAreaView>
)
}

View File

@@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true)
return {
presets: [
['babel-preset-expo', { jsxImportSource: 'nativewind' }],
'nativewind/babel',
],
plugins: ['react-native-reanimated/plugin'],
}
}

View File

@@ -0,0 +1,48 @@
import { TouchableOpacity, Text, ActivityIndicator } from 'react-native'
interface ButtonProps {
label: string
onPress: () => void
variant?: 'primary' | 'secondary' | 'ghost'
loading?: boolean
disabled?: boolean
icon?: string
}
export function Button({
label,
onPress,
variant = 'primary',
loading,
disabled,
icon,
}: ButtonProps) {
const base = 'rounded-2xl py-4 flex-row items-center justify-center gap-2'
const variants = {
primary: `${base} bg-brand-500`,
secondary: `${base} bg-white border border-gray-200`,
ghost: `${base}`,
}
const textVariants = {
primary: 'text-white font-semibold text-base',
secondary: 'text-gray-900 font-semibold text-base',
ghost: 'text-brand-500 font-semibold text-base',
}
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled || loading}
className={`${variants[variant]} ${disabled || loading ? 'opacity-50' : ''}`}
>
{loading ? (
<ActivityIndicator color={variant === 'primary' ? 'white' : '#E63946'} />
) : (
<>
{icon && <Text className="text-xl">{icon}</Text>}
<Text className={textVariants[variant]}>{label}</Text>
</>
)}
</TouchableOpacity>
)
}

View File

@@ -0,0 +1,26 @@
import { View, TouchableOpacity } from 'react-native'
interface CardProps {
children: React.ReactNode
onPress?: () => void
className?: string
}
export function Card({ children, onPress, className = '' }: CardProps) {
if (onPress) {
return (
<TouchableOpacity
onPress={onPress}
className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}
activeOpacity={0.7}
>
{children}
</TouchableOpacity>
)
}
return (
<View className={`bg-white rounded-2xl border border-gray-100 p-4 ${className}`}>
{children}
</View>
)
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,21 @@
import { useAuthStore } from '@/store/auth.store'
import { useRouter } from 'expo-router'
export function useAuth() {
const { session, orgId, role, signOut } = useAuthStore()
const router = useRouter()
async function handleSignOut() {
await signOut()
router.replace('/(auth)/login')
}
return {
session,
orgId,
role,
isAuthenticated: !!session,
isAdmin: role === 'admin',
signOut: handleSignOut,
}
}

View File

@@ -0,0 +1,19 @@
import { trpc } from '@/lib/trpc'
import { useMembersFilterStore } from '@/store/members.store'
export function useMembersList() {
const search = useMembersFilterStore((s) => s.search)
const nurAusbildungsbetriebe = useMembersFilterStore(
(s) => s.nurAusbildungsbetriebe
)
return trpc.members.list.useQuery({
search: search || undefined,
ausbildungsbetrieb: nurAusbildungsbetriebe || undefined,
status: 'aktiv',
})
}
export function useMemberDetail(id: string) {
return trpc.members.byId.useQuery({ id })
}

View File

@@ -0,0 +1,22 @@
import { trpc } from '@/lib/trpc'
import { useNewsReadStore } from '@/store/news.store'
export function useNewsList(kategorie?: string) {
return trpc.news.list.useQuery({
kategorie: kategorie as never,
})
}
export function useNewsDetail(id: string) {
const markRead = useNewsReadStore((s) => s.markRead)
const markReadMutation = trpc.news.markRead.useMutation()
const query = trpc.news.byId.useQuery({ id })
function onOpen() {
markRead(id)
markReadMutation.mutate({ newsId: id })
}
return { ...query, onOpen }
}

View File

@@ -0,0 +1,12 @@
import { trpc } from '@/lib/trpc'
export function useStellenListe(opts?: { sparte?: string; lehrjahr?: string }) {
return trpc.stellen.listPublic.useQuery({
sparte: opts?.sparte,
lehrjahr: opts?.lehrjahr,
})
}
export function useStelleDetail(id: string) {
return trpc.stellen.byId.useQuery({ id })
}

View File

@@ -0,0 +1,19 @@
import { trpc } from '@/lib/trpc'
export function useTermineListe(upcoming = true) {
return trpc.termine.list.useQuery({ upcoming })
}
export function useTerminDetail(id: string) {
return trpc.termine.byId.useQuery({ id })
}
export function useToggleAnmeldung() {
const utils = trpc.useUtils()
return trpc.termine.toggleAnmeldung.useMutation({
onSuccess: () => {
utils.termine.list.invalidate()
utils.termine.byId.invalidate()
},
})
}

View File

@@ -0,0 +1,13 @@
import { createAuthClient } from 'better-auth/react'
import { magicLinkClient } from 'better-auth/client/plugins'
import Constants from 'expo-constants'
const apiUrl =
Constants.expoConfig?.extra?.apiUrl ??
process.env.EXPO_PUBLIC_API_URL ??
'http://localhost:3000'
export const authClient = createAuthClient({
baseURL: apiUrl,
plugins: [magicLinkClient()],
})

View File

@@ -0,0 +1,43 @@
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { trpc } from './trpc'
import { queryClient } from './trpc'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
})
export async function setupPushNotifications() {
if (Platform.OS === 'web') return
const { status: existingStatus } = await Notifications.getPermissionsAsync()
let finalStatus = existingStatus
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') return
const token = await Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
})
// Store push token on the server
// We call the tRPC mutation to save the token
const caller = trpc.createClient as never
// Simple fetch to avoid circular deps:
const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
await fetch(`${apiUrl}/api/push-token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.data }),
}).catch(() => {
// Silently fail — push is optional
})
}

View File

@@ -0,0 +1,44 @@
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchLink } from '@trpc/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import superjson from 'superjson'
import { createElement, type ReactNode } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AppRouter } from '@innungsapp/admin'
export const trpc = createTRPCReact<AppRouter>()
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
})
function getApiUrl() {
return process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000'
}
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: `${getApiUrl()}/api/trpc`,
transformer: superjson,
async headers() {
// Include session cookie for auth
const token = await AsyncStorage.getItem('better-auth-session')
return token ? { cookie: `better-auth.session_token=${token}` } : {}
},
}),
],
})
export function TRPCProvider({ children }: { children: ReactNode }) {
return createElement(
trpc.Provider,
{ client: trpcClient, queryClient },
createElement(QueryClientProvider, { client: queryClient }, children)
)
}

View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require('expo/metro-config')
const { withNativeWind } = require('nativewind/metro')
const config = getDefaultConfig(__dirname)
module.exports = withNativeWind(config, { input: './global.css' })

View File

@@ -0,0 +1,52 @@
{
"name": "@innungsapp/mobile",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"lint": "expo lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^2.1.0",
"@tanstack/react-query": "^5.59.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"better-auth": "^1.2.0",
"expo": "~52.0.0",
"expo-calendar": "~13.0.0",
"expo-constants": "~17.0.0",
"expo-dev-client": "~5.0.0",
"expo-document-picker": "~13.0.0",
"expo-font": "~13.0.0",
"expo-haptics": "~14.0.0",
"expo-linking": "~7.0.0",
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
"expo-splash-screen": "~0.29.0",
"expo-status-bar": "~2.0.0",
"expo-system-ui": "~4.0.0",
"expo-web-browser": "~14.0.0",
"nativewind": "^4.1.0",
"react": "18.3.1",
"react-native": "0.76.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.0.0",
"react-native-reanimated": "~3.16.0",
"react-native-gesture-handler": "~2.21.0",
"superjson": "^2.2.1",
"zustand": "^5.0.0",
"date-fns": "^3.6.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@babel/core": "^7.25.0",
"@types/react": "~18.3.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
}
}

View File

@@ -0,0 +1,58 @@
import { create } from 'zustand'
import { authClient } from '@/lib/auth-client'
import AsyncStorage from '@react-native-async-storage/async-storage'
interface Session {
user: {
id: string
email: string
name: string
}
token?: string
}
interface AuthState {
session: Session | null
orgId: string | null
role: 'admin' | 'member' | null
isInitialized: boolean
initialize: () => Promise<void>
setSession: (session: Session | null) => void
signOut: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set) => ({
session: null,
orgId: null,
role: null,
isInitialized: false,
initialize: async () => {
try {
const { data } = await authClient.getSession()
if (data?.session && data?.user) {
set({
session: { user: data.user },
isInitialized: true,
})
// Store token for API requests
if (data.session.token) {
await AsyncStorage.setItem('better-auth-session', data.session.token)
}
} else {
await AsyncStorage.removeItem('better-auth-session')
set({ session: null, orgId: null, role: null, isInitialized: true })
}
} catch {
set({ session: null, isInitialized: true })
}
},
setSession: (session) => set({ session }),
signOut: async () => {
await authClient.signOut()
await AsyncStorage.removeItem('better-auth-session')
set({ session: null, orgId: null, role: null })
},
}))

View File

@@ -0,0 +1,18 @@
import { create } from 'zustand'
interface MembersFilterState {
search: string
nurAusbildungsbetriebe: boolean
setSearch: (s: string) => void
setNurAusbildungsbetriebe: (b: boolean) => void
reset: () => void
}
export const useMembersFilterStore = create<MembersFilterState>((set) => ({
search: '',
nurAusbildungsbetriebe: false,
setSearch: (search) => set({ search }),
setNurAusbildungsbetriebe: (nurAusbildungsbetriebe) =>
set({ nurAusbildungsbetriebe }),
reset: () => set({ search: '', nurAusbildungsbetriebe: false }),
}))

View File

@@ -0,0 +1,12 @@
import { create } from 'zustand'
interface NewsReadState {
readIds: Set<string>
markRead: (newsId: string) => void
}
export const useNewsReadStore = create<NewsReadState>((set) => ({
readIds: new Set(),
markRead: (newsId) =>
set((s) => ({ readIds: new Set([...s.readIds, newsId]) })),
}))

View File

@@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
brand: {
50: '#fff1f1',
100: '#ffe1e1',
400: '#ff6b6b',
500: '#E63946',
600: '#d42535',
700: '#b21e2c',
},
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,11 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.d.ts", "expo-env.d.ts"]
}