v1.3.1: Fix symbol loading, DEL key, SOMA/Pendenzen in rapport, improved onboarding, org settings tab, logo upload
This commit is contained in:
@@ -53,6 +53,7 @@ import {
|
||||
BookOpen,
|
||||
AlertTriangle,
|
||||
LayoutGrid,
|
||||
Building2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { TenantDetailDialog } from '@/components/admin/tenant-detail-dialog'
|
||||
@@ -62,6 +63,7 @@ import { SomaTab } from '@/components/admin/soma-tab'
|
||||
import { SuggestionsTab } from '@/components/admin/suggestions-tab'
|
||||
import { DictionaryTab } from '@/components/admin/dictionary-tab'
|
||||
import { SymbolManager } from '@/components/admin/symbol-manager'
|
||||
import { OrgTab } from '@/components/admin/org-tab'
|
||||
|
||||
// --- Types ---
|
||||
interface IconCategory {
|
||||
@@ -133,7 +135,7 @@ export default function AdminPage() {
|
||||
const [tenants, setTenants] = useState<TenantRecord[]>([])
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState(user?.role === 'SERVER_ADMIN' ? 'tenants' : 'users')
|
||||
const [activeTab, setActiveTab] = useState(user?.role === 'SERVER_ADMIN' ? 'tenants' : 'org')
|
||||
|
||||
// Category Dialog
|
||||
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false)
|
||||
@@ -571,10 +573,22 @@ export default function AdminPage() {
|
||||
</TabsList>
|
||||
) : user?.role === 'TENANT_ADMIN' ? (
|
||||
<TabsList className="grid w-full grid-cols-7 max-w-4xl">
|
||||
<TabsTrigger value="org" className="gap-2">
|
||||
<Building2 className="w-4 h-4" />
|
||||
Organisation
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="gap-2">
|
||||
<Users className="w-4 h-4" />
|
||||
Benutzer
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="icons" className="gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Symbole
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="soma" className="gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
SOMA
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="suggestions" className="gap-2">
|
||||
<ClipboardList className="w-4 h-4" />
|
||||
Wörterliste
|
||||
@@ -587,21 +601,16 @@ export default function AdminPage() {
|
||||
<Heart className="w-4 h-4" />
|
||||
Spenden
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="icons" className="gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Symbole
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="categories" className="gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
Kategorien
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="soma" className="gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
SOMA
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
) : null}
|
||||
|
||||
{/* ===== ORGANISATION TAB (TENANT_ADMIN) ===== */}
|
||||
{user?.role === 'TENANT_ADMIN' && (
|
||||
<TabsContent value="org" className="space-y-4">
|
||||
<OrgTab tenantId={tenant?.id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ===== ICONS TAB ===== */}
|
||||
<TabsContent value="icons" className="space-y-4">
|
||||
{user?.role === 'TENANT_ADMIN' ? (
|
||||
|
||||
@@ -17,9 +17,13 @@ export async function GET(req: NextRequest) {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
address: true,
|
||||
logoUrl: true,
|
||||
plan: true,
|
||||
subscriptionStatus: true,
|
||||
contactEmail: true,
|
||||
privacyAccepted: true,
|
||||
privacyAcceptedAt: true,
|
||||
adminAccessAccepted: true,
|
||||
@@ -39,3 +43,35 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role !== 'TENANT_ADMIN') return NextResponse.json({ error: 'Nur Admin' }, { status: 403 })
|
||||
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||
|
||||
const body = await req.json()
|
||||
const { name, description, contactEmail, contactPhone, address } = body
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return NextResponse.json({ error: 'Name darf nicht leer sein' }, { status: 400 })
|
||||
}
|
||||
|
||||
const updated = await (prisma as any).tenant.update({
|
||||
where: { id: user.tenantId },
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description || null,
|
||||
contactEmail: contactEmail || null,
|
||||
contactPhone: contactPhone || null,
|
||||
address: address || null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ tenant: updated })
|
||||
} catch (error: any) {
|
||||
console.error('[Tenant Info PATCH] Error:', error?.message)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
77
src/app/api/tenant/logo/route.ts
Normal file
77
src/app/api/tenant/logo/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { uploadFile, deleteFile } from '@/lib/minio'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user || user.role !== 'TENANT_ADMIN') {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||
}
|
||||
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('logo') as File
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Keine Datei hochgeladen' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
if (!validTypes.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Ungültiges Dateiformat. Erlaubt: PNG, JPEG, SVG, WebP' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
return NextResponse.json({ error: 'Datei zu gross (max. 2 MB)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const ext = file.name.split('.').pop() || 'png'
|
||||
const fileKey = `logos/tenant-${user.tenantId}.${ext}`
|
||||
|
||||
await uploadFile(fileKey, buffer, file.type)
|
||||
|
||||
const logoServeUrl = `/api/admin/tenants/${user.tenantId}/logo/serve`
|
||||
await (prisma as any).tenant.update({
|
||||
where: { id: user.tenantId },
|
||||
data: { logoFileKey: fileKey, logoUrl: logoServeUrl },
|
||||
})
|
||||
|
||||
return NextResponse.json({ logoUrl: logoServeUrl })
|
||||
} catch (error) {
|
||||
console.error('Tenant logo upload error:', error)
|
||||
return NextResponse.json({ error: 'Upload fehlgeschlagen' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user || user.role !== 'TENANT_ADMIN') {
|
||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 403 })
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
return NextResponse.json({ error: 'Kein Mandant' }, { status: 400 })
|
||||
}
|
||||
|
||||
const tenant = await (prisma as any).tenant.findUnique({ where: { id: user.tenantId } })
|
||||
if (tenant?.logoFileKey) {
|
||||
try {
|
||||
await deleteFile(tenant.logoFileKey)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await (prisma as any).tenant.update({
|
||||
where: { id: user.tenantId },
|
||||
data: { logoUrl: null, logoFileKey: null },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Tenant logo delete error:', error)
|
||||
return NextResponse.json({ error: 'Löschen fehlgeschlagen' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -862,7 +862,7 @@ export default function AppPage() {
|
||||
<span>Niemand bearbeitet gerade</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div data-tour="edit-toggle" className="flex items-center gap-2">
|
||||
{roleCanEdit && !isEditingByMe && !isReadOnly && (
|
||||
<Button size="sm" variant="default" onClick={handleStartEditing} disabled={editingLoading}>
|
||||
<Lock className="w-3.5 h-3.5 mr-1" />
|
||||
|
||||
@@ -210,9 +210,59 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 5. Eingesetzte Mittel */}
|
||||
{/* 5. SOMA Checkliste */}
|
||||
{Array.isArray(d.somaItems) && d.somaItems.length > 0 && (
|
||||
<Section num="5" title="SOMA Checkliste">
|
||||
<div className="border rounded">
|
||||
<div className="grid grid-cols-[24px_24px_1fr_60px] gap-0 text-[7pt] font-semibold uppercase tracking-wider bg-gray-900 text-white p-1.5">
|
||||
<span className="text-center">Best.</span>
|
||||
<span className="text-center">OK</span>
|
||||
<span>Punkt</span>
|
||||
<span className="text-right">Zeit</span>
|
||||
</div>
|
||||
{d.somaItems.map((s: any, i: number) => (
|
||||
<div key={i} className={`grid grid-cols-[24px_24px_1fr_60px] gap-0 p-1.5 border-b border-gray-100 text-[9pt] ${i % 2 === 1 ? 'bg-gray-50' : ''}`}>
|
||||
<span className="text-center font-bold">{s.confirmed ? '✓' : '—'}</span>
|
||||
<span className="text-center font-bold">{s.ok ? '✓' : '—'}</span>
|
||||
<span className="font-medium">{s.label}</span>
|
||||
<span className="text-right text-[8pt] text-gray-500 font-mono">{s.confirmedAt || ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 6. Pendenzen */}
|
||||
{Array.isArray(d.pendenzenItems) && d.pendenzenItems.length > 0 && (
|
||||
<Section num="6" title="Pendenzen">
|
||||
<table className="w-full border-collapse border rounded text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-white">
|
||||
<th className="p-1.5 text-center font-semibold uppercase tracking-wider text-[7pt] w-8">✓</th>
|
||||
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt]">Aufgabe</th>
|
||||
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-24">Wer</th>
|
||||
<th className="p-1.5 text-left font-semibold uppercase tracking-wider text-[7pt] w-32">Wann / Wie</th>
|
||||
<th className="p-1.5 text-right font-semibold uppercase tracking-wider text-[7pt] w-16">Erledigt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.pendenzenItems.map((p: any, i: number) => (
|
||||
<tr key={i} className={`${i % 2 === 1 ? 'bg-gray-50' : ''} ${p.done ? 'text-gray-400' : ''}`}>
|
||||
<td className="p-1.5 border-b border-gray-100 text-center font-bold">{p.done ? '✓' : '○'}</td>
|
||||
<td className={`p-1.5 border-b border-gray-100 ${p.done ? 'line-through' : ''}`}>{p.what}</td>
|
||||
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.who || '—'}</td>
|
||||
<td className="p-1.5 border-b border-gray-100 text-gray-500">{p.whenHow || '—'}</td>
|
||||
<td className="p-1.5 border-b border-gray-100 text-right font-mono text-[8pt]">{p.doneAt || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 7. Eingesetzte Mittel */}
|
||||
{d.fahrzeuge?.length > 0 && (
|
||||
<Section num="5" title="Eingesetzte Mittel">
|
||||
<Section num="7" title="Eingesetzte Mittel">
|
||||
<table className="w-full border-collapse border rounded text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-900 text-white">
|
||||
@@ -238,8 +288,8 @@ export default function RapportViewerPage({ params }: { params: Promise<{ token:
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 6. Bemerkungen */}
|
||||
<Section num="6" title="Bemerkungen / Besondere Vorkommnisse">
|
||||
{/* 8. Bemerkungen */}
|
||||
<Section num="8" title="Bemerkungen / Besondere Vorkommnisse">
|
||||
<div className="border rounded p-3 min-h-[50px] text-sm">{d.bemerkungen || '—'}</div>
|
||||
</Section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user