feat: Set up initial monorepo structure for admin and mobile applications with core configurations and database integration.
This commit is contained in:
81
innungsapp/apps/admin/app/superadmin/CreateOrgForm.tsx
Normal file
81
innungsapp/apps/admin/app/superadmin/CreateOrgForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
innungsapp/apps/admin/app/superadmin/actions.ts
Normal file
49
innungsapp/apps/admin/app/superadmin/actions.ts
Normal 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.' }
|
||||
}
|
||||
}
|
||||
45
innungsapp/apps/admin/app/superadmin/layout.tsx
Normal file
45
innungsapp/apps/admin/app/superadmin/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
innungsapp/apps/admin/app/superadmin/page.tsx
Normal file
84
innungsapp/apps/admin/app/superadmin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user