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:
36
innungsapp/.env.example
Normal file
36
innungsapp/.env.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# =============================================
|
||||
# DATABASE
|
||||
# =============================================
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/innungsapp"
|
||||
|
||||
# =============================================
|
||||
# BETTER-AUTH
|
||||
# =============================================
|
||||
BETTER_AUTH_SECRET="change-me-to-a-random-32-char-string"
|
||||
BETTER_AUTH_URL="http://localhost:3000"
|
||||
|
||||
# =============================================
|
||||
# EMAIL (SMTP for magic links & invitations)
|
||||
# =============================================
|
||||
EMAIL_FROM="noreply@innungsapp.de"
|
||||
SMTP_HOST="smtp.example.com"
|
||||
SMTP_PORT="587"
|
||||
SMTP_SECURE="false"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
|
||||
# =============================================
|
||||
# ADMIN APP (Next.js)
|
||||
# =============================================
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# =============================================
|
||||
# MOBILE APP (Expo)
|
||||
# =============================================
|
||||
EXPO_PUBLIC_API_URL="http://localhost:3000"
|
||||
|
||||
# =============================================
|
||||
# FILE UPLOADS
|
||||
# =============================================
|
||||
UPLOAD_DIR="./uploads"
|
||||
UPLOAD_MAX_SIZE_MB="10"
|
||||
39
innungsapp/.gitignore
vendored
Normal file
39
innungsapp/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
dist
|
||||
build
|
||||
out
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.staging
|
||||
|
||||
# Uploads (local file storage)
|
||||
apps/admin/uploads/
|
||||
|
||||
# Prisma
|
||||
packages/shared/prisma/migrations/
|
||||
|
||||
# Expo
|
||||
apps/mobile/.expo
|
||||
apps/mobile/android
|
||||
apps/mobile/ios
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
4
innungsapp/apps/admin/app/api/auth/[...all]/route.ts
Normal file
4
innungsapp/apps/admin/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from '@/lib/auth'
|
||||
import { toNextJsHandler } from 'better-auth/next-js'
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth)
|
||||
23
innungsapp/apps/admin/app/api/trpc/[trpc]/route.ts
Normal file
23
innungsapp/apps/admin/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||
import { appRouter } from '@/server/routers'
|
||||
import { createContext } from '@/server/context'
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => createContext({ req, resHeaders: new Headers(), info: {} as never }),
|
||||
onError:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`[tRPC] Error on ${path ?? '<no-path>'}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
54
innungsapp/apps/admin/app/api/upload/route.ts
Normal file
54
innungsapp/apps/admin/app/api/upload/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
|
||||
const MAX_SIZE_BYTES = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 10) * 1024 * 1024
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
const session = await auth.api.getSession({ headers: req.headers })
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
return NextResponse.json({ error: 'File too large' }, { status: 413 })
|
||||
}
|
||||
|
||||
// Only allow safe file types
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'File type not allowed' }, { status: 415 })
|
||||
}
|
||||
|
||||
const ext = path.extname(file.name)
|
||||
const fileName = `${randomUUID()}${ext}`
|
||||
const uploadPath = path.join(process.cwd(), UPLOAD_DIR)
|
||||
|
||||
await mkdir(uploadPath, { recursive: true })
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
await writeFile(path.join(uploadPath, fileName), buffer)
|
||||
|
||||
return NextResponse.json({
|
||||
storagePath: fileName,
|
||||
name: file.name,
|
||||
sizeBytes: file.size,
|
||||
url: `/uploads/${fileName}`,
|
||||
})
|
||||
}
|
||||
41
innungsapp/apps/admin/app/api/uploads/[...path]/route.ts
Normal file
41
innungsapp/apps/admin/app/api/uploads/[...path]/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { readFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads'
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { path: string[] } }
|
||||
) {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), UPLOAD_DIR, ...params.path)
|
||||
|
||||
// Security: prevent path traversal
|
||||
const resolved = path.resolve(filePath)
|
||||
const uploadDir = path.resolve(path.join(process.cwd(), UPLOAD_DIR))
|
||||
if (!resolved.startsWith(uploadDir)) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const file = await readFile(resolved)
|
||||
const ext = path.extname(resolved).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
return new NextResponse(file, {
|
||||
headers: {
|
||||
'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return new NextResponse('Not Found', { status: 404 })
|
||||
}
|
||||
}
|
||||
115
innungsapp/apps/admin/app/dashboard/einstellungen/page.tsx
Normal file
115
innungsapp/apps/admin/app/dashboard/einstellungen/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function EinstellungenPage() {
|
||||
const { data: org, isLoading } = trpc.organizations.me.useQuery()
|
||||
const updateMutation = trpc.organizations.update.useMutation()
|
||||
const avvMutation = trpc.organizations.acceptAvv.useMutation()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
|
||||
if (isLoading) return <div className="text-gray-500">Wird geladen...</div>
|
||||
if (!org) return null
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Einstellungen</h1>
|
||||
|
||||
{/* Org Settings */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Innung</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name der Innung</label>
|
||||
<input
|
||||
defaultValue={org.name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
defaultValue={org.contactEmail ?? ''}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ name: name || undefined, contactEmail: contactEmail || undefined })}
|
||||
disabled={updateMutation.isPending}
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
{updateMutation.isSuccess && (
|
||||
<p className="text-sm text-green-600">Einstellungen gespeichert ✓</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AVV */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Auftragsverarbeitungsvertrag (AVV)</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Der AVV regelt die Verarbeitung personenbezogener Daten im Auftrag Ihrer Innung
|
||||
durch InnungsApp GmbH gemäß Art. 28 DSGVO.
|
||||
</p>
|
||||
<a
|
||||
href="/avv.pdf"
|
||||
download
|
||||
className="inline-flex items-center gap-2 text-sm text-brand-600 hover:underline"
|
||||
>
|
||||
📄 AVV als PDF herunterladen
|
||||
</a>
|
||||
{org.avvAccepted ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-700 font-medium">
|
||||
✓ AVV akzeptiert am {org.avvAcceptedAt?.toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm text-yellow-800 font-medium">
|
||||
⚠️ Der AVV muss vor dem Go-Live akzeptiert werden.
|
||||
</p>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="avv-check"
|
||||
className="mt-0.5 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Ich bestätige, dass ich den AVV gelesen habe und im Namen der Innung akzeptiere.
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
const cb = document.getElementById('avv-check') as HTMLInputElement
|
||||
if (!cb.checked) { alert('Bitte bestätigen Sie den AVV.'); return }
|
||||
avvMutation.mutate()
|
||||
}}
|
||||
disabled={avvMutation.isPending}
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
AVV verbindlich akzeptieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan Info */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<h2 className="font-semibold text-gray-900 mb-2">Plan</h2>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-brand-100 text-brand-700 capitalize">
|
||||
{org.plan}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Für Upgrades oder Fragen zum Plan: kontakt@innungsapp.de
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
innungsapp/apps/admin/app/dashboard/layout.tsx
Normal file
18
innungsapp/apps/admin/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
187
innungsapp/apps/admin/app/dashboard/mitglieder/neu/page.tsx
Normal file
187
innungsapp/apps/admin/app/dashboard/mitglieder/neu/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import Link from 'next/link'
|
||||
import { SPARTEN } from '@innungsapp/shared'
|
||||
|
||||
export default function MitgliedNeuPage() {
|
||||
const router = useRouter()
|
||||
const [sendInvite, setSendInvite] = useState(true)
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
betrieb: '',
|
||||
sparte: 'Elektrotechnik',
|
||||
ort: '',
|
||||
telefon: '',
|
||||
email: '',
|
||||
status: 'aktiv' as const,
|
||||
istAusbildungsbetrieb: false,
|
||||
seit: new Date().getFullYear(),
|
||||
})
|
||||
|
||||
const createMutation = trpc.members.create.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/mitglieder'),
|
||||
})
|
||||
const inviteMutation = trpc.members.invite.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/mitglieder'),
|
||||
})
|
||||
|
||||
const isPending = createMutation.isPending || inviteMutation.isPending
|
||||
const error = createMutation.error ?? inviteMutation.error
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (sendInvite) {
|
||||
inviteMutation.mutate(form)
|
||||
} else {
|
||||
createMutation.mutate(form)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/dashboard/mitglieder" className="text-gray-400 hover:text-gray-600">
|
||||
← Zurück
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mitglied anlegen</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb *</label>
|
||||
<input
|
||||
required
|
||||
value={form.betrieb}
|
||||
onChange={(e) => setForm({ ...form, betrieb: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
|
||||
<select
|
||||
value={form.sparte}
|
||||
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{SPARTEN.map((s) => <option key={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort *</label>
|
||||
<input
|
||||
required
|
||||
value={form.ort}
|
||||
onChange={(e) => setForm({ ...form, ort: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail *</label>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={form.telefon}
|
||||
onChange={(e) => setForm({ ...form, telefon: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied seit</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.seit}
|
||||
onChange={(e) => setForm({ ...form, seit: Number(e.target.value) })}
|
||||
min="1900"
|
||||
max="2100"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value as typeof form.status })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="ruhend">Ruhend</option>
|
||||
<option value="ausgetreten">Ausgetreten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.istAusbildungsbetrieb}
|
||||
onChange={(e) => setForm({ ...form, istAusbildungsbetrieb: e.target.checked })}
|
||||
className="rounded border-gray-300 text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Ausbildungsbetrieb</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendInvite}
|
||||
onChange={(e) => setSendInvite(e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-500 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 font-medium">
|
||||
Einladungs-E-Mail senden
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
Das Mitglied erhält eine E-Mail mit einem Login-Link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{isPending ? 'Wird gespeichert...' : sendInvite ? 'Speichern & Einladung senden' : 'Speichern'}
|
||||
</button>
|
||||
<Link
|
||||
href="/dashboard/mitglieder"
|
||||
className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
innungsapp/apps/admin/app/dashboard/mitglieder/page.tsx
Normal file
150
innungsapp/apps/admin/app/dashboard/mitglieder/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
aktiv: 'bg-green-100 text-green-700',
|
||||
ruhend: 'bg-yellow-100 text-yellow-700',
|
||||
ausgetreten: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
export default async function MitgliederPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { q?: string; status?: string }
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
})
|
||||
if (!userRole || userRole.role !== 'admin') redirect('/dashboard')
|
||||
|
||||
const search = searchParams.q ?? ''
|
||||
const statusFilter = searchParams.status
|
||||
|
||||
const members = await prisma.member.findMany({
|
||||
where: {
|
||||
orgId: userRole.orgId,
|
||||
...(statusFilter && { status: statusFilter as never }),
|
||||
...(search && {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ betrieb: { contains: search, mode: 'insensitive' } },
|
||||
{ ort: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mitglieder</h1>
|
||||
<p className="text-gray-500 mt-1">{members.length} Einträge</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/mitglieder/neu"
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
+ Mitglied anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-4 flex gap-4">
|
||||
<form className="flex gap-4 w-full">
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={search}
|
||||
placeholder="Name, Betrieb, Ort suchen..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={statusFilter ?? ''}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="ruhend">Ruhend</option>
|
||||
<option value="ausgetreten">Ausgetreten</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg text-sm hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Betrieb</th>
|
||||
<th>Sparte</th>
|
||||
<th>Ort</th>
|
||||
<th>Mitglied seit</th>
|
||||
<th>Status</th>
|
||||
<th>Eingeladen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{m.name}</p>
|
||||
<p className="text-xs text-gray-500">{m.betrieb}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td>{m.sparte}</td>
|
||||
<td>{m.ort}</td>
|
||||
<td>{m.seit ?? '—'}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[m.status]}`}
|
||||
>
|
||||
{MEMBER_STATUS_LABELS[m.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{m.userId ? (
|
||||
<span className="text-xs text-green-600">✓ Aktiv</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Nicht eingeladen</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/dashboard/mitglieder/${m.id}`}
|
||||
className="text-sm text-brand-600 hover:underline"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{members.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Mitglieder gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
158
innungsapp/apps/admin/app/dashboard/news/neu/page.tsx
Normal file
158
innungsapp/apps/admin/app/dashboard/news/neu/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false })
|
||||
|
||||
const KATEGORIEN = [
|
||||
{ value: 'Wichtig', label: 'Wichtig' },
|
||||
{ value: 'Pruefung', label: 'Prüfung' },
|
||||
{ value: 'Foerderung', label: 'Förderung' },
|
||||
{ value: 'Veranstaltung', label: 'Veranstaltung' },
|
||||
{ value: 'Allgemein', label: 'Allgemein' },
|
||||
]
|
||||
|
||||
export default function NewsNeuPage() {
|
||||
const router = useRouter()
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('## Inhalt\n\nHier können Sie Ihren Beitrag verfassen.')
|
||||
const [kategorie, setKategorie] = useState('Allgemein')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [attachments, setAttachments] = useState<
|
||||
Array<{ name: string; storagePath: string; sizeBytes: number; url: string }>
|
||||
>([])
|
||||
|
||||
const createMutation = trpc.news.create.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/news'),
|
||||
})
|
||||
|
||||
function handleSubmit(publishNow: boolean) {
|
||||
if (!title.trim() || !body.trim()) return
|
||||
createMutation.mutate({
|
||||
title,
|
||||
body,
|
||||
kategorie: kategorie as never,
|
||||
publishedAt: publishNow ? new Date().toISOString() : null,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
try {
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
const data = await res.json()
|
||||
setAttachments((prev) => [...prev, data])
|
||||
} catch {
|
||||
alert('Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/dashboard/news" className="text-gray-400 hover:text-gray-600">
|
||||
← Zurück
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Beitrag erstellen</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||
<input
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Aussagekräftiger Titel..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={kategorie}
|
||||
onChange={(e) => setKategorie(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
>
|
||||
{KATEGORIEN.map((k) => (
|
||||
<option key={k.value} value={k.value}>{k.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Inhalt *</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={body}
|
||||
onChange={(v) => setBody(v ?? '')}
|
||||
height={400}
|
||||
preview="live"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anhänge (PDF)</label>
|
||||
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 border border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-brand-500 hover:text-brand-500 transition-colors">
|
||||
{uploading ? '⏳ Hochladen...' : '📎 Datei anhängen'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{attachments.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{attachments.map((a, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span>📄</span>
|
||||
<span>{a.name}</span>
|
||||
<span className="text-gray-400">({Math.round(a.sizeBytes / 1024)} KB)</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2 border-t">
|
||||
<button
|
||||
onClick={() => handleSubmit(true)}
|
||||
disabled={createMutation.isPending}
|
||||
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
Jetzt publizieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit(false)}
|
||||
disabled={createMutation.isPending}
|
||||
className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 border hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Als Entwurf speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
innungsapp/apps/admin/app/dashboard/news/page.tsx
Normal file
123
innungsapp/apps/admin/app/dashboard/news/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const KATEGORIE_COLORS: Record<string, string> = {
|
||||
Wichtig: 'bg-red-100 text-red-700',
|
||||
Pruefung: 'bg-blue-100 text-blue-700',
|
||||
Foerderung: 'bg-green-100 text-green-700',
|
||||
Veranstaltung: 'bg-purple-100 text-purple-700',
|
||||
Allgemein: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
export default async function NewsPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) redirect('/dashboard')
|
||||
|
||||
const news = await prisma.news.findMany({
|
||||
where: { orgId: userRole.orgId },
|
||||
include: { author: { select: { name: true } } },
|
||||
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
const published = news.filter((n) => n.publishedAt)
|
||||
const drafts = news.filter((n) => !n.publishedAt)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">News</h1>
|
||||
<p className="text-gray-500 mt-1">{published.length} publiziert · {drafts.length} Entwürfe</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/news/neu"
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
+ Beitrag erstellen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{drafts.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Entwürfe
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<tbody>
|
||||
{drafts.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td className="w-full">
|
||||
<p className="font-medium text-gray-900">{n.title}</p>
|
||||
<p className="text-xs text-gray-400">Erstellt {format(n.createdAt, 'dd. MMM yyyy', { locale: de })}</p>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Publiziert
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Autor</th>
|
||||
<th>Datum</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{published.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td className="font-medium text-gray-900">{n.title}</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${KATEGORIE_COLORS[n.kategorie]}`}>
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-gray-500">{n.author?.name ?? '—'}</td>
|
||||
<td className="text-gray-500">
|
||||
{n.publishedAt ? format(n.publishedAt, 'dd.MM.yyyy', { locale: de }) : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/news/${n.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
innungsapp/apps/admin/app/dashboard/page.tsx
Normal file
125
innungsapp/apps/admin/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatsCards } from '@/components/stats/StatsCards'
|
||||
import Link from 'next/link'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { NEWS_KATEGORIE_LABELS, TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id },
|
||||
include: { org: true },
|
||||
})
|
||||
if (!userRole) redirect('/login')
|
||||
|
||||
const orgId = userRole.orgId
|
||||
const now = new Date()
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen, recentNews, nextTermine] =
|
||||
await Promise.all([
|
||||
prisma.member.count({ where: { orgId, status: 'aktiv' } }),
|
||||
prisma.news.count({ where: { orgId, publishedAt: { gte: weekAgo, not: null } } }),
|
||||
prisma.termin.count({ where: { orgId, datum: { gte: now } } }),
|
||||
prisma.stelle.count({ where: { orgId, aktiv: true } }),
|
||||
prisma.news.findMany({
|
||||
where: { orgId, publishedAt: { not: null } },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 5,
|
||||
include: { author: { select: { name: true } } },
|
||||
}),
|
||||
prisma.termin.findMany({
|
||||
where: { orgId, datum: { gte: now } },
|
||||
orderBy: { datum: 'asc' },
|
||||
take: 3,
|
||||
}),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Übersicht</h1>
|
||||
<p className="text-gray-500 mt-1">{userRole.org.name}</p>
|
||||
</div>
|
||||
|
||||
<StatsCards
|
||||
stats={[
|
||||
{ label: 'Aktive Mitglieder', value: activeMembers, icon: '👥' },
|
||||
{ label: 'News diese Woche', value: newsThisWeek, icon: '📰' },
|
||||
{ label: 'Bevorstehende Termine', value: upcomingTermine, icon: '📅' },
|
||||
{ label: 'Aktive Stellen', value: activeStellen, icon: '🎓' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent News */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Neueste Beiträge</h2>
|
||||
<Link href="/dashboard/news" className="text-sm text-brand-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentNews.map((n) => (
|
||||
<div key={n.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{n.title}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{n.publishedAt
|
||||
? format(n.publishedAt, 'dd. MMM yyyy', { locale: de })
|
||||
: 'Entwurf'}{' '}
|
||||
· {n.author?.name ?? 'Unbekannt'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{NEWS_KATEGORIE_LABELS[n.kategorie]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Termine */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-gray-900">Nächste Termine</h2>
|
||||
<Link href="/dashboard/termine" className="text-sm text-brand-600 hover:underline">
|
||||
Alle anzeigen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{nextTermine.length === 0 && (
|
||||
<p className="text-sm text-gray-500">Keine bevorstehenden Termine</p>
|
||||
)}
|
||||
{nextTermine.map((t) => (
|
||||
<div key={t.id} className="flex items-start gap-3 py-2 border-b last:border-0">
|
||||
<div className="text-center min-w-[40px]">
|
||||
<p className="text-lg font-bold text-brand-500 leading-none">
|
||||
{format(t.datum, 'dd', { locale: de })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 uppercase">
|
||||
{format(t.datum, 'MMM', { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm text-gray-900 truncate">{t.titel}</p>
|
||||
<p className="text-xs text-gray-500">{t.ort ?? 'Kein Ort angegeben'}</p>
|
||||
</div>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{TERMIN_TYP_LABELS[t.typ]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export function DeactivateButton({ id }: { id: string }) {
|
||||
const router = useRouter()
|
||||
const mutation = trpc.stellen.deactivate.useMutation({
|
||||
onSuccess: () => router.refresh(),
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => mutation.mutate({ id })}
|
||||
disabled={mutation.isPending}
|
||||
className="text-sm text-red-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Deaktivieren
|
||||
</button>
|
||||
)
|
||||
}
|
||||
78
innungsapp/apps/admin/app/dashboard/stellen/page.tsx
Normal file
78
innungsapp/apps/admin/app/dashboard/stellen/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { DeactivateButton } from './DeactivateButton'
|
||||
|
||||
export default async function StellenPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) redirect('/dashboard')
|
||||
|
||||
const stellen = await prisma.stelle.findMany({
|
||||
where: { orgId: userRole.orgId },
|
||||
include: { member: { select: { name: true, betrieb: true } } },
|
||||
orderBy: [{ aktiv: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Lehrlingsbörse</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
{stellen.filter((s) => s.aktiv).length} aktive Angebote
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Betrieb</th>
|
||||
<th>Sparte</th>
|
||||
<th>Stellen</th>
|
||||
<th>Lehrjahr</th>
|
||||
<th>Vergütung</th>
|
||||
<th>Eingestellt</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stellen.map((s) => (
|
||||
<tr key={s.id} className={!s.aktiv ? 'opacity-50' : ''}>
|
||||
<td>
|
||||
<p className="font-medium text-gray-900">{s.member.betrieb}</p>
|
||||
<p className="text-xs text-gray-500">{s.member.name}</p>
|
||||
</td>
|
||||
<td>{s.sparte}</td>
|
||||
<td className="text-center">{s.stellenAnz}</td>
|
||||
<td>{s.lehrjahr ?? '—'}</td>
|
||||
<td>{s.verguetung ?? '—'}</td>
|
||||
<td>{format(s.createdAt, 'dd.MM.yyyy', { locale: de })}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${s.aktiv ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}
|
||||
>
|
||||
{s.aktiv ? 'Aktiv' : 'Inaktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{s.aktiv && <DeactivateButton id={s.id} />}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{stellen.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Noch keine Stellenangebote</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
innungsapp/apps/admin/app/dashboard/termine/neu/page.tsx
Normal file
144
innungsapp/apps/admin/app/dashboard/termine/neu/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import Link from 'next/link'
|
||||
|
||||
const TYPEN = [
|
||||
{ value: 'Pruefung', label: 'Prüfung' },
|
||||
{ value: 'Versammlung', label: 'Versammlung' },
|
||||
{ value: 'Kurs', label: 'Kurs' },
|
||||
{ value: 'Event', label: 'Event' },
|
||||
{ value: 'Sonstiges', label: 'Sonstiges' },
|
||||
]
|
||||
|
||||
export default function TerminNeuPage() {
|
||||
const router = useRouter()
|
||||
const [form, setForm] = useState({
|
||||
titel: '',
|
||||
datum: '',
|
||||
uhrzeit: '',
|
||||
endeDatum: '',
|
||||
endeUhrzeit: '',
|
||||
ort: '',
|
||||
adresse: '',
|
||||
typ: 'Versammlung',
|
||||
beschreibung: '',
|
||||
maxTeilnehmer: '',
|
||||
})
|
||||
|
||||
const createMutation = trpc.termine.create.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/termine'),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
createMutation.mutate({
|
||||
titel: form.titel,
|
||||
datum: form.datum,
|
||||
uhrzeit: form.uhrzeit || undefined,
|
||||
endeDatum: form.endeDatum || undefined,
|
||||
endeUhrzeit: form.endeUhrzeit || undefined,
|
||||
ort: form.ort || undefined,
|
||||
adresse: form.adresse || undefined,
|
||||
typ: form.typ as never,
|
||||
beschreibung: form.beschreibung || undefined,
|
||||
maxTeilnehmer: form.maxTeilnehmer ? Number(form.maxTeilnehmer) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const F = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||
setForm({ ...form, [field]: e.target.value })
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500'
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/dashboard/termine" className="text-gray-400 hover:text-gray-600">
|
||||
← Zurück
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Termin anlegen</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
||||
<input required value={form.titel} onChange={F('titel')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
|
||||
<select value={form.typ} onChange={F('typ')} className={inputClass}>
|
||||
{TYPEN.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max. Teilnehmer</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.maxTeilnehmer}
|
||||
onChange={F('maxTeilnehmer')}
|
||||
placeholder="Leer = unbegrenzt"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datum *</label>
|
||||
<input required type="date" value={form.datum} onChange={F('datum')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Uhrzeit (von)</label>
|
||||
<input type="time" value={form.uhrzeit} onChange={F('uhrzeit')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Datum</label>
|
||||
<input type="date" value={form.endeDatum} onChange={F('endeDatum')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ende Uhrzeit</label>
|
||||
<input type="time" value={form.endeUhrzeit} onChange={F('endeUhrzeit')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
||||
<input value={form.ort} onChange={F('ort')} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input value={form.adresse} onChange={F('adresse')} className={inputClass} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={form.beschreibung}
|
||||
onChange={F('beschreibung')}
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{createMutation.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="bg-brand-500 text-white px-6 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{createMutation.isPending ? 'Wird gespeichert...' : 'Termin anlegen'}
|
||||
</button>
|
||||
<Link href="/dashboard/termine" className="px-6 py-2 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
innungsapp/apps/admin/app/dashboard/termine/page.tsx
Normal file
124
innungsapp/apps/admin/app/dashboard/termine/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { TERMIN_TYP_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const TYP_COLORS: Record<string, string> = {
|
||||
Pruefung: 'bg-blue-100 text-blue-700',
|
||||
Versammlung: 'bg-purple-100 text-purple-700',
|
||||
Kurs: 'bg-green-100 text-green-700',
|
||||
Event: 'bg-yellow-100 text-yellow-700',
|
||||
Sonstiges: 'bg-gray-100 text-gray-700',
|
||||
}
|
||||
|
||||
export default async function TerminePage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
const userRole = await prisma.userRole.findFirst({
|
||||
where: { userId: session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) redirect('/dashboard')
|
||||
|
||||
const now = new Date()
|
||||
const [upcoming, past] = await Promise.all([
|
||||
prisma.termin.findMany({
|
||||
where: { orgId: userRole.orgId, datum: { gte: now } },
|
||||
include: { anmeldungen: { select: { id: true } } },
|
||||
orderBy: { datum: 'asc' },
|
||||
}),
|
||||
prisma.termin.findMany({
|
||||
where: { orgId: userRole.orgId, datum: { lt: now } },
|
||||
include: { anmeldungen: { select: { id: true } } },
|
||||
orderBy: { datum: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
])
|
||||
|
||||
const TerminRow = ({ t }: { t: typeof upcoming[0] }) => (
|
||||
<tr>
|
||||
<td>
|
||||
<div className="text-center w-10">
|
||||
<p className="font-bold text-brand-500">{format(t.datum, 'dd', { locale: de })}</p>
|
||||
<p className="text-xs text-gray-400 uppercase">{format(t.datum, 'MMM', { locale: de })}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<p className="font-medium text-gray-900">{t.titel}</p>
|
||||
{t.ort && <p className="text-xs text-gray-500">{t.ort}</p>}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${TYP_COLORS[t.typ]}`}>
|
||||
{TERMIN_TYP_LABELS[t.typ]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{t.maxTeilnehmer
|
||||
? `${t.anmeldungen.length} / ${t.maxTeilnehmer}`
|
||||
: t.anmeldungen.length}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/dashboard/termine/${t.id}`} className="text-sm text-brand-600 hover:underline">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Termine</h1>
|
||||
<Link
|
||||
href="/dashboard/termine/neu"
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
+ Termin anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Bevorstehend ({upcoming.length})
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Titel</th>
|
||||
<th>Typ</th>
|
||||
<th>Anmeldungen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{upcoming.map((t) => <TerminRow key={t.id} t={t} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
{upcoming.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine bevorstehenden Termine</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{past.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
Vergangen
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden opacity-70">
|
||||
<table className="w-full data-table">
|
||||
<tbody>
|
||||
{past.map((t) => <TerminRow key={t.id} t={t} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
innungsapp/apps/admin/app/globals.css
Normal file
47
innungsapp/apps/admin/app/globals.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--brand: #E63946;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-200;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.sidebar-link {
|
||||
@apply flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900;
|
||||
}
|
||||
|
||||
.sidebar-link-active {
|
||||
@apply bg-brand-50 text-brand-600;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@apply rounded-xl border bg-white p-6 shadow-sm;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
@apply bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
@apply px-4 py-3 text-sm text-gray-700;
|
||||
}
|
||||
|
||||
.data-table tr {
|
||||
@apply border-b border-gray-100 last:border-0;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
}
|
||||
25
innungsapp/apps/admin/app/layout.tsx
Normal file
25
innungsapp/apps/admin/app/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'InnungsApp Admin',
|
||||
description: 'Verwaltungsportal für Innungen',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
118
innungsapp/apps/admin/app/login/page.tsx
Normal file
118
innungsapp/apps/admin/app/login/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { magicLinkClient } from 'better-auth/client/plugins'
|
||||
|
||||
const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
|
||||
plugins: [magicLinkClient()],
|
||||
})
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const result = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: '/dashboard',
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? 'Ein Fehler ist aufgetreten.')
|
||||
} else {
|
||||
setSent(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-brand-500 rounded-2xl mb-4">
|
||||
<span className="text-white font-bold text-2xl">I</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">InnungsApp Admin</h1>
|
||||
<p className="text-gray-500 mt-1">Verwaltungsportal für Innungen</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border p-8">
|
||||
{sent ? (
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
E-Mail gesendet!
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
Wir haben einen Login-Link an <strong>{email}</strong> gesendet.
|
||||
Bitte überprüfen Sie Ihr Postfach.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSent(false)}
|
||||
className="mt-6 text-brand-600 text-sm hover:underline"
|
||||
>
|
||||
Andere E-Mail verwenden
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-6">
|
||||
Anmelden
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@ihre-innung.de"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-brand-500 text-white py-2.5 px-4 rounded-lg font-medium hover:bg-brand-600 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Wird gesendet...' : 'Magic Link senden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-gray-500">
|
||||
Kein Passwort nötig — Sie erhalten einen Link per E-Mail.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
innungsapp/apps/admin/app/page.tsx
Normal file
5
innungsapp/apps/admin/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/dashboard')
|
||||
}
|
||||
44
innungsapp/apps/admin/app/providers.tsx
Normal file
44
innungsapp/apps/admin/app/providers.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { httpBatchLink } from '@trpc/client'
|
||||
import { useState } from 'react'
|
||||
import superjson from 'superjson'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== 'undefined') return ''
|
||||
if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL
|
||||
return 'http://localhost:3000'
|
||||
}
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const [trpcClient] = useState(() =>
|
||||
trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
transformer: superjson,
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
)
|
||||
}
|
||||
29
innungsapp/apps/admin/components/layout/Header.tsx
Normal file
29
innungsapp/apps/admin/components/layout/Header.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const authClient = createAuthClient()
|
||||
|
||||
export function Header() {
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSignOut() {
|
||||
await authClient.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
|
||||
<div />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
58
innungsapp/apps/admin/components/layout/Sidebar.tsx
Normal file
58
innungsapp/apps/admin/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Übersicht', icon: '🏠' },
|
||||
{ href: '/dashboard/mitglieder', label: 'Mitglieder', icon: '👥' },
|
||||
{ href: '/dashboard/news', label: 'News', icon: '📰' },
|
||||
{ href: '/dashboard/termine', label: 'Termine', icon: '📅' },
|
||||
{ href: '/dashboard/stellen', label: 'Lehrlingsbörse', icon: '🎓' },
|
||||
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: '⚙️' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-white border-r flex flex-col flex-shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">I</span>
|
||||
</div>
|
||||
<span className="font-bold text-gray-900">InnungsApp</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/dashboard'
|
||||
? pathname === '/dashboard'
|
||||
: pathname.startsWith(item.href)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={clsx('sidebar-link', isActive && 'sidebar-link-active')}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t">
|
||||
<p className="text-xs text-gray-400">InnungsApp v0.1.0</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
19
innungsapp/apps/admin/components/stats/StatsCards.tsx
Normal file
19
innungsapp/apps/admin/components/stats/StatsCards.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface Stat {
|
||||
label: string
|
||||
value: number
|
||||
icon: string
|
||||
}
|
||||
|
||||
export function StatsCards({ stats }: { stats: Stat[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="stat-card">
|
||||
<div className="text-2xl mb-2">{stat.icon}</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{stat.value}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
innungsapp/apps/admin/lib/auth.ts
Normal file
34
innungsapp/apps/admin/lib/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma'
|
||||
import { magicLink } from 'better-auth/plugins'
|
||||
import { admin as adminPlugin } from 'better-auth/plugins'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
import { sendMagicLinkEmail } from './email'
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: 'postgresql',
|
||||
}),
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL!,
|
||||
trustedOrigins: [
|
||||
process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000',
|
||||
process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000',
|
||||
],
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
await sendMagicLinkEmail({ to: email, magicUrl: url })
|
||||
},
|
||||
}),
|
||||
adminPlugin(),
|
||||
],
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5, // 5 minutes cache
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type Auth = typeof auth
|
||||
90
innungsapp/apps/admin/lib/email.ts
Normal file
90
innungsapp/apps/admin/lib/email.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth:
|
||||
process.env.SMTP_USER
|
||||
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
to,
|
||||
magicUrl,
|
||||
}: {
|
||||
to: string
|
||||
magicUrl: string
|
||||
}) {
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: 'Ihr Login-Link für InnungsApp',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
|
||||
</div>
|
||||
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #111827; margin-top: 0;">Ihr persönlicher Login-Link</h2>
|
||||
<p style="color: #4b5563;">Klicken Sie auf den folgenden Button, um sich einzuloggen. Der Link ist 24 Stunden gültig.</p>
|
||||
<a href="${magicUrl}"
|
||||
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Jetzt einloggen
|
||||
</a>
|
||||
<p style="color: #9ca3af; font-size: 14px;">
|
||||
Wenn Sie diesen Link nicht angefordert haben, können Sie diese E-Mail ignorieren.
|
||||
</p>
|
||||
<hr style="border-color: #e5e7eb; margin: 24px 0;" />
|
||||
<p style="color: #9ca3af; font-size: 12px; margin: 0;">
|
||||
InnungsApp · Die digitale Plattform für Innungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendInviteEmail({
|
||||
to,
|
||||
memberName,
|
||||
orgName,
|
||||
apiUrl,
|
||||
}: {
|
||||
to: string
|
||||
memberName: string
|
||||
orgName: string
|
||||
apiUrl: string
|
||||
}) {
|
||||
// Generate magic link for the invite
|
||||
const signInUrl = `${apiUrl}/login?email=${encodeURIComponent(to)}&invited=true`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM ?? 'noreply@innungsapp.de',
|
||||
to,
|
||||
subject: `Einladung zur InnungsApp — ${orgName}`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #E63946; padding: 24px; border-radius: 8px 8px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">InnungsApp</h1>
|
||||
</div>
|
||||
<div style="background: #fff; padding: 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<h2 style="color: #111827; margin-top: 0;">Hallo ${memberName},</h2>
|
||||
<p style="color: #4b5563;">
|
||||
Sie wurden von der <strong>${orgName}</strong> zur InnungsApp eingeladen.
|
||||
InnungsApp ist die digitale Plattform Ihrer Innung für News, Termine und das Mitgliederverzeichnis.
|
||||
</p>
|
||||
<p style="color: #4b5563;">Klicken Sie auf den Button, um Ihren Account zu aktivieren:</p>
|
||||
<a href="${signInUrl}"
|
||||
style="display: inline-block; background: #E63946; color: white; padding: 12px 24px;
|
||||
border-radius: 6px; text-decoration: none; font-weight: bold; margin: 16px 0;">
|
||||
Jetzt Zugang aktivieren
|
||||
</a>
|
||||
<p style="color: #9ca3af; font-size: 14px;">Kein Passwort nötig — Sie erhalten einen sicheren Login-Link per E-Mail.</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
45
innungsapp/apps/admin/lib/notifications.ts
Normal file
45
innungsapp/apps/admin/lib/notifications.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send'
|
||||
|
||||
/**
|
||||
* Send push notifications to all active members in an org who have a push token
|
||||
*/
|
||||
export async function sendPushNotifications(orgId: string, title: string, body?: string) {
|
||||
const members = await prisma.member.findMany({
|
||||
where: { orgId, status: 'aktiv', pushToken: { not: null } },
|
||||
select: { pushToken: true },
|
||||
})
|
||||
|
||||
const tokens = members
|
||||
.map((m) => m.pushToken)
|
||||
.filter((t): t is string => t !== null)
|
||||
|
||||
if (tokens.length === 0) return
|
||||
|
||||
// Expo Push API supports batches of up to 100
|
||||
const batches: string[][] = []
|
||||
for (let i = 0; i < tokens.length; i += 100) {
|
||||
batches.push(tokens.slice(i, i + 100))
|
||||
}
|
||||
|
||||
for (const batch of batches) {
|
||||
const messages = batch.map((token) => ({
|
||||
to: token,
|
||||
title,
|
||||
body: body ?? 'Neue Nachricht von Ihrer Innung',
|
||||
sound: 'default',
|
||||
data: { type: 'news' },
|
||||
}))
|
||||
|
||||
await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Accept-encoding': 'gzip, deflate',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(messages),
|
||||
})
|
||||
}
|
||||
}
|
||||
6
innungsapp/apps/admin/lib/trpc-client.ts
Normal file
6
innungsapp/apps/admin/lib/trpc-client.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createTRPCReact } from '@trpc/react-query'
|
||||
import type { AppRouter } from '@/server/routers'
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>()
|
||||
29
innungsapp/apps/admin/middleware.ts
Normal file
29
innungsapp/apps/admin/middleware.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/api/auth', '/api/trpc/stellen.listPublic']
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname
|
||||
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
||||
|
||||
if (isPublic) return NextResponse.next()
|
||||
|
||||
const sessionToken =
|
||||
request.cookies.get('better-auth.session_token') ??
|
||||
request.cookies.get('__Secure-better-auth.session_token')
|
||||
|
||||
if (!sessionToken) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
loginUrl.searchParams.set('callbackUrl', pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|uploads).*)',
|
||||
],
|
||||
}
|
||||
19
innungsapp/apps/admin/next.config.ts
Normal file
19
innungsapp/apps/admin/next.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@innungsapp/shared'],
|
||||
experimental: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
// Serve uploaded files
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/uploads/:path*',
|
||||
destination: '/api/uploads/:path*',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
43
innungsapp/apps/admin/package.json
Normal file
43
innungsapp/apps/admin/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@innungsapp/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@innungsapp/shared": "workspace:*",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"next": "^15.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"zod": "^3.23.0",
|
||||
"superjson": "^2.2.1",
|
||||
"nodemailer": "^6.9.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"@uiw/react-md-editor": "^4.0.4",
|
||||
"lucide-react": "^0.460.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "^15.0.0"
|
||||
}
|
||||
}
|
||||
6
innungsapp/apps/admin/postcss.config.js
Normal file
6
innungsapp/apps/admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
14
innungsapp/apps/admin/server/context.ts
Normal file
14
innungsapp/apps/admin/server/context.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { prisma } from '@innungsapp/shared'
|
||||
|
||||
export async function createContext({ req }: FetchCreateContextFnOptions) {
|
||||
const session = await auth.api.getSession({ headers: req.headers })
|
||||
return {
|
||||
req,
|
||||
session,
|
||||
prisma,
|
||||
}
|
||||
}
|
||||
|
||||
export type Context = Awaited<ReturnType<typeof createContext>>
|
||||
16
innungsapp/apps/admin/server/routers/index.ts
Normal file
16
innungsapp/apps/admin/server/routers/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { router } from '../trpc'
|
||||
import { membersRouter } from './members'
|
||||
import { newsRouter } from './news'
|
||||
import { termineRouter } from './termine'
|
||||
import { stellenRouter } from './stellen'
|
||||
import { organizationsRouter } from './organizations'
|
||||
|
||||
export const appRouter = router({
|
||||
members: membersRouter,
|
||||
news: newsRouter,
|
||||
termine: termineRouter,
|
||||
stellen: stellenRouter,
|
||||
organizations: organizationsRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
161
innungsapp/apps/admin/server/routers/members.ts
Normal file
161
innungsapp/apps/admin/server/routers/members.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { z } from 'zod'
|
||||
import { router, memberProcedure, adminProcedure } from '../trpc'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { sendInviteEmail } from '@/lib/email'
|
||||
|
||||
const MemberInput = z.object({
|
||||
name: z.string().min(2),
|
||||
betrieb: z.string().min(2),
|
||||
sparte: z.string().min(2),
|
||||
ort: z.string().min(2),
|
||||
telefon: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).default('aktiv'),
|
||||
istAusbildungsbetrieb: z.boolean().default(false),
|
||||
seit: z.number().int().min(1900).max(2100).optional(),
|
||||
})
|
||||
|
||||
export const membersRouter = router({
|
||||
/**
|
||||
* List all members in the user's org
|
||||
*/
|
||||
list: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
search: z.string().optional(),
|
||||
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).optional(),
|
||||
ausbildungsbetrieb: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const members = await ctx.prisma.member.findMany({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
...(input.status && { status: input.status }),
|
||||
...(input.ausbildungsbetrieb !== undefined && {
|
||||
istAusbildungsbetrieb: input.ausbildungsbetrieb,
|
||||
}),
|
||||
...(input.search && {
|
||||
OR: [
|
||||
{ name: { contains: input.search, mode: 'insensitive' } },
|
||||
{ betrieb: { contains: input.search, mode: 'insensitive' } },
|
||||
{ ort: { contains: input.search, mode: 'insensitive' } },
|
||||
{ sparte: { contains: input.search, mode: 'insensitive' } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
return members
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get a single member by ID
|
||||
*/
|
||||
byId: memberProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
})
|
||||
if (!member) throw new Error('Member not found')
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new member (admin only)
|
||||
*/
|
||||
create: adminProcedure.input(MemberInput).mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.create({
|
||||
data: {
|
||||
...input,
|
||||
orgId: ctx.orgId,
|
||||
},
|
||||
})
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create member + send invite email (admin only)
|
||||
*/
|
||||
invite: adminProcedure
|
||||
.input(MemberInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// 1. Create member record
|
||||
const member = await ctx.prisma.member.create({
|
||||
data: { ...input, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
// 2. Create/get User via better-auth admin
|
||||
try {
|
||||
await auth.api.createUser({
|
||||
body: {
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
role: 'user',
|
||||
password: undefined,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// User may already exist — that's ok
|
||||
}
|
||||
|
||||
// 3. Send magic link
|
||||
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
||||
where: { id: ctx.orgId },
|
||||
})
|
||||
await sendInviteEmail({
|
||||
to: input.email,
|
||||
memberName: input.name,
|
||||
orgName: org.name,
|
||||
apiUrl: process.env.BETTER_AUTH_URL!,
|
||||
})
|
||||
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update member (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), data: MemberInput.partial() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.updateMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
data: input.data,
|
||||
})
|
||||
return member
|
||||
}),
|
||||
|
||||
/**
|
||||
* Send/resend invite to existing member (admin only)
|
||||
*/
|
||||
resendInvite: adminProcedure
|
||||
.input(z.object({ memberId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirstOrThrow({
|
||||
where: { id: input.memberId, orgId: ctx.orgId },
|
||||
})
|
||||
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
||||
where: { id: ctx.orgId },
|
||||
})
|
||||
await sendInviteEmail({
|
||||
to: member.email,
|
||||
memberName: member.name,
|
||||
orgName: org.name,
|
||||
apiUrl: process.env.BETTER_AUTH_URL!,
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get own member profile
|
||||
*/
|
||||
me: memberProcedure.query(async ({ ctx }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
include: { org: true },
|
||||
})
|
||||
return member
|
||||
}),
|
||||
})
|
||||
170
innungsapp/apps/admin/server/routers/news.ts
Normal file
170
innungsapp/apps/admin/server/routers/news.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { z } from 'zod'
|
||||
import { router, memberProcedure, adminProcedure } from '../trpc'
|
||||
import { sendPushNotifications } from '@/lib/notifications'
|
||||
|
||||
const NewsInput = z.object({
|
||||
title: z.string().min(3),
|
||||
body: z.string().min(10),
|
||||
kategorie: z.enum(['Wichtig', 'Pruefung', 'Foerderung', 'Veranstaltung', 'Allgemein']),
|
||||
publishedAt: z.string().datetime().optional().nullable(),
|
||||
})
|
||||
|
||||
export const newsRouter = router({
|
||||
/**
|
||||
* List published news for org members
|
||||
*/
|
||||
list: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
kategorie: z
|
||||
.enum(['Wichtig', 'Pruefung', 'Foerderung', 'Veranstaltung', 'Allgemein'])
|
||||
.optional(),
|
||||
includeUnpublished: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const news = await ctx.prisma.news.findMany({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
...(input.kategorie && { kategorie: input.kategorie }),
|
||||
...(!input.includeUnpublished && { publishedAt: { not: null } }),
|
||||
...(input.includeUnpublished &&
|
||||
ctx.role !== 'admin' && { publishedAt: { not: null } }),
|
||||
},
|
||||
include: {
|
||||
author: { select: { name: true } },
|
||||
attachments: true,
|
||||
reads: {
|
||||
where: { userId: ctx.session.user.id },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ publishedAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})
|
||||
|
||||
return news.map((n) => ({
|
||||
...n,
|
||||
isRead: n.reads.length > 0,
|
||||
reads: undefined,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get single news article
|
||||
*/
|
||||
byId: memberProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const news = await ctx.prisma.news.findFirstOrThrow({
|
||||
where: {
|
||||
id: input.id,
|
||||
orgId: ctx.orgId,
|
||||
...(ctx.role !== 'admin' && { publishedAt: { not: null } }),
|
||||
},
|
||||
include: {
|
||||
author: { select: { name: true, betrieb: true } },
|
||||
attachments: true,
|
||||
},
|
||||
})
|
||||
return news
|
||||
}),
|
||||
|
||||
/**
|
||||
* Mark news as read
|
||||
*/
|
||||
markRead: memberProcedure
|
||||
.input(z.object({ newsId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.newsRead.upsert({
|
||||
where: {
|
||||
newsId_userId: { newsId: input.newsId, userId: ctx.session.user.id },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
newsId: input.newsId,
|
||||
userId: ctx.session.user.id,
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create news article (admin only)
|
||||
*/
|
||||
create: adminProcedure.input(NewsInput).mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
const news = await ctx.prisma.news.create({
|
||||
data: {
|
||||
orgId: ctx.orgId,
|
||||
authorId: member?.id,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
kategorie: input.kategorie,
|
||||
publishedAt: input.publishedAt ? new Date(input.publishedAt) : null,
|
||||
},
|
||||
})
|
||||
|
||||
// Trigger push notifications if publishing now
|
||||
if (news.publishedAt) {
|
||||
sendPushNotifications(ctx.orgId, news.title).catch(console.error)
|
||||
}
|
||||
|
||||
return news
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update news article (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), data: NewsInput.partial() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const wasUnpublished = await ctx.prisma.news.findFirst({
|
||||
where: { id: input.id, orgId: ctx.orgId, publishedAt: null },
|
||||
})
|
||||
|
||||
const news = await ctx.prisma.news.updateMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
data: {
|
||||
...input.data,
|
||||
publishedAt: input.data.publishedAt
|
||||
? new Date(input.data.publishedAt)
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
// Trigger push if just published
|
||||
if (wasUnpublished && input.data.publishedAt && input.data.title) {
|
||||
sendPushNotifications(ctx.orgId, input.data.title).catch(console.error)
|
||||
}
|
||||
|
||||
return news
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete news article (admin only)
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.news.deleteMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get read stats for admin
|
||||
*/
|
||||
readStats: adminProcedure
|
||||
.input(z.object({ newsId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [totalMembers, readers] = await Promise.all([
|
||||
ctx.prisma.member.count({ where: { orgId: ctx.orgId, status: 'aktiv' } }),
|
||||
ctx.prisma.newsRead.count({ where: { newsId: input.newsId } }),
|
||||
])
|
||||
return { totalMembers, readers, readRate: totalMembers ? readers / totalMembers : 0 }
|
||||
}),
|
||||
})
|
||||
89
innungsapp/apps/admin/server/routers/organizations.ts
Normal file
89
innungsapp/apps/admin/server/routers/organizations.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from 'zod'
|
||||
import { router, adminProcedure, publicProcedure } from '../trpc'
|
||||
|
||||
export const organizationsRouter = router({
|
||||
/**
|
||||
* Get own organization details
|
||||
*/
|
||||
me: adminProcedure.query(async ({ ctx }) => {
|
||||
const org = await ctx.prisma.organization.findUniqueOrThrow({
|
||||
where: { id: ctx.orgId },
|
||||
})
|
||||
return org
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update org settings (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(3).optional(),
|
||||
contactEmail: z.string().email().optional(),
|
||||
primaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const org = await ctx.prisma.organization.update({
|
||||
where: { id: ctx.orgId },
|
||||
data: input,
|
||||
})
|
||||
return org
|
||||
}),
|
||||
|
||||
/**
|
||||
* Accept AVV (Auftragsverarbeitungsvertrag)
|
||||
*/
|
||||
acceptAvv: adminProcedure.mutation(async ({ ctx }) => {
|
||||
const org = await ctx.prisma.organization.update({
|
||||
where: { id: ctx.orgId },
|
||||
data: {
|
||||
avvAccepted: true,
|
||||
avvAcceptedAt: new Date(),
|
||||
},
|
||||
})
|
||||
return org
|
||||
}),
|
||||
|
||||
/**
|
||||
* Dashboard stats
|
||||
*/
|
||||
stats: adminProcedure.query(async ({ ctx }) => {
|
||||
const now = new Date()
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [activeMembers, newsThisWeek, upcomingTermine, activeStellen] =
|
||||
await Promise.all([
|
||||
ctx.prisma.member.count({
|
||||
where: { orgId: ctx.orgId, status: 'aktiv' },
|
||||
}),
|
||||
ctx.prisma.news.count({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
publishedAt: { gte: weekAgo, not: null },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.termin.count({
|
||||
where: { orgId: ctx.orgId, datum: { gte: now } },
|
||||
}),
|
||||
ctx.prisma.stelle.count({
|
||||
where: { orgId: ctx.orgId, aktiv: true },
|
||||
}),
|
||||
])
|
||||
|
||||
return { activeMembers, newsThisWeek, upcomingTermine, activeStellen }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Public org info by slug (for mobile onboarding)
|
||||
*/
|
||||
bySlug: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const org = await ctx.prisma.organization.findUnique({
|
||||
where: { slug: input.slug },
|
||||
select: { id: true, name: true, slug: true, primaryColor: true, logoUrl: true },
|
||||
})
|
||||
return org
|
||||
}),
|
||||
})
|
||||
143
innungsapp/apps/admin/server/routers/stellen.ts
Normal file
143
innungsapp/apps/admin/server/routers/stellen.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { z } from 'zod'
|
||||
import { router, publicProcedure, memberProcedure, adminProcedure } from '../trpc'
|
||||
|
||||
const StelleInput = z.object({
|
||||
sparte: z.string().min(2),
|
||||
stellenAnz: z.number().int().min(1).default(1),
|
||||
verguetung: z.string().optional(),
|
||||
lehrjahr: z.string().optional(),
|
||||
beschreibung: z.string().optional(),
|
||||
kontaktEmail: z.string().email(),
|
||||
kontaktName: z.string().optional(),
|
||||
})
|
||||
|
||||
export const stellenRouter = router({
|
||||
/**
|
||||
* Public list — no auth required (Lehrlingsbörse)
|
||||
*/
|
||||
listPublic: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
sparte: z.string().optional(),
|
||||
lehrjahr: z.string().optional(),
|
||||
orgSlug: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stellen = await ctx.prisma.stelle.findMany({
|
||||
where: {
|
||||
aktiv: true,
|
||||
...(input.sparte && { sparte: input.sparte }),
|
||||
...(input.lehrjahr && { lehrjahr: input.lehrjahr }),
|
||||
...(input.orgSlug && {
|
||||
org: { slug: input.orgSlug },
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
member: { select: { betrieb: true, ort: true } },
|
||||
org: { select: { name: true, slug: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return stellen
|
||||
}),
|
||||
|
||||
/**
|
||||
* List stellen for org (authenticated members)
|
||||
*/
|
||||
list: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
includeInaktiv: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stellen = await ctx.prisma.stelle.findMany({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
...(ctx.role !== 'admin' && !input.includeInaktiv && { aktiv: true }),
|
||||
...(ctx.role === 'admin' && !input.includeInaktiv && { aktiv: true }),
|
||||
},
|
||||
include: {
|
||||
member: { select: { name: true, betrieb: true, ort: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return stellen
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get single Stelle (public)
|
||||
*/
|
||||
byId: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const stelle = await ctx.prisma.stelle.findFirstOrThrow({
|
||||
where: { id: input.id, aktiv: true },
|
||||
include: {
|
||||
member: { select: { betrieb: true, ort: true } },
|
||||
org: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
return stelle
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create Stelle (own member only)
|
||||
*/
|
||||
create: memberProcedure
|
||||
.input(StelleInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirstOrThrow({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
const stelle = await ctx.prisma.stelle.create({
|
||||
data: {
|
||||
orgId: ctx.orgId,
|
||||
memberId: member.id,
|
||||
...input,
|
||||
},
|
||||
})
|
||||
return stelle
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update own Stelle or admin update any
|
||||
*/
|
||||
update: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
data: StelleInput.partial().extend({ aktiv: z.boolean().optional() }),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
await ctx.prisma.stelle.updateMany({
|
||||
where: {
|
||||
id: input.id,
|
||||
orgId: ctx.orgId,
|
||||
// Admin can update any, member only their own
|
||||
...(ctx.role !== 'admin' && member ? { memberId: member.id } : {}),
|
||||
},
|
||||
data: input.data,
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Deactivate Stelle (admin moderation)
|
||||
*/
|
||||
deactivate: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.stelle.updateMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
data: { aktiv: false },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
180
innungsapp/apps/admin/server/routers/termine.ts
Normal file
180
innungsapp/apps/admin/server/routers/termine.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { z } from 'zod'
|
||||
import { router, memberProcedure, adminProcedure } from '../trpc'
|
||||
|
||||
const TerminInput = z.object({
|
||||
titel: z.string().min(3),
|
||||
datum: z.string(), // ISO date string "YYYY-MM-DD"
|
||||
uhrzeit: z.string().optional(),
|
||||
endeDatum: z.string().optional().nullable(),
|
||||
endeUhrzeit: z.string().optional().nullable(),
|
||||
ort: z.string().optional(),
|
||||
adresse: z.string().optional(),
|
||||
typ: z.enum(['Pruefung', 'Versammlung', 'Kurs', 'Event', 'Sonstiges']),
|
||||
beschreibung: z.string().optional(),
|
||||
maxTeilnehmer: z.number().int().positive().optional().nullable(),
|
||||
})
|
||||
|
||||
export const termineRouter = router({
|
||||
/**
|
||||
* List all termine for org
|
||||
*/
|
||||
list: memberProcedure
|
||||
.input(
|
||||
z.object({
|
||||
upcoming: z.boolean().optional(),
|
||||
nurAngemeldet: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const termine = await ctx.prisma.termin.findMany({
|
||||
where: {
|
||||
orgId: ctx.orgId,
|
||||
...(input.upcoming && { datum: { gte: today } }),
|
||||
...(!input.upcoming &&
|
||||
input.upcoming !== undefined && { datum: { lt: today } }),
|
||||
...(input.nurAngemeldet &&
|
||||
member && {
|
||||
anmeldungen: { some: { memberId: member.id } },
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
anmeldungen: {
|
||||
select: { memberId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { datum: input.upcoming ? 'asc' : 'desc' },
|
||||
})
|
||||
|
||||
return termine.map((t) => ({
|
||||
...t,
|
||||
isAngemeldet: member
|
||||
? t.anmeldungen.some((a) => a.memberId === member.id)
|
||||
: false,
|
||||
teilnehmerAnzahl: t.anmeldungen.length,
|
||||
anmeldungen: undefined,
|
||||
}))
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get single Termin
|
||||
*/
|
||||
byId: memberProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirst({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
const termin = await ctx.prisma.termin.findFirstOrThrow({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
include: {
|
||||
anmeldungen: {
|
||||
include: {
|
||||
member: { select: { name: true, betrieb: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const isAngemeldet = member
|
||||
? termin.anmeldungen.some((a) => a.memberId === member.id)
|
||||
: false
|
||||
|
||||
return {
|
||||
...termin,
|
||||
isAngemeldet,
|
||||
teilnehmerAnzahl: termin.anmeldungen.length,
|
||||
// Only expose member list to admins
|
||||
anmeldungen: ctx.role === 'admin' ? termin.anmeldungen : [],
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Anmelden / Abmelden
|
||||
*/
|
||||
toggleAnmeldung: memberProcedure
|
||||
.input(z.object({ terminId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const member = await ctx.prisma.member.findFirstOrThrow({
|
||||
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
|
||||
})
|
||||
|
||||
const termin = await ctx.prisma.termin.findFirstOrThrow({
|
||||
where: { id: input.terminId, orgId: ctx.orgId },
|
||||
include: { anmeldungen: true },
|
||||
})
|
||||
|
||||
const existing = termin.anmeldungen.find(
|
||||
(a) => a.memberId === member.id
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
// Abmelden
|
||||
await ctx.prisma.terminAnmeldung.delete({ where: { id: existing.id } })
|
||||
return { angemeldet: false }
|
||||
} else {
|
||||
// Check capacity
|
||||
if (
|
||||
termin.maxTeilnehmer &&
|
||||
termin.anmeldungen.length >= termin.maxTeilnehmer
|
||||
) {
|
||||
throw new Error('Maximale Teilnehmerzahl erreicht')
|
||||
}
|
||||
await ctx.prisma.terminAnmeldung.create({
|
||||
data: { terminId: input.terminId, memberId: member.id },
|
||||
})
|
||||
return { angemeldet: true }
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create Termin (admin only)
|
||||
*/
|
||||
create: adminProcedure.input(TerminInput).mutation(async ({ ctx, input }) => {
|
||||
const termin = await ctx.prisma.termin.create({
|
||||
data: {
|
||||
orgId: ctx.orgId,
|
||||
...input,
|
||||
datum: new Date(input.datum),
|
||||
endeDatum: input.endeDatum ? new Date(input.endeDatum) : null,
|
||||
},
|
||||
})
|
||||
return termin
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update Termin (admin only)
|
||||
*/
|
||||
update: adminProcedure
|
||||
.input(z.object({ id: z.string(), data: TerminInput.partial() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.termin.updateMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
data: {
|
||||
...input.data,
|
||||
...(input.data.datum && { datum: new Date(input.data.datum) }),
|
||||
...(input.data.endeDatum && { endeDatum: new Date(input.data.endeDatum) }),
|
||||
},
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete Termin (admin only)
|
||||
*/
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.termin.deleteMany({
|
||||
where: { id: input.id, orgId: ctx.orgId },
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
82
innungsapp/apps/admin/server/trpc.ts
Normal file
82
innungsapp/apps/admin/server/trpc.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { initTRPC, TRPCError } from '@trpc/server'
|
||||
import superjson from 'superjson'
|
||||
import { ZodError } from 'zod'
|
||||
import { type Context } from './context'
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
zodError:
|
||||
error.cause instanceof ZodError ? error.cause.flatten() : null,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const router = t.router
|
||||
export const publicProcedure = t.procedure
|
||||
export const createCallerFactory = t.createCallerFactory
|
||||
|
||||
/**
|
||||
* Protected: user must be authenticated
|
||||
*/
|
||||
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' })
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
session: { ...ctx.session, user: ctx.session.user },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Member: user must belong to an organization
|
||||
* Adds orgId + role to context
|
||||
*/
|
||||
export const memberProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const userRole = await ctx.prisma.userRole.findFirst({
|
||||
where: { userId: ctx.session.user.id },
|
||||
})
|
||||
if (!userRole) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You are not a member of any organization.',
|
||||
})
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
orgId: userRole.orgId,
|
||||
role: userRole.role,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Admin: user must be an admin of their organization
|
||||
*/
|
||||
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
||||
const userRole = await ctx.prisma.userRole.findFirst({
|
||||
where: { userId: ctx.session.user.id, role: 'admin' },
|
||||
})
|
||||
if (!userRole) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Admin access required.',
|
||||
})
|
||||
}
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
orgId: userRole.orgId,
|
||||
role: 'admin' as const,
|
||||
},
|
||||
})
|
||||
})
|
||||
33
innungsapp/apps/admin/tailwind.config.ts
Normal file
33
innungsapp/apps/admin/tailwind.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#fff1f1',
|
||||
100: '#ffe1e1',
|
||||
200: '#ffC7c7',
|
||||
300: '#ffa0a0',
|
||||
400: '#ff6b6b',
|
||||
500: '#E63946',
|
||||
600: '#d42535',
|
||||
700: '#b21e2c',
|
||||
800: '#931d29',
|
||||
900: '#7a1e27',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
23
innungsapp/apps/admin/tsconfig.json
Normal file
23
innungsapp/apps/admin/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
59
innungsapp/apps/mobile/app.json
Normal file
59
innungsapp/apps/mobile/app.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
78
innungsapp/apps/mobile/app/(app)/_layout.tsx
Normal file
78
innungsapp/apps/mobile/app/(app)/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
innungsapp/apps/mobile/app/(app)/members/[id].tsx
Normal file
107
innungsapp/apps/mobile/app/(app)/members/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
101
innungsapp/apps/mobile/app/(app)/members/index.tsx
Normal file
101
innungsapp/apps/mobile/app/(app)/members/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
innungsapp/apps/mobile/app/(app)/news/[id].tsx
Normal file
87
innungsapp/apps/mobile/app/(app)/news/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
innungsapp/apps/mobile/app/(app)/news/index.tsx
Normal file
100
innungsapp/apps/mobile/app/(app)/news/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
innungsapp/apps/mobile/app/(app)/profil/index.tsx
Normal file
97
innungsapp/apps/mobile/app/(app)/profil/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
innungsapp/apps/mobile/app/(app)/stellen/[id].tsx
Normal file
94
innungsapp/apps/mobile/app/(app)/stellen/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
innungsapp/apps/mobile/app/(app)/stellen/index.tsx
Normal file
74
innungsapp/apps/mobile/app/(app)/stellen/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
innungsapp/apps/mobile/app/(app)/termine/[id].tsx
Normal file
152
innungsapp/apps/mobile/app/(app)/termine/[id].tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
innungsapp/apps/mobile/app/(app)/termine/index.tsx
Normal file
82
innungsapp/apps/mobile/app/(app)/termine/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
innungsapp/apps/mobile/app/(auth)/_layout.tsx
Normal file
12
innungsapp/apps/mobile/app/(auth)/_layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Stack } from 'expo-router'
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
44
innungsapp/apps/mobile/app/(auth)/check-email.tsx
Normal file
44
innungsapp/apps/mobile/app/(auth)/check-email.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
innungsapp/apps/mobile/app/(auth)/login.tsx
Normal file
111
innungsapp/apps/mobile/app/(auth)/login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
innungsapp/apps/mobile/app/_layout.tsx
Normal file
37
innungsapp/apps/mobile/app/_layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
innungsapp/apps/mobile/app/index.tsx
Normal file
7
innungsapp/apps/mobile/app/index.tsx
Normal 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'} />
|
||||
}
|
||||
62
innungsapp/apps/mobile/app/stellen-public/index.tsx
Normal file
62
innungsapp/apps/mobile/app/stellen-public/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
innungsapp/apps/mobile/babel.config.js
Normal file
10
innungsapp/apps/mobile/babel.config.js
Normal 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'],
|
||||
}
|
||||
}
|
||||
48
innungsapp/apps/mobile/components/ui/Button.tsx
Normal file
48
innungsapp/apps/mobile/components/ui/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
innungsapp/apps/mobile/components/ui/Card.tsx
Normal file
26
innungsapp/apps/mobile/components/ui/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
innungsapp/apps/mobile/global.css
Normal file
3
innungsapp/apps/mobile/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
21
innungsapp/apps/mobile/hooks/useAuth.ts
Normal file
21
innungsapp/apps/mobile/hooks/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
19
innungsapp/apps/mobile/hooks/useMembers.ts
Normal file
19
innungsapp/apps/mobile/hooks/useMembers.ts
Normal 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 })
|
||||
}
|
||||
22
innungsapp/apps/mobile/hooks/useNews.ts
Normal file
22
innungsapp/apps/mobile/hooks/useNews.ts
Normal 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 }
|
||||
}
|
||||
12
innungsapp/apps/mobile/hooks/useStellen.ts
Normal file
12
innungsapp/apps/mobile/hooks/useStellen.ts
Normal 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 })
|
||||
}
|
||||
19
innungsapp/apps/mobile/hooks/useTermine.ts
Normal file
19
innungsapp/apps/mobile/hooks/useTermine.ts
Normal 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()
|
||||
},
|
||||
})
|
||||
}
|
||||
13
innungsapp/apps/mobile/lib/auth-client.ts
Normal file
13
innungsapp/apps/mobile/lib/auth-client.ts
Normal 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()],
|
||||
})
|
||||
43
innungsapp/apps/mobile/lib/notifications.ts
Normal file
43
innungsapp/apps/mobile/lib/notifications.ts
Normal 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
|
||||
})
|
||||
}
|
||||
44
innungsapp/apps/mobile/lib/trpc.ts
Normal file
44
innungsapp/apps/mobile/lib/trpc.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
6
innungsapp/apps/mobile/metro.config.js
Normal file
6
innungsapp/apps/mobile/metro.config.js
Normal 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' })
|
||||
52
innungsapp/apps/mobile/package.json
Normal file
52
innungsapp/apps/mobile/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
58
innungsapp/apps/mobile/store/auth.store.ts
Normal file
58
innungsapp/apps/mobile/store/auth.store.ts
Normal 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 })
|
||||
},
|
||||
}))
|
||||
18
innungsapp/apps/mobile/store/members.store.ts
Normal file
18
innungsapp/apps/mobile/store/members.store.ts
Normal 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 }),
|
||||
}))
|
||||
12
innungsapp/apps/mobile/store/news.store.ts
Normal file
12
innungsapp/apps/mobile/store/news.store.ts
Normal 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]) })),
|
||||
}))
|
||||
20
innungsapp/apps/mobile/tailwind.config.js
Normal file
20
innungsapp/apps/mobile/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
11
innungsapp/apps/mobile/tsconfig.json
Normal file
11
innungsapp/apps/mobile/tsconfig.json
Normal 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"]
|
||||
}
|
||||
25
innungsapp/package.json
Normal file
25
innungsapp/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "innungsapp",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"type-check": "turbo type-check",
|
||||
"db:generate": "pnpm --filter @innungsapp/shared prisma generate",
|
||||
"db:migrate": "pnpm --filter @innungsapp/shared prisma migrate dev",
|
||||
"db:push": "pnpm --filter @innungsapp/shared prisma db push",
|
||||
"db:studio": "pnpm --filter @innungsapp/shared prisma studio",
|
||||
"db:seed": "pnpm --filter @innungsapp/shared tsx prisma/seed.ts",
|
||||
"db:reset": "pnpm --filter @innungsapp/shared prisma migrate reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
}
|
||||
}
|
||||
26
innungsapp/packages/shared/package.json
Normal file
26
innungsapp/packages/shared/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@innungsapp/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./prisma": "./src/lib/prisma.ts",
|
||||
"./types": "./src/types/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:studio": "prisma studio",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^5.20.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
310
innungsapp/packages/shared/prisma/schema.prisma
Normal file
310
innungsapp/packages/shared/prisma/schema.prisma
Normal file
@@ -0,0 +1,310 @@
|
||||
// InnungsApp — Prisma Schema
|
||||
// Stack: PostgreSQL + Prisma ORM + better-auth
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// BETTER-AUTH TABLES
|
||||
// =============================================
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false) @map("email_verified")
|
||||
image String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// better-auth admin plugin fields
|
||||
role String?
|
||||
banned Boolean? @default(false)
|
||||
banReason String? @map("ban_reason")
|
||||
banExpires DateTime? @map("ban_expires")
|
||||
|
||||
// App relations
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
member Member?
|
||||
userRoles UserRole[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
expiresAt DateTime @map("expires_at")
|
||||
token String @unique
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("session")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id
|
||||
accountId String @map("account_id")
|
||||
providerId String @map("provider_id")
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
accessToken String? @map("access_token")
|
||||
refreshToken String? @map("refresh_token")
|
||||
idToken String? @map("id_token")
|
||||
accessTokenExpiresAt DateTime? @map("access_token_expires_at")
|
||||
refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")
|
||||
scope String?
|
||||
password String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("account")
|
||||
}
|
||||
|
||||
model Verification {
|
||||
id String @id
|
||||
identifier String
|
||||
value String
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime? @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
|
||||
@@map("verification")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// ORGANIZATIONS
|
||||
// =============================================
|
||||
|
||||
model Organization {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
plan Plan @default(pilot)
|
||||
logoUrl String? @map("logo_url")
|
||||
primaryColor String @default("#E63946") @map("primary_color")
|
||||
contactEmail String? @map("contact_email")
|
||||
avvAccepted Boolean @default(false) @map("avv_accepted")
|
||||
avvAcceptedAt DateTime? @map("avv_accepted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
members Member[]
|
||||
userRoles UserRole[]
|
||||
news News[]
|
||||
stellen Stelle[]
|
||||
termine Termin[]
|
||||
|
||||
@@map("organizations")
|
||||
}
|
||||
|
||||
enum Plan {
|
||||
pilot
|
||||
standard
|
||||
pro
|
||||
verband
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// MEMBERS
|
||||
// =============================================
|
||||
|
||||
model Member {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
userId String? @unique @map("user_id") // NULL until magic-link clicked
|
||||
name String
|
||||
betrieb String
|
||||
sparte String
|
||||
ort String
|
||||
telefon String?
|
||||
email String
|
||||
status MemberStatus @default(aktiv)
|
||||
istAusbildungsbetrieb Boolean @default(false) @map("ist_ausbildungsbetrieb")
|
||||
seit Int?
|
||||
avatarUrl String? @map("avatar_url")
|
||||
pushToken String? @map("push_token")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
newsAuthored News[] @relation("NewsAuthor")
|
||||
stellen Stelle[]
|
||||
terminAnmeldungen TerminAnmeldung[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([status])
|
||||
@@map("members")
|
||||
}
|
||||
|
||||
enum MemberStatus {
|
||||
aktiv
|
||||
ruhend
|
||||
ausgetreten
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// USER ROLES (multi-tenancy)
|
||||
// =============================================
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
userId String @map("user_id")
|
||||
role OrgRole
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([orgId, userId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
enum OrgRole {
|
||||
admin
|
||||
member
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// NEWS
|
||||
// =============================================
|
||||
|
||||
model News {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
authorId String? @map("author_id")
|
||||
title String
|
||||
body String // Markdown
|
||||
kategorie NewsKategorie
|
||||
publishedAt DateTime? @map("published_at") // NULL = Entwurf
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
author Member? @relation("NewsAuthor", fields: [authorId], references: [id], onDelete: SetNull)
|
||||
reads NewsRead[]
|
||||
attachments NewsAttachment[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([publishedAt])
|
||||
@@map("news")
|
||||
}
|
||||
|
||||
enum NewsKategorie {
|
||||
Wichtig
|
||||
Pruefung
|
||||
Foerderung
|
||||
Veranstaltung
|
||||
Allgemein
|
||||
}
|
||||
|
||||
model NewsRead {
|
||||
id String @id @default(uuid())
|
||||
newsId String @map("news_id")
|
||||
userId String @map("user_id")
|
||||
readAt DateTime @default(now()) @map("read_at")
|
||||
|
||||
news News @relation(fields: [newsId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([newsId, userId])
|
||||
@@map("news_reads")
|
||||
}
|
||||
|
||||
model NewsAttachment {
|
||||
id String @id @default(uuid())
|
||||
newsId String @map("news_id")
|
||||
name String
|
||||
storagePath String @map("storage_path")
|
||||
mimeType String? @map("mime_type")
|
||||
sizeBytes Int? @map("size_bytes")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
news News @relation(fields: [newsId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("news_attachments")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// STELLENANGEBOTE (Lehrlingsbörse)
|
||||
// =============================================
|
||||
|
||||
model Stelle {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
memberId String @map("member_id")
|
||||
sparte String
|
||||
stellenAnz Int @default(1) @map("stellen_anz")
|
||||
verguetung String? // "600-800 € / Monat"
|
||||
lehrjahr String? // "1. Lehrjahr" | "beliebig"
|
||||
beschreibung String?
|
||||
kontaktEmail String @map("kontakt_email")
|
||||
kontaktName String? @map("kontakt_name")
|
||||
aktiv Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([orgId])
|
||||
@@index([aktiv])
|
||||
@@map("stellen")
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// TERMINE
|
||||
// =============================================
|
||||
|
||||
model Termin {
|
||||
id String @id @default(uuid())
|
||||
orgId String @map("org_id")
|
||||
titel String
|
||||
datum DateTime @db.Date
|
||||
uhrzeit String? // stored as "HH:MM"
|
||||
endeDatum DateTime? @map("ende_datum") @db.Date
|
||||
endeUhrzeit String? @map("ende_uhrzeit")
|
||||
ort String?
|
||||
adresse String?
|
||||
typ TerminTyp
|
||||
beschreibung String?
|
||||
maxTeilnehmer Int? @map("max_teilnehmer") // NULL = unbegrenzt
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
anmeldungen TerminAnmeldung[]
|
||||
|
||||
@@index([orgId])
|
||||
@@index([datum])
|
||||
@@map("termine")
|
||||
}
|
||||
|
||||
enum TerminTyp {
|
||||
Pruefung
|
||||
Versammlung
|
||||
Kurs
|
||||
Event
|
||||
Sonstiges
|
||||
}
|
||||
|
||||
model TerminAnmeldung {
|
||||
id String @id @default(uuid())
|
||||
terminId String @map("termin_id")
|
||||
memberId String @map("member_id")
|
||||
angemeldetAt DateTime @default(now()) @map("angemeldet_at")
|
||||
|
||||
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
|
||||
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([terminId, memberId])
|
||||
@@map("termin_anmeldungen")
|
||||
}
|
||||
209
innungsapp/packages/shared/prisma/seed.ts
Normal file
209
innungsapp/packages/shared/prisma/seed.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database...')
|
||||
|
||||
// Create demo organization
|
||||
const org = await prisma.organization.upsert({
|
||||
where: { slug: 'innung-elektro-stuttgart' },
|
||||
update: {},
|
||||
create: {
|
||||
name: 'Innung Elektrotechnik Stuttgart',
|
||||
slug: 'innung-elektro-stuttgart',
|
||||
plan: 'pilot',
|
||||
primaryColor: '#E63946',
|
||||
contactEmail: 'kontakt@innung-elektro-stuttgart.de',
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Created organization: ${org.name}`)
|
||||
|
||||
// Create demo admin user (better-auth manages sessions, we just pre-create the role)
|
||||
// In production: use the invite flow via the admin dashboard
|
||||
const adminUser = await prisma.user.upsert({
|
||||
where: { email: 'admin@demo.de' },
|
||||
update: {},
|
||||
create: {
|
||||
id: 'demo-admin-user-id',
|
||||
name: 'Demo Admin',
|
||||
email: 'admin@demo.de',
|
||||
emailVerified: true,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.userRole.upsert({
|
||||
where: { orgId_userId: { orgId: org.id, userId: adminUser.id } },
|
||||
update: {},
|
||||
create: {
|
||||
orgId: org.id,
|
||||
userId: adminUser.id,
|
||||
role: 'admin',
|
||||
},
|
||||
})
|
||||
|
||||
// Create demo admin member record
|
||||
const adminMember = await prisma.member.upsert({
|
||||
where: { userId: adminUser.id },
|
||||
update: {},
|
||||
create: {
|
||||
orgId: org.id,
|
||||
userId: adminUser.id,
|
||||
name: 'Demo Admin',
|
||||
betrieb: 'Innungsgeschäftsstelle',
|
||||
sparte: 'Elektrotechnik',
|
||||
ort: 'Stuttgart',
|
||||
email: 'admin@demo.de',
|
||||
status: 'aktiv',
|
||||
},
|
||||
})
|
||||
|
||||
// Create demo members
|
||||
const demoMembers = [
|
||||
{
|
||||
name: 'Klaus Müller',
|
||||
betrieb: 'Elektro Müller GmbH',
|
||||
sparte: 'Elektrotechnik',
|
||||
ort: 'Stuttgart',
|
||||
telefon: '+49 711 123456',
|
||||
email: 'mueller@elektro-mueller.de',
|
||||
istAusbildungsbetrieb: true,
|
||||
seit: 2015,
|
||||
},
|
||||
{
|
||||
name: 'Maria Schmidt',
|
||||
betrieb: 'Schmidt Elektrik',
|
||||
sparte: 'Elektrotechnik',
|
||||
ort: 'Ludwigsburg',
|
||||
telefon: '+49 7141 987654',
|
||||
email: 'schmidt@schmidt-elektrik.de',
|
||||
istAusbildungsbetrieb: false,
|
||||
seit: 2018,
|
||||
},
|
||||
{
|
||||
name: 'Thomas Weber',
|
||||
betrieb: 'Weber & Söhne Elektro',
|
||||
sparte: 'Informationstechnik',
|
||||
ort: 'Esslingen',
|
||||
telefon: '+49 711 555123',
|
||||
email: 'weber@weber-elektro.de',
|
||||
istAusbildungsbetrieb: true,
|
||||
seit: 2012,
|
||||
},
|
||||
]
|
||||
|
||||
const createdMembers = []
|
||||
for (const m of demoMembers) {
|
||||
const member = await prisma.member.create({
|
||||
data: { orgId: org.id, status: 'aktiv', ...m },
|
||||
})
|
||||
createdMembers.push(member)
|
||||
}
|
||||
console.log(`Created ${createdMembers.length} demo members`)
|
||||
|
||||
// Create demo news
|
||||
const news1 = await prisma.news.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
authorId: adminMember.id,
|
||||
title: 'Wichtige Änderungen bei der Gesellenprüfung 2025',
|
||||
body: `## Änderungen ab Herbst 2025
|
||||
|
||||
Die Prüfungsordnung wurde angepasst. Folgende Änderungen sind zu beachten:
|
||||
|
||||
- **Prüfungsteil A** (Gesellenstück) wird auf 2 Tage ausgeweitet
|
||||
- Neue digitale Komponenten in Prüfungsteil B
|
||||
- Anmeldeschluss ist der **15. September 2025**
|
||||
|
||||
Weitere Details entnehmen Sie dem beigefügten Rundschreiben.`,
|
||||
kategorie: 'Pruefung',
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.news.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
authorId: adminMember.id,
|
||||
title: 'Förderung für Ausbildungsbetriebe: Jetzt beantragen!',
|
||||
body: `## Neue Fördergelder verfügbar
|
||||
|
||||
Das Land Baden-Württemberg stellt für das Jahr 2025 zusätzliche Fördermittel für Ausbildungsbetriebe bereit.
|
||||
|
||||
**Förderhöhe:** Bis zu 3.000 € pro Auszubildenden
|
||||
|
||||
**Voraussetzungen:**
|
||||
- Mitglied in einer anerkannten Innung
|
||||
- Erstauszubildende oder benachteiligte Jugendliche
|
||||
|
||||
Anträge bis **31. März 2025** einreichen.`,
|
||||
kategorie: 'Foerderung',
|
||||
publishedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created demo news articles')
|
||||
|
||||
// Create demo Termin
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
titel: 'Herbst-Gesellenprüfung 2025',
|
||||
datum: new Date('2025-11-15'),
|
||||
uhrzeit: '08:00',
|
||||
endeDatum: new Date('2025-11-16'),
|
||||
endeUhrzeit: '17:00',
|
||||
ort: 'Berufsschule Stuttgart-Mitte',
|
||||
adresse: 'Neckarstraße 22, 70190 Stuttgart',
|
||||
typ: 'Pruefung',
|
||||
beschreibung: 'Praktische und theoretische Gesellenprüfung für Elektrotechniker.',
|
||||
maxTeilnehmer: 30,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.termin.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
titel: 'Innungsversammlung Winter 2025',
|
||||
datum: new Date('2025-12-03'),
|
||||
uhrzeit: '19:00',
|
||||
endeUhrzeit: '21:00',
|
||||
ort: 'Gasthof Zum Lamm',
|
||||
adresse: 'Marktplatz 5, 70173 Stuttgart',
|
||||
typ: 'Versammlung',
|
||||
beschreibung: 'Jährliche Winterversammlung mit Jahresrückblick und Vorstandswahlen.',
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created demo Termine')
|
||||
|
||||
// Create demo Stelle
|
||||
await prisma.stelle.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
memberId: createdMembers[0].id,
|
||||
sparte: 'Elektrotechnik',
|
||||
stellenAnz: 2,
|
||||
verguetung: '620-750 € / Monat',
|
||||
lehrjahr: 'beliebig',
|
||||
beschreibung:
|
||||
'Wir suchen motivierte Auszubildende für unser erfolgreiches Elektroinstallationsunternehmen. Moderner Fuhrpark, faire Vergütung, Übernahmechancen.',
|
||||
kontaktEmail: 'mueller@elektro-mueller.de',
|
||||
kontaktName: 'Klaus Müller',
|
||||
aktiv: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Created demo Stelle')
|
||||
console.log('Seeding complete!')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
2
innungsapp/packages/shared/src/index.ts
Normal file
2
innungsapp/packages/shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { prisma } from './lib/prisma'
|
||||
export * from './types/index'
|
||||
18
innungsapp/packages/shared/src/lib/prisma.ts
Normal file
18
innungsapp/packages/shared/src/lib/prisma.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ['query', 'error', 'warn']
|
||||
: ['error'],
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
71
innungsapp/packages/shared/src/types/index.ts
Normal file
71
innungsapp/packages/shared/src/types/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Re-export Prisma types for use across apps
|
||||
export type {
|
||||
User,
|
||||
Session,
|
||||
Account,
|
||||
Organization,
|
||||
Member,
|
||||
UserRole,
|
||||
News,
|
||||
NewsRead,
|
||||
NewsAttachment,
|
||||
Stelle,
|
||||
Termin,
|
||||
TerminAnmeldung,
|
||||
Plan,
|
||||
MemberStatus,
|
||||
OrgRole,
|
||||
NewsKategorie,
|
||||
TerminTyp,
|
||||
} from '@prisma/client'
|
||||
|
||||
// =============================================
|
||||
// UI Display Helpers
|
||||
// =============================================
|
||||
|
||||
export const NEWS_KATEGORIE_LABELS: Record<string, string> = {
|
||||
Wichtig: 'Wichtig',
|
||||
Pruefung: 'Prüfung',
|
||||
Foerderung: 'Förderung',
|
||||
Veranstaltung: 'Veranstaltung',
|
||||
Allgemein: 'Allgemein',
|
||||
}
|
||||
|
||||
export const TERMIN_TYP_LABELS: Record<string, string> = {
|
||||
Pruefung: 'Prüfung',
|
||||
Versammlung: 'Versammlung',
|
||||
Kurs: 'Kurs',
|
||||
Event: 'Event',
|
||||
Sonstiges: 'Sonstiges',
|
||||
}
|
||||
|
||||
export const MEMBER_STATUS_LABELS: Record<string, string> = {
|
||||
aktiv: 'Aktiv',
|
||||
ruhend: 'Ruhend',
|
||||
ausgetreten: 'Ausgetreten',
|
||||
}
|
||||
|
||||
export const PLAN_LABELS: Record<string, string> = {
|
||||
pilot: 'Pilot',
|
||||
standard: 'Standard',
|
||||
pro: 'Pro',
|
||||
verband: 'Verband',
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Shared Zod schemas (for validation on both apps)
|
||||
// =============================================
|
||||
|
||||
export const SPARTEN = [
|
||||
'Elektrotechnik',
|
||||
'Informationstechnik',
|
||||
'Sanitär',
|
||||
'Heizung',
|
||||
'Klimatechnik',
|
||||
'Maler',
|
||||
'Schreiner',
|
||||
'Metallbau',
|
||||
'Sonstiges',
|
||||
] as const
|
||||
|
||||
export type Sparte = (typeof SPARTEN)[number]
|
||||
15
innungsapp/packages/shared/tsconfig.json
Normal file
15
innungsapp/packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
3
innungsapp/pnpm-workspace.yaml
Normal file
3
innungsapp/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
19
innungsapp/turbo.json
Normal file
19
innungsapp/turbo.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"type-check": {
|
||||
"dependsOn": ["^build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user