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:
1 parent
59de7b692b
commit
231d8449e7
12 files changed
+851
-2
No files matched your search
@@ -36,6 +36,10 @@ model School {
|
|||||||
|
|
||||||
users User[]
|
users User[]
|
||||||
schoolYears SchoolYear[]
|
schoolYears SchoolYear[]
|
||||||
|
archivedDocuments ArchivedDocument[]
|
||||||
|
governmentReports GovernmentReport[]
|
||||||
|
ecoMetrics EcoMetric[]
|
||||||
|
storageConfig StorageConfig?
|
||||||
classGroups ClassGroup[]
|
classGroups ClassGroup[]
|
||||||
students Student[]
|
students Student[]
|
||||||
subjects Subject[]
|
subjects Subject[]
|
||||||
@@ -235,6 +239,8 @@ model User {
|
|||||||
dashboardWidgets DashboardWidget[]
|
dashboardWidgets DashboardWidget[]
|
||||||
dashboardLayouts DashboardLayout[]
|
dashboardLayouts DashboardLayout[]
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
|
uploadedDocuments ArchivedDocument[] @relation("DocumentUploader")
|
||||||
|
createdReports GovernmentReport[] @relation("ReportCreator")
|
||||||
twoFactorRecoveryCodes TwoFactorRecoveryCode[]
|
twoFactorRecoveryCodes TwoFactorRecoveryCode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3558,3 +3564,108 @@ model DashboardLayout {
|
|||||||
@@map("dashboard_layouts")
|
@@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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,7 @@ import CounselingView from './counseling-view';
|
|||||||
import DisciplinaryView from './disciplinary-view';
|
import DisciplinaryView from './disciplinary-view';
|
||||||
import AITestsView from './ai-tests-view';
|
import AITestsView from './ai-tests-view';
|
||||||
import AIStudioView from './ai-studio-view';
|
import AIStudioView from './ai-studio-view';
|
||||||
|
import EcoArchiveView from './eco-archive-view';
|
||||||
import ParentPortalView from './parent-portal-view';
|
import ParentPortalView from './parent-portal-view';
|
||||||
import NotificationCenterView from './notification-center-view';
|
import NotificationCenterView from './notification-center-view';
|
||||||
import SchoolAnnouncementsView from './school-announcements-view';
|
import SchoolAnnouncementsView from './school-announcements-view';
|
||||||
@@ -285,6 +286,7 @@ const navSections: NavSection[] = [
|
|||||||
{ key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' },
|
{ key: 'competencies', icon: BookOpen, labelKey: 'nav.competencies' },
|
||||||
{ key: 'districts', icon: Building2, labelKey: 'nav.districts' },
|
{ key: 'districts', icon: Building2, labelKey: 'nav.districts' },
|
||||||
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
|
{ 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: 'notification-center', icon: Bell, labelKey: 'nav.notification_center' },
|
||||||
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
{ key: 'announcements', icon: Megaphone, labelKey: 'nav.announcements' },
|
||||||
{ key: 'settings', icon: Settings, labelKey: 'nav.settings' },
|
{ 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 'disciplinary': return <DisciplinaryView />;
|
||||||
case 'ai-tests': return <AITestsView />;
|
case 'ai-tests': return <AITestsView />;
|
||||||
case 'ai-studio': return <AIStudioView />;
|
case 'ai-studio': return <AIStudioView />;
|
||||||
|
case 'eco-archive': return <EcoArchiveView />;
|
||||||
case 'parent-portal': return <ParentPortalView />;
|
case 'parent-portal': return <ParentPortalView />;
|
||||||
case 'notification-center': return <NotificationCenterView />;
|
case 'notification-center': return <NotificationCenterView />;
|
||||||
case 'announcements': return <SchoolAnnouncementsView />;
|
case 'announcements': return <SchoolAnnouncementsView />;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -819,6 +819,7 @@ const de: TranslationDict = {
|
|||||||
'polish.nav_insights': 'Erkenntnisse',
|
'polish.nav_insights': 'Erkenntnisse',
|
||||||
'polish.nav_administration': 'Verwaltung',
|
'polish.nav_administration': 'Verwaltung',
|
||||||
'nav.ai-studio': 'KI-Studio',
|
'nav.ai-studio': 'KI-Studio',
|
||||||
|
'nav.eco-archive': 'Umwelt & Archiv',
|
||||||
'action.upload': 'Hochladen',
|
'action.upload': 'Hochladen',
|
||||||
'attendance.daily_timeline': 'Tagesverlauf',
|
'attendance.daily_timeline': 'Tagesverlauf',
|
||||||
'auth.back_to_login': 'Zurück zum Login',
|
'auth.back_to_login': 'Zurück zum Login',
|
||||||
@@ -7468,6 +7469,7 @@ const en: TranslationDict = {
|
|||||||
'polish.nav_insights': 'Insights',
|
'polish.nav_insights': 'Insights',
|
||||||
'polish.nav_administration': 'Administration',
|
'polish.nav_administration': 'Administration',
|
||||||
'nav.ai-studio': 'AI Studio',
|
'nav.ai-studio': 'AI Studio',
|
||||||
|
'nav.eco-archive': 'Environment & Archive',
|
||||||
'action.upload': 'Upload',
|
'action.upload': 'Upload',
|
||||||
'attendance.daily_timeline': 'Daily Timeline',
|
'attendance.daily_timeline': 'Daily Timeline',
|
||||||
'auth.back_to_login': 'Back to Login',
|
'auth.back_to_login': 'Back to Login',
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const teacherViews: ViewName[] = [
|
|||||||
'peer-assessment', 'subjects', 'competitions', 'parents',
|
'peer-assessment', 'subjects', 'competitions', 'parents',
|
||||||
'announcements', 'school-library', 'school-transport',
|
'announcements', 'school-library', 'school-transport',
|
||||||
'counseling', 'student-wellness', 'student-career',
|
'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[] = [
|
const vicePrincipalViews: ViewName[] = [
|
||||||
|
|||||||
+2
-1
@@ -53,7 +53,8 @@ export type ViewName =
|
|||||||
| 'school-newsletter'
|
| 'school-newsletter'
|
||||||
| 'school-transport'
|
| 'school-transport'
|
||||||
| 'peer-assessment'
|
| 'peer-assessment'
|
||||||
| 'ai-studio';
|
| 'ai-studio'
|
||||||
|
| 'eco-archive';
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in new issue
Block a user