feat: Set up initial monorepo structure for admin and mobile applications with core configurations and database integration.

This commit is contained in:
2026-02-20 12:58:54 +01:00
parent 5e2d5fb3ae
commit b7f8221095
52 changed files with 2200 additions and 175 deletions

View File

@@ -0,0 +1,81 @@
'use client'
import { useActionState } from 'react'
import { createOrganization } from './actions'
const initialState = {
success: false,
error: '',
}
export function CreateOrgForm() {
const [state, formAction, isPending] = useActionState(createOrganization, initialState)
return (
<div className="bg-white p-6 rounded-xl border shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Neue Innung anlegen</h2>
{state.success && (
<div className="mb-4 p-3 bg-green-50 text-green-700 rounded-lg text-sm">
Innung wurde erfolgreich angelegt!
</div>
)}
{state.error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">
{state.error}
</div>
)}
<form action={formAction} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name der Innung
</label>
<input
type="text"
name="name"
required
placeholder="z.B. Tischler-Innung Berlin"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kurzbezeichnung (Slug)
</label>
<p className="text-xs text-gray-500 mb-2">Für interne Zuordnung (nur Kleinbuchstaben, ohne Leerzeichen).</p>
<input
type="text"
name="slug"
required
placeholder="tischler-berlin"
pattern="^[a-z0-9-]+$"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kontakt E-Mail (Optional)
</label>
<input
type="email"
name="contactEmail"
placeholder="info@tischler-berlin.de"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-brand-500 text-white font-medium py-2 px-4 rounded-lg hover:bg-brand-600 transition-colors disabled:opacity-50"
>
{isPending ? 'Wird angelegt...' : 'Innung anlegen'}
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use server'
import { prisma } from '@innungsapp/shared'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const createOrgSchema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
slug: z.string().min(2, 'Slug muss mindestens 2 Zeichen lang sein').regex(/^[a-z0-9-]+$/, 'Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten'),
contactEmail: z.string().email('Ungültige E-Mail Adresse').optional().or(z.literal('')),
})
export async function createOrganization(prevState: any, formData: FormData) {
try {
const rawData = {
name: formData.get('name') as string,
slug: (formData.get('slug') as string).toLowerCase(),
contactEmail: formData.get('contactEmail') as string,
}
const validatedData = createOrgSchema.parse(rawData)
// Check if slug exists
const existingOrg = await prisma.organization.findUnique({
where: { slug: validatedData.slug }
})
if (existingOrg) {
return { success: false, error: 'Diese Kurzbezeichnung (Slug) existiert bereits.' }
}
await prisma.organization.create({
data: {
name: validatedData.name,
slug: validatedData.slug,
contactEmail: validatedData.contactEmail || null,
plan: 'pilot',
}
})
revalidatePath('/superadmin')
return { success: true, error: '' }
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: error.errors[0].message }
}
return { success: false, error: 'Ein unerwarteter Fehler ist aufgetreten.' }
}
}

View File

@@ -0,0 +1,45 @@
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import Link from 'next/link'
export default async function SuperAdminLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session?.user) {
redirect('/login')
}
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
if (session.user.email !== superAdminEmail) {
redirect('/dashboard') // Normal admins go back to dashboard
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Super Admin Header */}
<header className="bg-gray-900 text-white border-t-2 border-brand-500 border-b border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-12 items-center">
<span
className="font-bold text-base tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
Super Admin
</span>
<span className="text-xs text-gray-400">{session.user.email}</span>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full">
{children}
</main>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { prisma } from '@innungsapp/shared'
import { CreateOrgForm } from './CreateOrgForm'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
export default async function SuperAdminPage() {
const organizations = await prisma.organization.findMany({
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: {
members: true,
userRoles: true,
},
},
},
})
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Innungs-Verwaltung (Multi-Tenant)</h1>
<p className="text-gray-500 mt-1">Hierüber werden alle Mandanten der Lösung verwaltet.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Form: Create new org */}
<div className="lg:col-span-1">
<CreateOrgForm />
</div>
{/* List of orgs */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg border overflow-hidden">
<div className="p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">Aktive Innungen ({organizations.length})</h2>
</div>
<div className="divide-y">
{organizations.length === 0 ? (
<div className="p-8 text-center text-gray-500">
Bisher keine Innungen angelegt.
</div>
) : (
organizations.map((org) => (
<div key={org.id} className="p-5 hover:bg-gray-50 border-l-[3px] border-transparent hover:border-brand-500 transition-all">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-gray-900 text-lg">{org.name}</h3>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500">
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded text-[11px]">{org.slug}</span>
<span></span>
<span>{org.contactEmail || 'Keine E-Mail'}</span>
</div>
</div>
<span className="bg-blue-100 text-blue-800 text-xs font-semibold px-2.5 py-0.5 rounded">
{org.plan}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-4 text-sm">
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Mitglieder</span>
<span className="font-bold text-gray-900">{org._count.members}</span>
</div>
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Admins</span>
<span className="font-bold text-gray-900">{org._count.userRoles}</span>
</div>
<div className="bg-gray-50 px-3 py-2 rounded-lg border inline-block">
<span className="text-gray-500 block text-xs uppercase tracking-wider font-semibold">Erstellt am</span>
<span className="font-bold text-gray-900">{format(org.createdAt, 'dd.MM.yyyy', { locale: de })}</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
)
}