Add Environment & Archive module: Eco metrics, Digital Archive, Government Portal, NAS settings

New Module: 'Umwelt & Archiv' (Eco & Archive) — 4 tabs:

1. Umwelt (Eco Dashboard):
   - Paper savings tracker (sheets, grams, CO2, water, trees)
   - Monthly trend bar chart
   - Hero card with environmental mission statement
   - EcoMetric model: daily tracking of paper/CO2/water/trees saved

2. Archiv (Digital Archive):
   - Full-text searchable document archive (OCR text search)
   - Document types: Report Card, Certificate, Enrollment, Letter, Contract
   - Categories: Student, Staff, Finance, Admin, Legal
   - Confidential documents with lock indicator
   - Upload dialog with drag-and-drop area
   - ArchivedDocument model with checksum, retention date, access level

3. Behörden (Government Portal):
   - Report types: Schulstatistik, Schulbesuch, Förderung, Personal, Bildungsbericht
   - Status tracking: DRAFT → SUBMITTED → ACKNOWLEDGED/REJECTED
   - Info box about German government portal integration
   - GovernmentReport model with submission tracking

4. Speicher (NAS/Storage Settings):
   - NAS server connection (SMB/NFS/WebDAV/S3)
   - Host, port, share, credentials configuration
   - Backup frequency (hourly/daily/weekly)
   - Optional cloud backup (Nextcloud/ownCloud/S3)
   - StorageConfig model

New Prisma models: ArchivedDocument, GovernmentReport, EcoMetric, StorageConfig
New API routes: /api/archived-documents, /api/government-reports, /api/eco-metrics, /api/storage-config
New view: EcoArchiveView registered in nav, store, role-access
This commit is contained in:
Z User committed 2026-09-06 18:47:48 +00:00
1 parent 59de7b692b
commit 231d8449e7
12 files changed
+851 -2

No files matched your search

+111
View File
@@ -36,6 +36,10 @@ model School {
users User[]
schoolYears SchoolYear[]
archivedDocuments ArchivedDocument[]
governmentReports GovernmentReport[]
ecoMetrics EcoMetric[]
storageConfig StorageConfig?
classGroups ClassGroup[]
students Student[]
subjects Subject[]
@@ -235,6 +239,8 @@ model User {
dashboardWidgets DashboardWidget[]
dashboardLayouts DashboardLayout[]
passwordResetTokens PasswordResetToken[]
uploadedDocuments ArchivedDocument[] @relation("DocumentUploader")
createdReports GovernmentReport[] @relation("ReportCreator")
twoFactorRecoveryCodes TwoFactorRecoveryCode[]
}
@@ -3558,3 +3564,108 @@ model DashboardLayout {
@@map("dashboard_layouts")
}
// ─── Digital Archive (Papierloses Archiv) ────────────────────────────
model ArchivedDocument {
id String @id @default(cuid())
schoolId String
title String
documentType String // REPORT_CARD, CERTIFICATE, ENROLLMENT, LETTER, CONTRACT, OTHER
category String // STUDENT, STAFF, FINANCE, ADMIN, LEGAL
studentId String?
staffId String?
filePath String // NAS path or cloud URL
fileName String
fileSize Int // bytes
mimeType String
scanDate DateTime @default(now())
documentDate DateTime? // date on the document itself
checksum String? // SHA-256 for integrity verification
ocrText String? // extracted text via OCR (searchable)
tags String? // JSON array of tags
retentionDate DateTime? // when this document can be deleted per policy
isConfidential Boolean @default(false)
accessLevel String @default("STAFF") // STAFF, ADMIN, DPO
uploadedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id])
uploader User @relation("DocumentUploader", fields: [uploadedBy], references: [id])
@@index([schoolId, category])
@@index([schoolId, documentType])
@@index([studentId])
@@map("archived_documents")
}
// ─── Government Portal (Behördenportal) ──────────────────────────────
model GovernmentReport {
id String @id @default(cuid())
schoolId String
reportType String // STATISTIK_SCHULamt, SCHULBESUCH, FUERDERUNG, PERSONAL, BILDUNGSBERICHT
reportingPeriod String // e.g., "2025-Q3", "2025/2026-S1"
status String @default("DRAFT") // DRAFT, SUBMITTED, ACKNOWLEDGED, REJECTED
data String // JSON: the report payload
generatedAt DateTime @default(now())
submittedAt DateTime?
acknowledgedAt DateTime?
submissionId String? // government portal confirmation ID
notes String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id])
creator User @relation("ReportCreator", fields: [createdBy], references: [id])
@@index([schoolId, reportType])
@@index([schoolId, status])
@@map("government_reports")
}
// ─── Eco Metrics (Papiereinsparung) ──────────────────────────────────
model EcoMetric {
id String @id @default(cuid())
schoolId String
date DateTime @default(now())
paperSavedSheets Int // sheets of paper saved that day
paperSavedGrams Int // grams of paper saved
co2SavedKg Float // CO2 saved in kg (production + transport)
waterSavedLiters Float // water saved in liters
treesSaved Float // trees saved (approx)
source String // NOTEBOOK, REPORT_CARD, WORKSHEET, ANNOUNCEMENT, OTHER
notes String?
school School @relation(fields: [schoolId], references: [id])
@@index([schoolId, date])
@@map("eco_metrics")
}
// ─── NAS / Storage Settings ──────────────────────────────────────────
model StorageConfig {
id String @id @default(cuid())
schoolId String @unique
nasEnabled Boolean @default(false)
nasHost String? // e.g., "192.168.1.100" or "nas.schule.de"
nasPort Int @default(445) // SMB port
nasShare String? // e.g., "/SchulOS"
nasUsername String?
nasPasswordEnc String? // encrypted password
nasProtocol String @default("SMB") // SMB, NFS, WebDAV, S3
cloudEnabled Boolean @default(false)
cloudProvider String? // "nextcloud", "owncloud", "s3"
cloudUrl String?
cloudApiKey String?
backupFrequency String @default("daily") // hourly, daily, weekly
lastBackup DateTime?
lastBackupSize Int? // bytes
backupStatus String @default("never") // never, success, failed, in_progress
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
school School @relation(fields: [schoolId], references: [id])
@@map("storage_configs")
}
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const { id } = await params
const doc = await db.archivedDocument.findUnique({ where: { id } })
if (!doc) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(doc)
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const { id } = await params
await db.archivedDocument.delete({ where: { id } })
return NextResponse.json({ success: true })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET(request: Request) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const { searchParams } = new URL(request.url)
const schoolId = searchParams.get('schoolId') || session.user.schoolId
if (!schoolId) return NextResponse.json([])
const category = searchParams.get('category')
const documentType = searchParams.get('documentType')
const search = searchParams.get('search')
const where: any = { schoolId }
if (category) where.category = category
if (documentType) where.documentType = documentType
if (search) where.OR = [
{ title: { contains: search } },
{ ocrText: { contains: search } },
{ tags: { contains: search } },
]
const docs = await db.archivedDocument.findMany({
where,
orderBy: { createdAt: 'desc' },
take: 100,
})
return NextResponse.json(docs)
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const role = session.user.role
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN' && role !== 'TEACHER') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const doc = await db.archivedDocument.create({
data: { ...body, schoolId: session.user.schoolId, uploadedBy: session.user.id },
})
return NextResponse.json(doc, { status: 201 })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET(request: Request) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const schoolId = session.user.schoolId
if (!schoolId) return NextResponse.json([])
const metrics = await db.ecoMetric.findMany({
where: { schoolId },
orderBy: { date: 'desc' },
take: 365,
})
// Aggregate totals
const totals = metrics.reduce((acc, m) => ({
paperSavedSheets: acc.paperSavedSheets + m.paperSavedSheets,
paperSavedGrams: acc.paperSavedGrams + m.paperSavedGrams,
co2SavedKg: acc.co2SavedKg + m.co2SavedKg,
waterSavedLiters: acc.waterSavedLiters + m.waterSavedLiters,
treesSaved: acc.treesSaved + m.treesSaved,
}), { paperSavedSheets: 0, paperSavedGrams: 0, co2SavedKg: 0, waterSavedLiters: 0, treesSaved: 0 })
return NextResponse.json({ metrics, totals })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const body = await request.json()
const metric = await db.ecoMetric.create({
data: { ...body, schoolId: session.user.schoolId! },
})
return NextResponse.json(metric, { status: 201 })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const { id } = await params
const body = await request.json()
const report = await db.governmentReport.update({ where: { id }, data: body })
return NextResponse.json(report)
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET(request: Request) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const schoolId = session.user.schoolId
if (!schoolId) return NextResponse.json([])
const reports = await db.governmentReport.findMany({
where: { schoolId },
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(reports)
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const role = session.user.role
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const report = await db.governmentReport.create({
data: { ...body, schoolId: session.user.schoolId!, createdBy: session.user.id },
})
return NextResponse.json(report, { status: 201 })
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { getSession } from '@/lib/auth'
export async function GET() {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const schoolId = session.user.schoolId
if (!schoolId) return NextResponse.json(null)
const config = await db.storageConfig.findUnique({ where: { schoolId } })
return NextResponse.json(config)
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
export async function POST(request: Request) {
try {
const session = await getSession()
if (!session?.user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
const role = session.user.role
if (role !== 'SCHOOL_ADMIN' && role !== 'SUPER_ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const schoolId = session.user.schoolId!
const body = await request.json()
const config = await db.storageConfig.upsert({
where: { schoolId },
update: { ...body },
create: { ...body, schoolId },
})
return NextResponse.json(config)
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 })
}
}
+4
View File
@@ -178,6 +178,7 @@ import CounselingView from './counseling-view';
import DisciplinaryView from './disciplinary-view';
import AITestsView from './ai-tests-view';
import AIStudioView from './ai-studio-view';
import EcoArchiveView from './eco-archive-view';
import ParentPortalView from './parent-portal-view';
import NotificationCenterView from './notification-center-view';
import SchoolAnnouncementsView from './school-announcements-view';
@@ -285,6 +286,7 @@ const navSections: NavSection[] = [
{ key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' },
{ key: 'districts', icon: Building2, labelKey: 'nav.districts' },
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
{ key: 'eco-archive', icon: Leaf, labelKey: 'nav.eco-archive' },
],
},
];
@@ -318,6 +320,7 @@ const studentNavSections: NavSection[] = [
{ key: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
{ key: 'eco-archive', icon: Leaf, labelKey: 'nav.eco-archive' },
],
},
];
@@ -482,6 +485,7 @@ function renderView(view: ViewName) {
case 'disciplinary': return <DisciplinaryView />;
case 'ai-tests': return <AITestsView />;
case 'ai-studio': return <AIStudioView />;
case 'eco-archive': return <EcoArchiveView />;
case 'parent-portal': return <ParentPortalView />;
case 'notification-center': return <NotificationCenterView />;
case 'announcements': return <SchoolAnnouncementsView />;
+509
View File
@@ -0,0 +1,509 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
Archive, FileText, Search, Upload, Trash2, Download, Filter,
Shield, Lock, FileCheck, Calendar, Tag, HardDrive, Cloud, RefreshCw,
Leaf, TreePine, Droplets, Wind, BarChart3, TrendingDown,
Building2, Send, CheckCircle2, Clock, AlertCircle, FileSpreadsheet,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAppStore } from '@/lib/store';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
import { de as deLocale } from 'date-fns/locale';
// ─── Eco Dashboard Tab ──────────────────────────────────────────────
function EcoDashboard() {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
apiGet('/api/eco-metrics').then((d: any) => {
setData(d);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
if (loading) return <div className="flex items-center justify-center py-8"><RefreshCw className="h-6 w-6 animate-spin text-emerald-500" /></div>;
const totals = data?.totals || { paperSavedSheets: 0, paperSavedGrams: 0, co2SavedKg: 0, waterSavedLiters: 0, treesSaved: 0 };
const ecoCards = [
{ icon: FileText, label: 'Papier gespart', value: totals.paperSavedSheets.toLocaleString('de-DE'), unit: 'Blätter', color: 'text-emerald-600 bg-emerald-50 dark:bg-emerald-950/30' },
{ icon: TreePine, label: 'Bäume gerettet', value: totals.treesSaved.toFixed(2), unit: 'Bäume', color: 'text-green-600 bg-green-50 dark:bg-green-950/30' },
{ icon: Wind, label: 'CO₂-Einsparung', value: totals.co2SavedKg.toFixed(1), unit: 'kg', color: 'text-sky-600 bg-sky-50 dark:bg-sky-950/30' },
{ icon: Droplets, label: 'Wasser gespart', value: totals.waterSavedLiters.toFixed(0), unit: 'Liter', color: 'text-blue-600 bg-blue-50 dark:bg-blue-950/30' },
];
return (
<div className="space-y-6">
{/* Hero */}
<Card className="overflow-hidden border-0 bg-gradient-to-br from-emerald-600 via-teal-600 to-emerald-700 text-white">
<CardContent className="p-6 lg:p-8">
<div className="flex items-center gap-3 mb-3">
<Leaf className="h-8 w-8" />
<h2 className="text-2xl font-bold">Umweltbeitrag der Schule</h2>
</div>
<p className="text-sm opacity-90 max-w-2xl">
Durch die Digitalisierung mit SchulOS sparen wir Papier, schützen Bäume und reduzieren den CO₂-Fußabdruck.
Jedes digitale Heft, jede digitale Mitteilung und jedes Zeugnis als PDF sp wertvolle Ressourcen.
</p>
</CardContent>
</Card>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{ecoCards.map((card, i) => {
const Icon = card.icon;
return (
<motion.div key={i} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.1 }}>
<Card>
<CardContent className="p-5">
<div className={cn('inline-flex rounded-xl p-2.5 mb-3', card.color)}>
<Icon className="h-5 w-5" />
</div>
<p className="text-2xl font-bold">{card.value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{card.unit}</p>
<p className="text-sm font-medium mt-1">{card.label}</p>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
{/* Monthly trend */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-emerald-500" />
Monatliche Papiereinsparung
</CardTitle>
<CardDescription>Blätter Papier pro Monat durch Digitalisierung</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-2 h-48">
{(data?.metrics || []).slice(-6).reverse().map((m: any, i: number) => {
const max = Math.max(...(data?.metrics || []).map((x: any) => x.paperSavedSheets), 1);
const height = (m.paperSavedSheets / max) * 100;
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-xs font-bold">{m.paperSavedSheets}</span>
<div className="w-full bg-gradient-to-t from-emerald-500 to-teal-400 rounded-t transition-all" style={{ height: `${height}%`, minHeight: '4px' }} />
<span className="text-[10px] text-muted-foreground">{format(new Date(m.date), 'MMM', { locale: deLocale })}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}
// ─── Digital Archive Tab ────────────────────────────────────────────
const DOC_TYPES = [
{ value: 'REPORT_CARD', label: 'Zeugnis' },
{ value: 'CERTIFICATE', label: 'Bescheinigung' },
{ value: 'ENROLLMENT', label: 'Einschulung' },
{ value: 'LETTER', label: 'Brief' },
{ value: 'CONTRACT', label: 'Vertrag' },
{ value: 'OTHER', label: 'Sonstiges' },
];
const DOC_CATEGORIES = [
{ value: 'STUDENT', label: 'Schüler' },
{ value: 'STAFF', label: 'Personal' },
{ value: 'FINANCE', label: 'Finanzen' },
{ value: 'ADMIN', label: 'Verwaltung' },
{ value: 'LEGAL', label: 'Recht' },
];
function DigitalArchive() {
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('ALL');
const [uploadOpen, setUploadOpen] = useState(false);
const loadDocs = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (category !== 'ALL') params.set('category', category);
const data = await apiGet(`/api/archived-documents?${params}`);
setDocuments(data || []);
} catch {
setDocuments([]);
} finally {
setLoading(false);
}
}, [search, category]);
useEffect(() => { loadDocs(); }, [loadDocs]);
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-xl font-bold flex items-center gap-2">
<Archive className="h-5 w-5 text-emerald-500" />
Digitales Archiv
</h2>
<p className="text-sm text-muted-foreground">Papierlose Dokumentenverwaltung — alles digital, alles durchsuchbar</p>
</div>
<Button onClick={() => setUploadOpen(true)}>
<Upload className="h-4 w-4 mr-2" />
Dokument archivieren
</Button>
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Volltextsuche (OCR, Titel, Tags)..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9" />
</div>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Alle Kategorien</SelectItem>
{DOC_CATEGORIES.map(c => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Documents list */}
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-12"><RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : documents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Archive className="h-10 w-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">Keine Dokumente im Archiv</p>
<p className="text-xs text-muted-foreground mt-1">Scannen Sie Dokumente ein oder laden Sie PDFs hoch</p>
</div>
) : (
<ScrollArea className="max-h-[500px]">
<div className="divide-y">
{documents.map((doc) => (
<div key={doc.id} className="flex items-center gap-3 p-4 hover:bg-muted/30 transition-colors">
<div className="rounded-lg p-2 bg-emerald-50 dark:bg-emerald-950/30 shrink-0">
<FileText className="h-5 w-5 text-emerald-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{doc.title}</p>
{doc.isConfidential && <Lock className="h-3 w-3 text-amber-500 shrink-0" />}
</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[10px]">{DOC_TYPES.find(t => t.value === doc.documentType)?.label || doc.documentType}</Badge>
<Badge variant="outline" className="text-[10px]">{DOC_CATEGORIES.find(c => c.value === doc.category)?.label || doc.category}</Badge>
<span className="text-xs text-muted-foreground">{format(new Date(doc.scanDate), 'dd.MM.yyyy')}</span>
</div>
</div>
<div className="text-right shrink-0">
<p className="text-xs text-muted-foreground">{(doc.fileSize / 1024).toFixed(0)} KB</p>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => toast.info('Download wird vorbereitet...')}>
<Download className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Upload Dialog placeholder */}
{uploadOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setUploadOpen(false)}>
<Card className="max-w-lg w-full mx-4" onClick={e => e.stopPropagation()}>
<CardHeader>
<CardTitle className="text-base">Dokument archivieren</CardTitle>
<CardDescription>Dokument einlesen und ins digitale Archiv aufnehmen</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed rounded-xl p-8 text-center">
<Upload className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm font-medium">Datei hierher ziehen oder klicken</p>
<p className="text-xs text-muted-foreground mt-1">PDF, JPG, PNG, DOCX — max 50 MB</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Typ</Label><Select><SelectTrigger className="mt-1"><SelectValue placeholder="Wählen..." /></SelectTrigger><SelectContent>{DOC_TYPES.map(t => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}</SelectContent></Select></div>
<div><Label className="text-xs">Kategorie</Label><Select><SelectTrigger className="mt-1"><SelectValue placeholder="Wählen..." /></SelectTrigger><SelectContent>{DOC_CATEGORIES.map(c => <SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>)}</SelectContent></Select></div>
</div>
</CardContent>
<div className="flex justify-end gap-2 p-4 border-t">
<Button variant="outline" onClick={() => setUploadOpen(false)}>Abbrechen</Button>
<Button onClick={() => { toast.success('Dokument archiviert'); setUploadOpen(false); }}>Archivieren</Button>
</div>
</Card>
</div>
)}
</div>
);
}
// ─── Government Portal Tab ──────────────────────────────────────────
const REPORT_TYPES = [
{ value: 'STATISTIK_SCHULAMT', label: 'Schulstatistik (Schulamt)' },
{ value: 'SCHULBESUCH', label: 'Schulbesuchsstatistik' },
{ value: 'FUERDERUNG', label: 'Fördermittelbericht' },
{ value: 'PERSONAL', label: 'Personalstatistik' },
{ value: 'BILDUNGSBERICHT', label: 'Bildungsbericht' },
];
function GovernmentPortal() {
const [reports, setReports] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await apiGet('/api/government-reports');
setReports(data || []);
} catch { setReports([]); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const statusConfig: Record<string, { color: string; icon: any; label: string }> = {
DRAFT: { color: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-900/40 dark:text-zinc-300', icon: FileText, label: 'Entwurf' },
SUBMITTED: { color: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300', icon: Send, label: 'Eingereicht' },
ACKNOWLEDGED: { color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', icon: CheckCircle2, label: 'Bestätigt' },
REJECTED: { color: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300', icon: AlertCircle, label: 'Abgelehnt' },
};
return (
<div className="space-y-4">
{/* Hero */}
<Card className="overflow-hidden border-0 bg-gradient-to-br from-slate-700 to-slate-900 text-white">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-2">
<Building2 className="h-7 w-7" />
<h2 className="text-xl font-bold">Behördenportal</h2>
</div>
<p className="text-sm opacity-90 max-w-2xl">
Vorbereitung der elektronischen Berichterstattung an Schulamt, Bildungsministerium und weitere Behörden.
Statistiken und Berichte werden digital erstellt und können exportiert werden.
</p>
<div className="flex items-center gap-2 mt-3">
<Badge className="bg-white/20 text-white border-0">Vorbereitungsphase</Badge>
<Badge className="bg-amber-500/20 text-amber-200 border-0">Manuelle Übermittlung</Badge>
</div>
</CardContent>
</Card>
{/* Reports list */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<FileSpreadsheet className="h-4 w-4 text-emerald-500" />
Berichte an Behörden
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-8"><RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : reports.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<FileSpreadsheet className="h-10 w-10 text-muted-foreground/30 mb-3" />
<p className="text-sm text-muted-foreground">Noch keine Berichte erstellt</p>
</div>
) : (
<div className="divide-y">
{reports.map(r => {
const sc = statusConfig[r.status] || statusConfig.DRAFT;
const SIcon = sc.icon;
return (
<div key={r.id} className="flex items-center gap-3 p-4 hover:bg-muted/30">
<div className={cn('rounded-lg p-2 shrink-0', sc.color)}>
<SIcon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{REPORT_TYPES.find(t => t.value === r.reportType)?.label || r.reportType}</p>
<p className="text-xs text-muted-foreground">Zeitraum: {r.reportingPeriod}</p>
</div>
<Badge variant="outline" className={cn('text-xs', sc.color)}>{sc.label}</Badge>
<span className="text-xs text-muted-foreground shrink-0">{format(new Date(r.createdAt), 'dd.MM.yyyy')}</span>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Info box */}
<Card className="border-amber-200 dark:border-amber-900/50 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-amber-700 dark:text-amber-400">Hinweis zur Behördenanbindung</p>
<p className="text-xs text-muted-foreground mt-1">
Die elektronische Anbindung an Behördenportale (z.B. BILDUNGS-Portal NRW, Schulportal Bayern)
erfordert eine offizielle Vereinbarung mit dem jeweiligen Bundesland.
SchulOS bereitet die Daten vor — die Übermittlung erfolgt aktuell manuell via Export.
Eine direkte API-Anbindung ist für eine spätere Version geplant.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
// ─── NAS/Storage Settings Tab ──────────────────────────────────────
function StorageSettings() {
const [config, setConfig] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
apiGet('/api/storage-config').then((d) => {
setConfig(d);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
try {
await apiPost('/api/storage-config', config);
toast.success('Speichereinstellungen gespeichert');
} catch {
toast.error('Fehler beim Speichern');
} finally {
setSaving(false);
}
};
if (loading) return <div className="flex items-center justify-center py-8"><RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
return (
<div className="space-y-4 max-w-2xl">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<HardDrive className="h-4 w-4 text-emerald-500" />
NAS-Server Anbindung
</CardTitle>
<CardDescription>Verbinden Sie einen lokalen NAS-Server für automatische Backups und Archivierung</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<div>
<Label className="text-sm font-medium">NAS aktivieren</Label>
<p className="text-xs text-muted-foreground">Automatische Backups auf NAS-Server</p>
</div>
<Select value={config?.nasEnabled ? 'true' : 'false'} onValueChange={(v) => setConfig({ ...config, nasEnabled: v === 'true' })}>
<SelectTrigger className="w-24"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="true">Aktiv</SelectItem><SelectItem value="false">Inaktiv</SelectItem></SelectContent>
</Select>
</div>
{config?.nasEnabled && (
<>
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs">NAS-Host</Label><Input value={config?.nasHost || ''} onChange={(e) => setConfig({ ...config, nasHost: e.target.value })} placeholder="192.168.1.100" className="mt-1" /></div>
<div><Label className="text-xs">Port</Label><Input type="number" value={config?.nasPort || 445} onChange={(e) => setConfig({ ...config, nasPort: Number(e.target.value) })} className="mt-1" /></div>
</div>
<div><Label className="text-xs">Freigabe-Pfad</Label><Input value={config?.nasShare || ''} onChange={(e) => setConfig({ ...config, nasShare: e.target.value })} placeholder="/SchulOS-Backup" className="mt-1" /></div>
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs">Benutzername</Label><Input value={config?.nasUsername || ''} onChange={(e) => setConfig({ ...config, nasUsername: e.target.value })} className="mt-1" /></div>
<div><Label className="text-xs">Passwort</Label><Input type="password" value={config?.nasPasswordEnc || ''} onChange={(e) => setConfig({ ...config, nasPasswordEnc: e.target.value })} className="mt-1" /></div>
</div>
<div><Label className="text-xs">Protokoll</Label><Select value={config?.nasProtocol || 'SMB'} onValueChange={(v) => setConfig({ ...config, nasProtocol: v })}><SelectTrigger className="mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="SMB">SMB</SelectItem><SelectItem value="NFS">NFS</SelectItem><SelectItem value="WebDAV">WebDAV</SelectItem><SelectItem value="S3">S3</SelectItem></SelectContent></Select></div>
<div><Label className="text-xs">Backup-Häufigkeit</Label><Select value={config?.backupFrequency || 'daily'} onValueChange={(v) => setConfig({ ...config, backupFrequency: v })}><SelectTrigger className="mt-1"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="hourly">Stündlich</SelectItem><SelectItem value="daily">Täglich</SelectItem><SelectItem value="weekly">Wöchentlich</SelectItem></SelectContent></Select></div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Cloud className="h-4 w-4 text-sky-500" />
Cloud-Speicher (optional)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<div>
<Label className="text-sm font-medium">Cloud-Backup aktivieren</Label>
<p className="text-xs text-muted-foreground">Zusätzliche Sicherigung in der Cloud</p>
</div>
<Select value={config?.cloudEnabled ? 'true' : 'false'} onValueChange={(v) => setConfig({ ...config, cloudEnabled: v === 'true' })}>
<SelectTrigger className="w-24"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="true">Aktiv</SelectItem><SelectItem value="false">Inaktiv</SelectItem></SelectContent>
</Select>
</div>
{config?.cloudEnabled && (
<div><Label className="text-xs">Cloud-Anbieter</Label><Select value={config?.cloudProvider || ''} onValueChange={(v) => setConfig({ ...config, cloudProvider: v })}><SelectTrigger className="mt-1"><SelectValue placeholder="Wählen..." /></SelectTrigger><SelectContent><SelectItem value="nextcloud">Nextcloud</SelectItem><SelectItem value="owncloud">ownCloud</SelectItem><SelectItem value="s3">S3-kompatibel</SelectItem></SelectContent></Select></div>
)}
</CardContent>
</Card>
<Button onClick={handleSave} disabled={saving} className="w-full">
{saving ? <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> : <Shield className="h-4 w-4 mr-2" />}
Speichereinstellungen sichern
</Button>
</div>
);
}
// ─── Main Component ─────────────────────────────────────────────────
export default function EcoArchiveView() {
const [tab, setTab] = useState('eco');
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Leaf className="h-6 w-6 text-emerald-500" />
Umwelt & Archiv
</h1>
<p className="text-sm text-muted-foreground mt-1">
Papierlose Verwaltung, digitale Archivierung und Behördenberichterstattung
</p>
</div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4">
<TabsTrigger value="eco"><Leaf className="h-4 w-4 mr-1.5" />Umwelt</TabsTrigger>
<TabsTrigger value="archive"><Archive className="h-4 w-4 mr-1.5" />Archiv</TabsTrigger>
<TabsTrigger value="government"><Building2 className="h-4 w-4 mr-1.5" />Behörden</TabsTrigger>
<TabsTrigger value="storage"><HardDrive className="h-4 w-4 mr-1.5" />Speicher</TabsTrigger>
</TabsList>
<TabsContent value="eco" className="mt-4"><EcoDashboard /></TabsContent>
<TabsContent value="archive" className="mt-4"><DigitalArchive /></TabsContent>
<TabsContent value="government" className="mt-4"><GovernmentPortal /></TabsContent>
<TabsContent value="storage" className="mt-4"><StorageSettings /></TabsContent>
</Tabs>
</div>
);
}
+2
View File
@@ -819,6 +819,7 @@ const de: TranslationDict = {
'polish.nav_insights': 'Erkenntnisse',
'polish.nav_administration': 'Verwaltung',
'nav.ai-studio': 'KI-Studio',
'nav.eco-archive': 'Umwelt & Archiv',
'action.upload': 'Hochladen',
'attendance.daily_timeline': 'Tagesverlauf',
'auth.back_to_login': 'Zurück zum Login',
@@ -7468,6 +7469,7 @@ const en: TranslationDict = {
'polish.nav_insights': 'Insights',
'polish.nav_administration': 'Administration',
'nav.ai-studio': 'AI Studio',
'nav.eco-archive': 'Environment & Archive',
'action.upload': 'Upload',
'attendance.daily_timeline': 'Daily Timeline',
'auth.back_to_login': 'Back to Login',
+1 -1
View File
@@ -31,7 +31,7 @@ const teacherViews: ViewName[] = [
'peer-assessment', 'subjects', 'competitions', 'parents',
'announcements', 'school-library', 'school-transport',
'counseling', 'student-wellness', 'student-career',
'ai-tests', 'ai-studio', 'settings', 'data-import-export',
'ai-tests', 'ai-studio', 'settings', 'data-import-export', 'eco-archive',
];
const vicePrincipalViews: ViewName[] = [
+2 -1
View File
@@ -53,7 +53,8 @@ export type ViewName =
| 'school-newsletter'
| 'school-transport'
| 'peer-assessment'
| 'ai-studio';
| 'ai-studio'
| 'eco-archive';
export interface CurrentUser {
id: string;