feat: Set up initial monorepo structure for admin and mobile applications with core configurations and database integration.
This commit is contained in:
@@ -19,14 +19,14 @@ export default function EinstellungenPage() {
|
||||
<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">
|
||||
<div className="bg-white rounded-lg border 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"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -35,7 +35,7 @@ export default function EinstellungenPage() {
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -51,7 +51,7 @@ export default function EinstellungenPage() {
|
||||
</div>
|
||||
|
||||
{/* AVV */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-4">
|
||||
<div className="bg-white rounded-lg border 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
|
||||
@@ -101,7 +101,7 @@ export default function EinstellungenPage() {
|
||||
</div>
|
||||
|
||||
{/* Plan Info */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<div className="bg-white rounded-lg border 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}
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { Sidebar } from '@/components/layout/Sidebar'
|
||||
import { Header } from '@/components/layout/Header'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function DashboardLayout({
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Superadmin Redirect
|
||||
const superAdminEmail = process.env.SUPERADMIN_EMAIL || 'superadmin@innungsapp.de'
|
||||
if (session.user.email === superAdminEmail) {
|
||||
redirect('/superadmin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<Sidebar />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { SPARTEN, MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||
@@ -41,7 +42,7 @@ export default function MitgliedEditPage({
|
||||
ort: member.ort,
|
||||
telefon: member.telefon ?? '',
|
||||
email: member.email,
|
||||
status: member.status,
|
||||
status: member.status as 'aktiv' | 'ruhend' | 'ausgetreten',
|
||||
istAusbildungsbetrieb: member.istAusbildungsbetrieb,
|
||||
seit: member.seit ?? undefined,
|
||||
})
|
||||
@@ -57,24 +58,25 @@ export default function MitgliedEditPage({
|
||||
}
|
||||
|
||||
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'
|
||||
'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent'
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/mitglieder" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
← Zurück
|
||||
</Link>
|
||||
<span className="text-gray-200">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Mitglied bearbeiten</h1>
|
||||
</div>
|
||||
|
||||
{/* Invite Status */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-4 flex items-center justify-between">
|
||||
<div className="bg-white rounded-lg border p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">App-Zugang</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{member.userId
|
||||
? '✓ Mitglied hat sich eingeloggt'
|
||||
? 'Mitglied hat sich eingeloggt'
|
||||
: 'Noch nicht eingeladen / eingeloggt'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -84,61 +86,79 @@ export default function MitgliedEditPage({
|
||||
disabled={resendMutation.isPending}
|
||||
className="text-sm text-brand-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
{resendMutation.isPending ? 'Sende...' : resendMutation.isSuccess ? '✓ Gesendet' : 'Einladung senden'}
|
||||
{resendMutation.isPending ? 'Sende...' : resendMutation.isSuccess ? 'Gesendet' : 'Einladung senden'}
|
||||
</button>
|
||||
)}
|
||||
</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 value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
|
||||
{/* Section: Stammdaten */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stammdaten</p>
|
||||
<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 value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
|
||||
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
|
||||
</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={inputClass}>
|
||||
{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 value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrieb</label>
|
||||
<input value={form.betrieb} onChange={(e) => setForm({ ...form, betrieb: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Section: Kontakt */}
|
||||
<div className="border-t pt-5">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
|
||||
</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={inputClass} />
|
||||
</div>
|
||||
</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={inputClass}>
|
||||
{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 value={form.ort} onChange={(e) => setForm({ ...form, ort: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} className={inputClass} />
|
||||
</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={inputClass} />
|
||||
</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={inputClass}>
|
||||
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
|
||||
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</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: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
|
||||
</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>
|
||||
|
||||
{/* Section: Status */}
|
||||
<div className="border-t pt-5">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Status</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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={inputClass}>
|
||||
{(['aktiv', 'ruhend', 'ausgetreten'] as const).map((s) => (
|
||||
<option key={s} value={s}>{MEMBER_STATUS_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</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: e.target.value ? Number(e.target.value) : undefined })} className={inputClass} />
|
||||
</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>
|
||||
|
||||
{updateMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">{updateMutation.error.message}</p>
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">{getTrpcErrorMessage(updateMutation.error)}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2 border-t">
|
||||
|
||||
@@ -7,17 +7,19 @@ import { MEMBER_STATUS_LABELS } from '@innungsapp/shared'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
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 }
|
||||
export default async function MitgliederPage(props: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const search = typeof searchParams.q === 'string' ? searchParams.q : ''
|
||||
const statusFilter = typeof searchParams.status === 'string' ? searchParams.status : undefined
|
||||
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
if (!session?.user) redirect('/login')
|
||||
|
||||
@@ -26,18 +28,15 @@ export default async function MitgliederPage({
|
||||
})
|
||||
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' } },
|
||||
{ name: { contains: search } },
|
||||
{ betrieb: { contains: search } },
|
||||
{ ort: { contains: search } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -60,7 +59,7 @@ export default async function MitgliederPage({
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-4 flex gap-4">
|
||||
<div className="bg-white rounded-lg border p-4 flex gap-4">
|
||||
<form className="flex gap-4 w-full">
|
||||
<input
|
||||
name="q"
|
||||
@@ -88,7 +87,7 @@ export default async function MitgliederPage({
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -115,16 +114,16 @@ export default async function MitgliederPage({
|
||||
<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]}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] 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-[11px] font-medium text-green-600 bg-green-50 px-2 py-0.5 rounded-full">Aktiv</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Nicht eingeladen</span>
|
||||
<span className="text-[11px] text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
170
innungsapp/apps/admin/app/dashboard/news/[id]/page.tsx
Normal file
170
innungsapp/apps/admin/app/dashboard/news/[id]/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
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 NewsEditPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const { data: news, isLoading } = trpc.news.byId.useQuery({ id })
|
||||
const updateMutation = trpc.news.update.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/news'),
|
||||
})
|
||||
const deleteMutation = trpc.news.delete.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/news'),
|
||||
})
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [kategorie, setKategorie] = useState('Allgemein')
|
||||
|
||||
useEffect(() => {
|
||||
if (news) {
|
||||
setTitle(news.title)
|
||||
setBody(news.body)
|
||||
setKategorie(news.kategorie)
|
||||
}
|
||||
}, [news])
|
||||
|
||||
if (isLoading) return <div className="text-gray-500 text-sm">Wird geladen...</div>
|
||||
if (!news) return <div className="text-gray-500 text-sm">Beitrag nicht gefunden.</div>
|
||||
|
||||
function handleSave(publishNow: boolean) {
|
||||
if (!title.trim() || !body.trim()) return
|
||||
updateMutation.mutate({
|
||||
id,
|
||||
data: {
|
||||
title,
|
||||
body,
|
||||
kategorie: kategorie as never,
|
||||
publishedAt: publishNow ? new Date().toISOString() : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleUnpublish() {
|
||||
updateMutation.mutate({ id, data: { publishedAt: null } })
|
||||
}
|
||||
|
||||
const isPublished = !!news.publishedAt
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/news" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
← Zurück
|
||||
</Link>
|
||||
<span className="text-gray-200">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Beitrag bearbeiten</h1>
|
||||
{isPublished && (
|
||||
<span className="text-[11px] font-medium bg-green-100 text-green-700 px-2 py-0.5 rounded-full">
|
||||
Publiziert
|
||||
</span>
|
||||
)}
|
||||
{!isPublished && (
|
||||
<span className="text-[11px] font-medium bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">
|
||||
Entwurf
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-6 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Titel</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Titel..."
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Kategorie</label>
|
||||
<select
|
||||
value={kategorie}
|
||||
onChange={(e) => setKategorie(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||
>
|
||||
{KATEGORIEN.map((k) => (
|
||||
<option key={k.value} value={k.value}>{k.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Inhalt</label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={body}
|
||||
onChange={(v) => setBody(v ?? '')}
|
||||
height={400}
|
||||
preview="live"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{getTrpcErrorMessage(updateMutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex gap-3">
|
||||
{!isPublished && (
|
||||
<button
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={updateMutation.isPending}
|
||||
className="bg-brand-500 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
Publizieren
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium text-gray-700 border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{updateMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
{isPublished && (
|
||||
<button
|
||||
onClick={handleUnpublish}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium text-gray-500 hover:text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Depublizieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Beitrag wirklich löschen?')) deleteMutation.mutate({ id })
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="text-sm text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
@@ -132,7 +133,7 @@ export default function NewsNeuPage() {
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{createMutation.error.message}
|
||||
{getTrpcErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export default async function NewsPage() {
|
||||
<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">
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<tbody>
|
||||
{drafts.map((n) => (
|
||||
@@ -83,7 +83,7 @@ export default async function NewsPage() {
|
||||
<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">
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -59,7 +59,7 @@ export default async function DashboardPage() {
|
||||
|
||||
<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="bg-white rounded-lg border 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">
|
||||
@@ -87,7 +87,7 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Upcoming Termine */}
|
||||
<div className="bg-white rounded-xl border shadow-sm p-6">
|
||||
<div className="bg-white rounded-lg border 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">
|
||||
|
||||
182
innungsapp/apps/admin/app/dashboard/stellen/neu/page.tsx
Normal file
182
innungsapp/apps/admin/app/dashboard/stellen/neu/page.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function StelleNeuPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const { data: members } = trpc.members.list.useQuery({})
|
||||
const createMutation = trpc.stellen.createForMember.useMutation({
|
||||
onSuccess: () => router.push('/dashboard/stellen'),
|
||||
})
|
||||
|
||||
const [form, setForm] = useState({
|
||||
memberId: '',
|
||||
sparte: '',
|
||||
stellenAnz: 1,
|
||||
verguetung: '',
|
||||
lehrjahr: '',
|
||||
beschreibung: '',
|
||||
kontaktEmail: '',
|
||||
kontaktName: '',
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!form.memberId) return
|
||||
createMutation.mutate({
|
||||
...form,
|
||||
stellenAnz: Number(form.stellenAnz),
|
||||
verguetung: form.verguetung || undefined,
|
||||
lehrjahr: form.lehrjahr || undefined,
|
||||
beschreibung: form.beschreibung || undefined,
|
||||
kontaktName: form.kontaktName || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent'
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/stellen" className="text-xs text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
← Zurück
|
||||
</Link>
|
||||
<span className="text-gray-200">/</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Stelle anlegen</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-lg border p-6 space-y-6">
|
||||
{/* Betrieb */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Betrieb</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitglied / Betrieb *</label>
|
||||
<select
|
||||
required
|
||||
value={form.memberId}
|
||||
onChange={(e) => {
|
||||
const selected = members?.find((m) => m.id === e.target.value)
|
||||
setForm({ ...form, memberId: e.target.value, sparte: selected?.sparte ?? form.sparte })
|
||||
}}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">Mitglied auswählen...</option>
|
||||
{members?.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.betrieb} – {m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stellendetails */}
|
||||
<div className="border-t pt-5">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Stellendetails</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sparte *</label>
|
||||
<input
|
||||
required
|
||||
value={form.sparte}
|
||||
onChange={(e) => setForm({ ...form, sparte: e.target.value })}
|
||||
placeholder="z.B. Elektrotechnik"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anzahl Stellen</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.stellenAnz}
|
||||
onChange={(e) => setForm({ ...form, stellenAnz: Number(e.target.value) })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Lehrjahr</label>
|
||||
<input
|
||||
value={form.lehrjahr}
|
||||
onChange={(e) => setForm({ ...form, lehrjahr: e.target.value })}
|
||||
placeholder="z.B. 1. Lehrjahr"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vergütung</label>
|
||||
<input
|
||||
value={form.verguetung}
|
||||
onChange={(e) => setForm({ ...form, verguetung: e.target.value })}
|
||||
placeholder="z.B. 650 € / Monat"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.beschreibung}
|
||||
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
|
||||
placeholder="Aufgaben, Anforderungen, ..."
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kontakt */}
|
||||
<div className="border-t pt-5">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Kontakt</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kontakt-E-Mail *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={form.kontaktEmail}
|
||||
onChange={(e) => setForm({ ...form, kontaktEmail: e.target.value })}
|
||||
placeholder="bewerbung@betrieb.de"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ansprechpartner</label>
|
||||
<input
|
||||
value={form.kontaktName}
|
||||
onChange={(e) => setForm({ ...form, kontaktName: e.target.value })}
|
||||
placeholder="Max Mustermann"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{getTrpcErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2 border-t">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !form.memberId}
|
||||
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...' : 'Stelle anlegen'}
|
||||
</button>
|
||||
<Link href="/dashboard/stellen" 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>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation'
|
||||
import { format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import { DeactivateButton } from './DeactivateButton'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function StellenPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() })
|
||||
@@ -22,14 +23,22 @@ export default async function StellenPage() {
|
||||
|
||||
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 className="flex items-center justify-between">
|
||||
<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>
|
||||
<Link
|
||||
href="/dashboard/stellen/neu"
|
||||
className="bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 transition-colors"
|
||||
>
|
||||
+ Stelle anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border shadow-sm overflow-hidden">
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc-client'
|
||||
import { getTrpcErrorMessage } from '@/lib/trpc-error'
|
||||
import Link from 'next/link'
|
||||
|
||||
const TYPEN = [
|
||||
@@ -122,7 +123,7 @@ export default function TerminNeuPage() {
|
||||
|
||||
{createMutation.error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 px-4 py-2 rounded-lg">
|
||||
{createMutation.error.message}
|
||||
{getTrpcErrorMessage(createMutation.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export default async function TerminePage() {
|
||||
<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">
|
||||
<div className="bg-white rounded-lg border overflow-hidden">
|
||||
<table className="w-full data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -110,7 +110,7 @@ export default async function TerminePage() {
|
||||
<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">
|
||||
<div className="bg-white rounded-lg border overflow-hidden opacity-70">
|
||||
<table className="w-full data-table">
|
||||
<tbody>
|
||||
{past.map((t) => <TerminRow key={t.id} t={t} />)}
|
||||
|
||||
Reference in New Issue
Block a user