feat(cockpit): Modul-Baukasten – Einsatz-Cockpit frei zusammenstellbar (v1.5.0)
Jede Feuerwehr stellt ihr Cockpit selbst zusammen (Wunsch: modular & einfach): M0 – Zerlegt: - SOMA + Pendenzen als eigenständige Modul-Komponenten, gemeinsame Typen (journal/types.ts), einheitliche ModuleCard-Hülle M1 – Registry & Verwaltung: - Modul-Registry src/lib/modules.ts, Konfiguration pro Mandant in tenants.modulesConfig (JSONB, idempotente Migration) - API GET/PUT /api/tenant/modules (normalisiert + validiert via normalizeModules) - "Module"-Button im Journal (Admins): an/aus, Reihenfolge hoch/runter, eigene Module löschen M2 – Eigene Module: - Generisches Tabellen-Modul: frei definierbare Spalten (Text / Haken mit Zeitstempel / Auto-Zeit), max 8 Spalten - Generische Daten-API /api/projects/[id]/modules/[moduleId]/items (+ [itemId]), neue Tabelle module_items, Live-Sync über journal-refresh Events M3 – Feld-Vorlagen: - Checkliste, Atemschutz-Überwachung (Trupp/Druck/Zeit/Draussen), Kräfte vor Ort (Name/Funktion/seit/Weg), Lagemeldungen (Hauptbereich), leeres Modul mit Spalten-Editor Eingebaute Module (Journal/SOMA/Pendenzen) bleiben ohne Konfiguration exakt wie bisher — kaputte Configs fallen sicher auf den Standard zurück. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
14
ROADMAP.md
14
ROADMAP.md
@@ -74,6 +74,20 @@ Legende Aufwand: 🟢 klein · 🟡 mittel · 🔴 gross
|
||||
- Offen (später): Berechnungsparameter pro Mandant, mehrere Leitungen/Pumpenkette,
|
||||
Elevation-Proxy mit Cache, Ergebnis als speicherbares Lageplan-Objekt.
|
||||
|
||||
## Zusatz – Modul-Baukasten (Einsatz-Cockpit) ✅ v1.5.0
|
||||
|
||||
- [x] Journal-Monolith zerlegt: SOMA + Pendenzen als eigenständige Module,
|
||||
gemeinsame Typen, Karten-Hülle (`ModuleCard`)
|
||||
- [x] Modul-Registry (`src/lib/modules.ts`) + Konfiguration pro Mandant
|
||||
(`tenants.modulesConfig`), API `GET/PUT /api/tenant/modules`
|
||||
- [x] „Module verwalten"-Dialog: an/aus, Reihenfolge, eigene Module erstellen
|
||||
- [x] Generisches Tabellen-Modul (Spaltentypen Text/Haken/Zeit) mit eigener
|
||||
Daten-API (`module_items`), Live-Sync über journal-refresh
|
||||
- [x] Vorlagen: Checkliste, Atemschutz-Überwachung, Kräfte vor Ort,
|
||||
Lagemeldungen, leeres Modul
|
||||
- Offen (später): Übungs-Cockpit auf denselben Baukasten umstellen,
|
||||
Modul-Daten in Rapport-PDF übernehmen
|
||||
|
||||
## Phase 5 – Kür
|
||||
|
||||
- [x] **5.1 – Linien-Typ-Abfrage** 🟡 ✅
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lageplan",
|
||||
"version": "1.4.17",
|
||||
"version": "1.5.0",
|
||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -82,6 +82,8 @@ async function migrate() {
|
||||
// Journal
|
||||
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "isCorrected" BOOLEAN DEFAULT false`,
|
||||
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`,
|
||||
// Modul-Baukasten (Cockpit-Konfiguration pro Mandant)
|
||||
`ALTER TABLE tenants ADD COLUMN IF NOT EXISTS "modulesConfig" JSONB`,
|
||||
]
|
||||
let added = 0
|
||||
for (const sql of columnMigrations) {
|
||||
@@ -131,6 +133,17 @@ async function migrate() {
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE
|
||||
)`,
|
||||
// Modul-Baukasten: generische Zeilen für Tabellen-Module
|
||||
`CREATE TABLE IF NOT EXISTS module_items (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"moduleId" TEXT NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"projectId" TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS module_items_project_module_idx ON module_items("projectId", "moduleId")`,
|
||||
]
|
||||
for (const sql of tableMigrations) {
|
||||
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
||||
|
||||
@@ -74,6 +74,8 @@ model Tenant {
|
||||
notes String?
|
||||
hiddenIconIds String[] @default([])
|
||||
journalSuggestions String[] @default([])
|
||||
// Modul-Baukasten: Cockpit-Konfiguration (siehe src/lib/modules.ts). NULL = Standard.
|
||||
modulesConfig Json?
|
||||
|
||||
// Privacy consent
|
||||
privacyAccepted Boolean @default(false)
|
||||
@@ -193,6 +195,7 @@ model Project {
|
||||
journalPendenzen JournalPendenz[]
|
||||
exerciseGoals ExerciseGoal[]
|
||||
rapports Rapport[]
|
||||
moduleItems ModuleItem[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -349,6 +352,24 @@ model JournalPendenz {
|
||||
@@map("journal_pendenzen")
|
||||
}
|
||||
|
||||
// Generischer Datenspeicher für Tabellen-Module (Modul-Baukasten).
|
||||
// data enthält die Zellwerte gemäss Spaltendefinition des Moduls,
|
||||
// z.B. { "trupp": "Trupp 1", "druck": "280", "out": true, "outAt": "..." }
|
||||
model ModuleItem {
|
||||
id String @id @default(uuid())
|
||||
moduleId String
|
||||
data Json @default("{}")
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
projectId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([projectId, moduleId])
|
||||
@@map("module_items")
|
||||
}
|
||||
|
||||
model JournalCheckTemplate {
|
||||
id String @id @default(uuid())
|
||||
label String
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
/** Zeile eines Tabellen-Moduls aktualisieren (data wird gemerged) */
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; moduleId: string; itemId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, moduleId, itemId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const existing = await (prisma as any).moduleItem.findFirst({
|
||||
where: { id: itemId, projectId: id, moduleId },
|
||||
})
|
||||
if (!existing) return NextResponse.json({ error: 'Eintrag nicht gefunden' }, { status: 404 })
|
||||
|
||||
const body = await request.json()
|
||||
const patch = body && typeof body.data === 'object' && body.data !== null ? body.data : {}
|
||||
|
||||
const item = await (prisma as any).moduleItem.update({
|
||||
where: { id: itemId },
|
||||
data: { data: { ...(existing.data || {}), ...patch } },
|
||||
})
|
||||
return NextResponse.json(item)
|
||||
} catch (error) {
|
||||
console.error('Error updating module item:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Zeile eines Tabellen-Moduls löschen */
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; moduleId: string; itemId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, moduleId, itemId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
await (prisma as any).moduleItem.deleteMany({
|
||||
where: { id: itemId, projectId: id, moduleId },
|
||||
})
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting module item:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
56
src/app/api/projects/[id]/modules/[moduleId]/items/route.ts
Normal file
56
src/app/api/projects/[id]/modules/[moduleId]/items/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getProjectWithTenantCheck } from '@/lib/tenant'
|
||||
|
||||
/** Zeilen eines Tabellen-Moduls laden */
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; moduleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, moduleId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const items = await (prisma as any).moduleItem.findMany({
|
||||
where: { projectId: id, moduleId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
return NextResponse.json({ items })
|
||||
} catch (error) {
|
||||
console.error('Error fetching module items:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Neue Zeile in einem Tabellen-Modul anlegen */
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; moduleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id, moduleId } = await params
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (user.role === 'VIEWER') return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
|
||||
const project = await getProjectWithTenantCheck(id, user)
|
||||
if (!project) return NextResponse.json({ error: 'Projekt nicht gefunden' }, { status: 404 })
|
||||
|
||||
const body = await request.json()
|
||||
const data = body && typeof body.data === 'object' && body.data !== null ? body.data : {}
|
||||
|
||||
const count = await (prisma as any).moduleItem.count({ where: { projectId: id, moduleId } })
|
||||
const item = await (prisma as any).moduleItem.create({
|
||||
data: { projectId: id, moduleId, data, sortOrder: count },
|
||||
})
|
||||
return NextResponse.json(item, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating module item:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
57
src/app/api/tenant/modules/route.ts
Normal file
57
src/app/api/tenant/modules/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/db'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { normalizeModules, DEFAULT_MODULES } from '@/lib/modules'
|
||||
|
||||
/** Cockpit-Modul-Konfiguration des eigenen Mandanten lesen */
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
|
||||
if (!user.tenantId) {
|
||||
// SERVER_ADMIN ohne Mandant: Standard-Cockpit
|
||||
return NextResponse.json({ modules: DEFAULT_MODULES, canManage: false })
|
||||
}
|
||||
|
||||
const tenant = await (prisma as any).tenant.findUnique({
|
||||
where: { id: user.tenantId },
|
||||
select: { modulesConfig: true },
|
||||
})
|
||||
|
||||
const canManage = user.role === 'SERVER_ADMIN' || user.role === 'TENANT_ADMIN'
|
||||
return NextResponse.json({
|
||||
modules: normalizeModules(tenant?.modulesConfig),
|
||||
canManage,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching module config:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** Cockpit-Modul-Konfiguration speichern (nur Admins) */
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSession()
|
||||
if (!user) return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 })
|
||||
if (!user.tenantId) return NextResponse.json({ error: 'Kein Mandant zugeordnet' }, { status: 400 })
|
||||
if (user.role !== 'SERVER_ADMIN' && user.role !== 'TENANT_ADMIN') {
|
||||
return NextResponse.json({ error: 'Keine Berechtigung' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
// normalizeModules verwirft Unbekanntes und stellt eingebaute Module sicher
|
||||
const modules = normalizeModules(body?.modules)
|
||||
|
||||
await (prisma as any).tenant.update({
|
||||
where: { id: user.tenantId },
|
||||
data: { modulesConfig: modules },
|
||||
})
|
||||
|
||||
return NextResponse.json({ modules })
|
||||
} catch (error) {
|
||||
console.error('Error saving module config:', error)
|
||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,42 +3,19 @@
|
||||
import { useState, useEffect, useCallback, useRef, MutableRefObject } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Plus, Trash2, Check, Clock, CheckSquare,
|
||||
Plus, Check, Clock, CheckSquare,
|
||||
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
|
||||
Blocks, ListChecks, Shield, Users, Radio, Table as TableIcon,
|
||||
} from 'lucide-react'
|
||||
import { getSocket } from '@/lib/socket'
|
||||
import { RapportDialog } from '@/components/journal/rapport-dialog'
|
||||
|
||||
interface JournalEntry {
|
||||
id: string
|
||||
time: string
|
||||
what: string
|
||||
who: string | null
|
||||
done: boolean
|
||||
doneAt: string | null
|
||||
isCorrected?: boolean
|
||||
correctionOfId?: string | null
|
||||
}
|
||||
|
||||
interface JournalCheckItem {
|
||||
id: string
|
||||
label: string
|
||||
confirmed: boolean
|
||||
confirmedAt: string | null
|
||||
ok: boolean
|
||||
okAt: string | null
|
||||
}
|
||||
|
||||
interface JournalPendenz {
|
||||
id: string
|
||||
what: string
|
||||
who: string | null
|
||||
whenHow: string | null
|
||||
done: boolean
|
||||
doneAt: string | null
|
||||
}
|
||||
import { ModuleManagerDialog } from '@/components/journal/module-manager-dialog'
|
||||
import { SomaModule } from '@/components/journal/modules/soma-module'
|
||||
import { PendenzenModule } from '@/components/journal/modules/pendenzen-module'
|
||||
import { TableModule } from '@/components/journal/modules/table-module'
|
||||
import { DEFAULT_MODULES, type CockpitModule } from '@/lib/modules'
|
||||
import { type JournalEntry, type JournalCheckItem, type JournalPendenz, formatTime } from '@/components/journal/types'
|
||||
|
||||
interface JournalViewProps {
|
||||
projectId: string | null
|
||||
@@ -56,14 +33,38 @@ interface JournalViewProps {
|
||||
mapScreenshot?: string
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
/** Lucide-Icons für Modul-Karten (Schlüssel siehe src/lib/modules.ts) */
|
||||
const MODULE_ICONS: Record<string, typeof AlertTriangle> = {
|
||||
'clipboard-list': ClipboardList,
|
||||
'alert-triangle': AlertTriangle,
|
||||
'check-square': CheckSquare,
|
||||
'list-checks': ListChecks,
|
||||
'shield': Shield,
|
||||
'users': Users,
|
||||
'radio': Radio,
|
||||
'table': TableIcon,
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ' ' +
|
||||
d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
/** Karten-Hülle für Seitenspalten-Module — einheitliche rote Titelzeile */
|
||||
function ModuleCard({ icon, name, count, children }: {
|
||||
icon: string
|
||||
name: string
|
||||
count?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const Icon = MODULE_ICONS[icon] || TableIcon
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
|
||||
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{name}
|
||||
{count && <span className="text-[10px] text-muted-foreground font-normal">({count})</span>}
|
||||
</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function JournalView({ projectId, projectTitle, projectLocation, mode, einsatzleiter, journalfuehrer, canEdit, tenantId, einsatzNr, tenantName, tenantLogoUrl, mapRef, mapScreenshot: preCapuredScreenshot }: JournalViewProps) {
|
||||
@@ -74,18 +75,15 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const initDoneRef = useRef(false)
|
||||
|
||||
// Modul-Baukasten: Cockpit-Konfiguration des Mandanten
|
||||
const [modules, setModules] = useState<CockpitModule[]>(DEFAULT_MODULES)
|
||||
const [canManageModules, setCanManageModules] = useState(false)
|
||||
const [showModuleManager, setShowModuleManager] = useState(false)
|
||||
|
||||
// New entry form
|
||||
const [newWhat, setNewWhat] = useState('')
|
||||
const [newWho, setNewWho] = useState('')
|
||||
|
||||
// New pendenz form
|
||||
const [newPendWhat, setNewPendWhat] = useState('')
|
||||
const [newPendWho, setNewPendWho] = useState('')
|
||||
const [newPendWhen, setNewPendWhen] = useState('')
|
||||
|
||||
// New check item
|
||||
const [newCheckLabel, setNewCheckLabel] = useState('')
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Rapport creation
|
||||
@@ -147,6 +145,17 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
// Cockpit-Modul-Konfiguration laden
|
||||
useEffect(() => {
|
||||
fetch('/api/tenant/modules')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data?.modules) setModules(data.modules)
|
||||
if (data) setCanManageModules(!!data.canManage)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [tenantId])
|
||||
|
||||
// Init check items from templates if none exist (guarded against double-call)
|
||||
const initCheckItems = useCallback(async () => {
|
||||
if (!projectId || initDoneRef.current) return
|
||||
@@ -290,24 +299,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
}, [projectId, notifyJournalChanged])
|
||||
|
||||
// Add custom check item
|
||||
const addCheckItem = useCallback(async () => {
|
||||
if (!projectId || !newCheckLabel.trim()) return
|
||||
const addCheckItem = useCallback(async (label: string) => {
|
||||
if (!projectId || !label.trim()) return
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/journal/check-items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: newCheckLabel.trim(), sortOrder: checkItems.length }),
|
||||
body: JSON.stringify({ label: label.trim(), sortOrder: checkItems.length }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const item = await res.json()
|
||||
setCheckItems(prev => [...prev, item])
|
||||
setNewCheckLabel('')
|
||||
notifyJournalChanged()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add check item:', err)
|
||||
}
|
||||
}, [projectId, newCheckLabel, checkItems.length, notifyJournalChanged])
|
||||
}, [projectId, checkItems.length, notifyJournalChanged])
|
||||
|
||||
// Delete check item
|
||||
const deleteCheckItem = useCallback(async (itemId: string) => {
|
||||
@@ -322,26 +330,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
}, [projectId, notifyJournalChanged])
|
||||
|
||||
// Add pendenz
|
||||
const addPendenz = useCallback(async () => {
|
||||
if (!projectId || !newPendWhat.trim()) return
|
||||
const addPendenz = useCallback(async (what: string, who: string, whenHow: string) => {
|
||||
if (!projectId || !what.trim()) return
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/journal/pendenzen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ what: newPendWhat.trim(), who: newPendWho.trim() || null, whenHow: newPendWhen.trim() || null }),
|
||||
body: JSON.stringify({ what: what.trim(), who: who.trim() || null, whenHow: whenHow.trim() || null }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const item = await res.json()
|
||||
setPendenzen(prev => [...prev, item])
|
||||
setNewPendWhat('')
|
||||
setNewPendWho('')
|
||||
setNewPendWhen('')
|
||||
notifyJournalChanged()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add pendenz:', err)
|
||||
}
|
||||
}, [projectId, newPendWhat, newPendWho, newPendWhen, notifyJournalChanged])
|
||||
}, [projectId, notifyJournalChanged])
|
||||
|
||||
// Toggle pendenz done
|
||||
const togglePendenzDone = useCallback(async (p: JournalPendenz) => {
|
||||
@@ -417,6 +422,11 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
)
|
||||
}
|
||||
|
||||
// Aktivierte Module nach Slot aufteilen (Reihenfolge = sortOrder aus der Konfiguration)
|
||||
const enabledModules = modules.filter(m => m.enabled)
|
||||
const mainModules = enabledModules.filter(m => m.slot === 'main')
|
||||
const sideModules = enabledModules.filter(m => m.slot === 'side')
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Print styles: landscape, compact */}
|
||||
@@ -437,6 +447,12 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
{isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
|
||||
</h2>
|
||||
<div className="flex gap-1.5 print:hidden">
|
||||
{canManageModules && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowModuleManager(true)} title="Cockpit-Module verwalten">
|
||||
<Blocks className="w-4 h-4 md:mr-1.5" />
|
||||
<span className="hidden md:inline">Module</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -502,19 +518,19 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-1 text-sm print:text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatz</span>
|
||||
<p className="font-semibold truncate">{einsatzNr && <span className="font-mono text-xs bg-primary/10 text-primary px-1 py-0.5 rounded mr-1">{einsatzNr}</span>}{projectTitle || '\u2013'}</p>
|
||||
<p className="font-semibold truncate">{einsatzNr && <span className="font-mono text-xs bg-primary/10 text-primary px-1 py-0.5 rounded mr-1">{einsatzNr}</span>}{projectTitle || '–'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs print:text-[9px]">Standort</span>
|
||||
<p className="font-semibold truncate">{projectLocation || '\u2013'}</p>
|
||||
<p className="font-semibold truncate">{projectLocation || '–'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatzleiter</span>
|
||||
<p className="font-semibold truncate">{einsatzleiter || '\u2013'}</p>
|
||||
<p className="font-semibold truncate">{einsatzleiter || '–'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs print:text-[9px]">Journalführer</span>
|
||||
<p className="font-semibold truncate">{journalfuehrer || '\u2013'}</p>
|
||||
<p className="font-semibold truncate">{journalfuehrer || '–'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -543,10 +559,14 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content: side-by-side on large screens */}
|
||||
{/* Main content: side-by-side on large screens — Module gemäss Mandanten-Konfiguration */}
|
||||
<div className="flex flex-col lg:flex-row print:flex-row">
|
||||
{/* Journal entries */}
|
||||
{/* Hauptbereich: Journal + weitere main-Module */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{mainModules.map(mod => {
|
||||
if (mod.type === 'journal') {
|
||||
return (
|
||||
<div key={mod.id}>
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[55px_1fr_70px_40px_30px] md:grid-cols-[70px_1fr_90px_50px_40px] print:grid-cols-[60px_1fr_70px_40px] gap-px text-[11px] md:text-xs font-semibold sticky top-0 z-10 shadow-sm">
|
||||
<div className="bg-stone-100 dark:bg-muted px-2 py-1.5 print:py-1 print:text-[9px] text-stone-700 dark:text-foreground border-b border-red-200 dark:border-border">Zeit</div>
|
||||
@@ -589,7 +609,7 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
|
||||
{entry.who || '\u2013'}
|
||||
{entry.who || '–'}
|
||||
</div>
|
||||
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
|
||||
{canEdit && !(entry as any).isCorrected ? (
|
||||
@@ -736,178 +756,67 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Weitere main-Module (z.B. Lagemeldungen): breite Tabellen-Karte
|
||||
return (
|
||||
<div key={mod.id} className="border-t-4 border-stone-200 dark:border-border bg-white dark:bg-card">
|
||||
<ModuleCard icon={mod.icon} name={mod.name}>
|
||||
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyJournalChanged} />
|
||||
</ModuleCard>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right column: SOMA + Pendenzen */}
|
||||
{/* Seitenspalte: SOMA, Pendenzen + eigene Module */}
|
||||
{sideModules.length > 0 && (
|
||||
<div className="w-full lg:w-72 xl:w-80 print:w-[280px] border-t lg:border-t-0 lg:border-l-2 lg:border-l-red-200 dark:lg:border-l-border border-border bg-white dark:bg-card shrink-0 shadow-sm">
|
||||
{/* SOMA Checklist */}
|
||||
<div className="border-b border-border">
|
||||
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
|
||||
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
SOMA
|
||||
<span className="text-[10px] text-muted-foreground font-normal">
|
||||
({checkItems.filter(c => c.confirmed).length}/{checkItems.length})
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
{/* SOMA table with column headers */}
|
||||
<div className="grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] text-[10px] font-semibold text-muted-foreground border-b border-border/50">
|
||||
<div className="px-2 py-1"></div>
|
||||
<div className="px-0.5 py-1 text-center text-red-600">JA</div>
|
||||
<div className="px-0.5 py-1 text-center text-red-500">Ok</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{checkItems.map((item, idx) => (
|
||||
<div key={item.id} className={`grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] items-center text-xs md:text-sm print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`}>
|
||||
<div className="px-2 py-1.5 flex items-center gap-1 min-w-0">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.confirmedAt && item.confirmed && (
|
||||
<span className="text-[9px] text-red-500 shrink-0">{formatTime(item.confirmedAt)}</span>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => deleteCheckItem(item.id)}
|
||||
className="ml-auto opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 print:hidden"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1.5">
|
||||
<button
|
||||
onClick={() => canEdit && toggleCheck(item, 'confirmed')}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
item.confirmed
|
||||
? 'bg-blue-500 border-blue-500 text-white'
|
||||
: 'border-muted-foreground/30 hover:border-blue-400'
|
||||
}`}
|
||||
title={item.confirmedAt ? `JA: ${formatDateTime(item.confirmedAt)}` : 'JA'}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{item.confirmed && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1.5">
|
||||
<button
|
||||
onClick={() => canEdit && toggleCheck(item, 'ok')}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
item.ok
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: 'border-muted-foreground/30 hover:border-green-400'
|
||||
}`}
|
||||
title={item.okAt ? `Ok: ${formatDateTime(item.okAt)}` : 'Ok'}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{item.ok && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-1 px-2 py-1.5 border-t border-border/50 print:hidden">
|
||||
<Input
|
||||
placeholder="Neuer Punkt..."
|
||||
value={newCheckLabel}
|
||||
onChange={(e) => setNewCheckLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addCheckItem()}
|
||||
className="h-6 text-[11px]"
|
||||
{sideModules.map(mod => {
|
||||
if (mod.type === 'soma') {
|
||||
return (
|
||||
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name} count={`${checkItems.filter(c => c.confirmed).length}/${checkItems.length}`}>
|
||||
<SomaModule
|
||||
items={checkItems}
|
||||
canEdit={canEdit}
|
||||
onToggle={toggleCheck}
|
||||
onAdd={addCheckItem}
|
||||
onDelete={deleteCheckItem}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={addCheckItem} disabled={!newCheckLabel.trim()} className="h-6 px-1.5">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</ModuleCard>
|
||||
)
|
||||
}
|
||||
if (mod.type === 'pendenzen') {
|
||||
return (
|
||||
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name} count={`${pendenzen.filter(p => p.done).length}/${pendenzen.length}`}>
|
||||
<PendenzenModule
|
||||
items={pendenzen}
|
||||
canEdit={canEdit}
|
||||
onAdd={addPendenz}
|
||||
onToggle={togglePendenzDone}
|
||||
onDelete={deletePendenz}
|
||||
/>
|
||||
</ModuleCard>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ModuleCard key={mod.id} icon={mod.icon} name={mod.name}>
|
||||
<TableModule projectId={projectId} module={mod} canEdit={canEdit} notifyChanged={notifyJournalChanged} />
|
||||
</ModuleCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pendenzen */}
|
||||
<div>
|
||||
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
|
||||
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
|
||||
<CheckSquare className="w-3.5 h-3.5" />
|
||||
Pendenzen
|
||||
<span className="text-[10px] text-muted-foreground font-normal">
|
||||
({pendenzen.filter(p => p.done).length}/{pendenzen.length})
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
{pendenzen.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-4 text-xs">
|
||||
Keine Pendenzen
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{pendenzen.map((p) => (
|
||||
<div key={p.id} className={`flex items-start gap-1.5 px-2 py-1.5 text-xs print:text-[9px] group ${p.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : ''}`}>
|
||||
<button
|
||||
onClick={() => canEdit && togglePendenzDone(p)}
|
||||
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
p.done
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: 'border-muted-foreground/30 hover:border-primary'
|
||||
}`}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{p.done && <Check className="w-2.5 h-2.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`${p.done ? 'text-muted-foreground' : ''}`}>
|
||||
{p.what}
|
||||
{p.done && p.doneAt && (
|
||||
<span className="ml-1 text-green-600 text-[10px] print:text-[8px] font-medium no-underline">
|
||||
(erledigt um {formatTime(p.doneAt)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2 text-[10px] text-muted-foreground">
|
||||
{p.who && <span>Wer: {p.who}</span>}
|
||||
{p.whenHow && <span>Wann: {p.whenHow}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => deletePendenz(p.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-0.5 print:hidden"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="border-t border-border p-1.5 print:hidden">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
placeholder="Was..."
|
||||
value={newPendWhat}
|
||||
onChange={(e) => setNewPendWhat(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addPendenz()}
|
||||
className="flex-1 h-6 text-[11px]"
|
||||
{/* Modul-Verwaltung */}
|
||||
<ModuleManagerDialog
|
||||
open={showModuleManager}
|
||||
onOpenChange={setShowModuleManager}
|
||||
modules={modules}
|
||||
onSaved={setModules}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Wer"
|
||||
value={newPendWho}
|
||||
onChange={(e) => setNewPendWho(e.target.value)}
|
||||
className="w-14 h-6 text-[11px]"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Wann"
|
||||
value={newPendWhen}
|
||||
onChange={(e) => setNewPendWhen(e.target.value)}
|
||||
className="w-14 h-6 text-[11px]"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={addPendenz} disabled={!newPendWhat.trim()} className="h-6 px-1.5">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rapport Dialog */}
|
||||
{showRapportDialog && projectId && (
|
||||
|
||||
261
src/components/journal/module-manager-dialog.tsx
Normal file
261
src/components/journal/module-manager-dialog.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ArrowUp, ArrowDown, Plus, Trash2, Loader2, Blocks, X,
|
||||
} from 'lucide-react'
|
||||
import { MODULE_PRESETS, type CockpitModule, type ModuleColumn, type ModuleColumnType } from '@/lib/modules'
|
||||
|
||||
interface ModuleManagerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
modules: CockpitModule[]
|
||||
onSaved: (modules: CockpitModule[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Module verwalten" — der Baukasten:
|
||||
* Module ein-/ausschalten, Reihenfolge ändern, eigene Tabellen-Module
|
||||
* aus Vorlagen erstellen (Checkliste, Atemschutz, Kräfte, Lagemeldungen, leer).
|
||||
* Gespeichert wird pro Mandant (gilt für alle Einsätze).
|
||||
*/
|
||||
export function ModuleManagerDialog({ open, onOpenChange, modules, onSaved }: ModuleManagerDialogProps) {
|
||||
const [working, setWorking] = useState<CockpitModule[]>(modules)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Neues Modul aus Vorlage
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [presetKey, setPresetKey] = useState(MODULE_PRESETS[0].key)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newColumns, setNewColumns] = useState<ModuleColumn[]>(MODULE_PRESETS[0].columns)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setWorking(modules)
|
||||
setError('')
|
||||
setShowAdd(false)
|
||||
}
|
||||
}, [open, modules])
|
||||
|
||||
const move = (idx: number, dir: -1 | 1) => {
|
||||
const next = [...working]
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= next.length) return
|
||||
;[next[idx], next[target]] = [next[target], next[idx]]
|
||||
setWorking(next.map((m, i) => ({ ...m, sortOrder: i })))
|
||||
}
|
||||
|
||||
const toggle = (idx: number) => {
|
||||
setWorking(prev => prev.map((m, i) => i === idx ? { ...m, enabled: !m.enabled } : m))
|
||||
}
|
||||
|
||||
const removeCustom = (idx: number) => {
|
||||
setWorking(prev => prev.filter((_, i) => i !== idx).map((m, i) => ({ ...m, sortOrder: i })))
|
||||
}
|
||||
|
||||
const selectPreset = (key: string) => {
|
||||
const preset = MODULE_PRESETS.find(p => p.key === key)!
|
||||
setPresetKey(key)
|
||||
setNewName(preset.key === 'leer' ? '' : preset.name)
|
||||
setNewColumns(preset.columns)
|
||||
}
|
||||
|
||||
const addModule = () => {
|
||||
const preset = MODULE_PRESETS.find(p => p.key === presetKey)!
|
||||
const name = newName.trim() || preset.name
|
||||
const mod: CockpitModule = {
|
||||
id: `custom-${Date.now().toString(36)}`,
|
||||
type: 'table',
|
||||
name,
|
||||
icon: preset.icon,
|
||||
slot: preset.slot,
|
||||
enabled: true,
|
||||
sortOrder: working.length,
|
||||
columns: newColumns.filter(c => c.label.trim()),
|
||||
}
|
||||
if (!mod.columns || mod.columns.length === 0) return
|
||||
setWorking(prev => [...prev, mod])
|
||||
setShowAdd(false)
|
||||
setNewName('')
|
||||
}
|
||||
|
||||
const updateColumn = (idx: number, patch: Partial<ModuleColumn>) => {
|
||||
setNewColumns(prev => prev.map((c, i) => i === idx ? { ...c, ...patch } : c))
|
||||
}
|
||||
|
||||
const addColumn = () => {
|
||||
if (newColumns.length >= 8) return
|
||||
setNewColumns(prev => [...prev, { key: `col${prev.length}-${Date.now().toString(36).slice(-4)}`, label: '', type: 'text' }])
|
||||
}
|
||||
|
||||
const removeColumn = (idx: number) => {
|
||||
setNewColumns(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/tenant/modules', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ modules: working }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Speichern fehlgeschlagen')
|
||||
onSaved(data.modules)
|
||||
onOpenChange(false)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const COLUMN_TYPES: { value: ModuleColumnType; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'check', label: 'Haken' },
|
||||
{ value: 'time', label: 'Zeit (auto)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Blocks className="w-5 h-5" />
|
||||
Cockpit-Module verwalten
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Stelle das Einsatz-Cockpit für deine Feuerwehr zusammen. Gilt für alle Einsätze deiner Organisation.
|
||||
</p>
|
||||
|
||||
{/* Modul-Liste */}
|
||||
<div className="space-y-1.5">
|
||||
{working.map((m, idx) => (
|
||||
<div key={m.id} className={`flex items-center gap-2 rounded-lg border px-2.5 py-2 ${m.enabled ? 'bg-card' : 'bg-muted/40 opacity-60'}`}>
|
||||
{/* An/aus */}
|
||||
<button
|
||||
onClick={() => toggle(idx)}
|
||||
className={`relative w-9 h-5 rounded-full transition-colors shrink-0 ${m.enabled ? 'bg-green-500' : 'bg-muted-foreground/30'}`}
|
||||
title={m.enabled ? 'Deaktivieren' : 'Aktivieren'}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${m.enabled ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{m.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{m.type === 'table' ? `Eigenes Modul · ${m.columns?.length || 0} Spalten` : 'Eingebaut'}
|
||||
{' · '}{m.slot === 'main' ? 'Hauptbereich' : 'Seitenspalte'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button onClick={() => move(idx, -1)} disabled={idx === 0} className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-30" title="Nach oben">
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => move(idx, 1)} disabled={idx === working.length - 1} className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-30" title="Nach unten">
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{m.type === 'table' && (
|
||||
<button onClick={() => removeCustom(idx)} className="p-1 text-muted-foreground hover:text-destructive" title="Modul löschen (Daten bleiben in der DB)">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Neues Modul */}
|
||||
{!showAdd ? (
|
||||
<Button variant="outline" size="sm" onClick={() => { selectPreset(MODULE_PRESETS[0].key); setShowAdd(true) }} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Modul hinzufügen
|
||||
</Button>
|
||||
) : (
|
||||
<div className="rounded-lg border p-3 space-y-3 bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold">Neues Modul</p>
|
||||
<button onClick={() => setShowAdd(false)} className="text-muted-foreground hover:text-foreground"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
|
||||
{/* Vorlagen */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{MODULE_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => selectPreset(p.key)}
|
||||
className={`text-left rounded-md border-2 px-2 py-1.5 transition-colors ${
|
||||
presetKey === p.key ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'
|
||||
}`}
|
||||
title={p.description}
|
||||
>
|
||||
<p className="text-xs font-medium">{p.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground line-clamp-1">{p.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="z.B. Atemschutz Zug 1" className="h-8 text-sm" />
|
||||
</div>
|
||||
|
||||
{/* Spalten-Editor */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Spalten</Label>
|
||||
{newColumns.map((c, i) => (
|
||||
<div key={c.key} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={c.label}
|
||||
onChange={e => updateColumn(i, { label: e.target.value })}
|
||||
placeholder={`Spalte ${i + 1}`}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<select
|
||||
value={c.type}
|
||||
onChange={e => updateColumn(i, { type: e.target.value as ModuleColumnType })}
|
||||
className="h-7 text-xs rounded-md border border-input bg-background px-1.5"
|
||||
>
|
||||
{COLUMN_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => removeColumn(i)} disabled={newColumns.length <= 1} className="p-1 text-muted-foreground hover:text-destructive disabled:opacity-30">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{newColumns.length < 8 && (
|
||||
<button onClick={addColumn} className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" /> Spalte hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={addModule} disabled={newColumns.filter(c => c.label.trim()).length === 0} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-1" /> Modul erstellen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>Abbrechen</Button>
|
||||
<Button onClick={save} disabled={isSaving}>
|
||||
{isSaving ? <><Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> Speichern…</> : 'Speichern'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
108
src/components/journal/modules/pendenzen-module.tsx
Normal file
108
src/components/journal/modules/pendenzen-module.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, Trash2, Check } from 'lucide-react'
|
||||
import { type JournalPendenz, formatTime } from '@/components/journal/types'
|
||||
|
||||
interface PendenzenModuleProps {
|
||||
items: JournalPendenz[]
|
||||
canEdit: boolean
|
||||
onAdd: (what: string, who: string, whenHow: string) => void
|
||||
onToggle: (p: JournalPendenz) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
/** Pendenzen — eingebautes Cockpit-Modul (Was/Wer/Wann mit Erledigt-Haken) */
|
||||
export function PendenzenModule({ items, canEdit, onAdd, onToggle, onDelete }: PendenzenModuleProps) {
|
||||
const [what, setWhat] = useState('')
|
||||
const [who, setWho] = useState('')
|
||||
const [whenHow, setWhenHow] = useState('')
|
||||
|
||||
const submit = () => {
|
||||
if (!what.trim()) return
|
||||
onAdd(what.trim(), who.trim(), whenHow.trim())
|
||||
setWhat('')
|
||||
setWho('')
|
||||
setWhenHow('')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-4 text-xs">
|
||||
Keine Pendenzen
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{items.map((p) => (
|
||||
<div key={p.id} className={`flex items-start gap-1.5 px-2 py-1.5 text-xs print:text-[9px] group ${p.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : ''}`}>
|
||||
<button
|
||||
onClick={() => canEdit && onToggle(p)}
|
||||
className={`w-4 h-4 mt-0.5 rounded border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
p.done
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: 'border-muted-foreground/30 hover:border-primary'
|
||||
}`}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{p.done && <Check className="w-2.5 h-2.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`${p.done ? 'text-muted-foreground' : ''}`}>
|
||||
{p.what}
|
||||
{p.done && p.doneAt && (
|
||||
<span className="ml-1 text-green-600 text-[10px] print:text-[8px] font-medium no-underline">
|
||||
(erledigt um {formatTime(p.doneAt)})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2 text-[10px] text-muted-foreground">
|
||||
{p.who && <span>Wer: {p.who}</span>}
|
||||
{p.whenHow && <span>Wann: {p.whenHow}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => onDelete(p.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 mt-0.5 print:hidden"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="border-t border-border p-1.5 print:hidden">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
placeholder="Was..."
|
||||
value={what}
|
||||
onChange={(e) => setWhat(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
className="flex-1 h-6 text-[11px]"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Wer"
|
||||
value={who}
|
||||
onChange={(e) => setWho(e.target.value)}
|
||||
className="w-14 h-6 text-[11px]"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Wann"
|
||||
value={whenHow}
|
||||
onChange={(e) => setWhenHow(e.target.value)}
|
||||
className="w-14 h-6 text-[11px]"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={submit} disabled={!what.trim()} className="h-6 px-1.5">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
99
src/components/journal/modules/soma-module.tsx
Normal file
99
src/components/journal/modules/soma-module.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, Trash2, Check } from 'lucide-react'
|
||||
import { type JournalCheckItem, formatTime, formatDateTime } from '@/components/journal/types'
|
||||
|
||||
interface SomaModuleProps {
|
||||
items: JournalCheckItem[]
|
||||
canEdit: boolean
|
||||
onToggle: (item: JournalCheckItem, field: 'confirmed' | 'ok') => void
|
||||
onAdd: (label: string) => void
|
||||
onDelete: (itemId: string) => void
|
||||
}
|
||||
|
||||
/** SOMA-Checkliste — eingebautes Cockpit-Modul (JA/Ok mit Zeitstempel) */
|
||||
export function SomaModule({ items, canEdit, onToggle, onAdd, onDelete }: SomaModuleProps) {
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
|
||||
const submit = () => {
|
||||
if (!newLabel.trim()) return
|
||||
onAdd(newLabel.trim())
|
||||
setNewLabel('')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Spalten-Kopf */}
|
||||
<div className="grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] text-[10px] font-semibold text-muted-foreground border-b border-border/50">
|
||||
<div className="px-2 py-1"></div>
|
||||
<div className="px-0.5 py-1 text-center text-red-600">JA</div>
|
||||
<div className="px-0.5 py-1 text-center text-red-500">Ok</div>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{items.map((item, idx) => (
|
||||
<div key={item.id} className={`grid grid-cols-[1fr_28px_28px] print:grid-cols-[1fr_24px_24px] items-center text-xs md:text-sm print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`}>
|
||||
<div className="px-2 py-1.5 flex items-center gap-1 min-w-0">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.confirmedAt && item.confirmed && (
|
||||
<span className="text-[9px] text-red-500 shrink-0">{formatTime(item.confirmedAt)}</span>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="ml-auto opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive shrink-0 print:hidden"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1.5">
|
||||
<button
|
||||
onClick={() => canEdit && onToggle(item, 'confirmed')}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
item.confirmed
|
||||
? 'bg-blue-500 border-blue-500 text-white'
|
||||
: 'border-muted-foreground/30 hover:border-blue-400'
|
||||
}`}
|
||||
title={item.confirmedAt ? `JA: ${formatDateTime(item.confirmedAt)}` : 'JA'}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{item.confirmed && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1.5">
|
||||
<button
|
||||
onClick={() => canEdit && onToggle(item, 'ok')}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
item.ok
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: 'border-muted-foreground/30 hover:border-green-400'
|
||||
}`}
|
||||
title={item.okAt ? `Ok: ${formatDateTime(item.okAt)}` : 'Ok'}
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{item.ok && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex gap-1 px-2 py-1.5 border-t border-border/50 print:hidden">
|
||||
<Input
|
||||
placeholder="Neuer Punkt..."
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
className="h-6 text-[11px]"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={submit} disabled={!newLabel.trim()} className="h-6 px-1.5">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
209
src/components/journal/modules/table-module.tsx
Normal file
209
src/components/journal/modules/table-module.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, Trash2, Check, Loader2 } from 'lucide-react'
|
||||
import type { CockpitModule, ModuleColumn } from '@/lib/modules'
|
||||
|
||||
interface ModuleItemRow {
|
||||
id: string
|
||||
data: Record<string, any>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface TableModuleProps {
|
||||
projectId: string
|
||||
module: CockpitModule
|
||||
canEdit: boolean
|
||||
/** Live-Sync: andere Clients über Änderung informieren */
|
||||
notifyChanged: () => void
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Generisches Tabellen-Modul des Modul-Baukastens.
|
||||
* Rendert beliebige Spalten (text / check / time) gemäss Modul-Konfiguration.
|
||||
* Daten liegen als JSON-Zeilen in module_items (generische API).
|
||||
*/
|
||||
export function TableModule({ projectId, module, canEdit, notifyChanged }: TableModuleProps) {
|
||||
const [items, setItems] = useState<ModuleItemRow[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [draft, setDraft] = useState<Record<string, string>>({})
|
||||
|
||||
const columns: ModuleColumn[] = module.columns || []
|
||||
const textColumns = columns.filter(c => c.type === 'text')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setItems(data.items || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load module ${module.id}:`, err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [projectId, module.id])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Live-Refresh, wenn andere Clients Journal-Änderungen melden
|
||||
useEffect(() => {
|
||||
const onRefresh = () => load()
|
||||
window.addEventListener('journal-refresh', onRefresh)
|
||||
return () => window.removeEventListener('journal-refresh', onRefresh)
|
||||
}, [load])
|
||||
|
||||
const addRow = useCallback(async () => {
|
||||
const hasContent = textColumns.some(c => (draft[c.key] || '').trim())
|
||||
if (!hasContent) return
|
||||
const data: Record<string, any> = {}
|
||||
for (const c of textColumns) {
|
||||
const v = (draft[c.key] || '').trim()
|
||||
if (v) data[c.key] = v
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const item = await res.json()
|
||||
setItems(prev => [...prev, item])
|
||||
setDraft({})
|
||||
notifyChanged()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add module item:', err)
|
||||
}
|
||||
}, [projectId, module.id, draft, textColumns, notifyChanged])
|
||||
|
||||
const toggleCheck = useCallback(async (item: ModuleItemRow, key: string) => {
|
||||
if (!canEdit) return
|
||||
const next = !item.data[key]
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/modules/${module.id}/items/${item.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: { [key]: next, [`${key}At`]: next ? new Date().toISOString() : null } }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setItems(prev => prev.map(i => i.id === updated.id ? updated : i))
|
||||
notifyChanged()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle module item:', err)
|
||||
}
|
||||
}, [projectId, module.id, canEdit, notifyChanged])
|
||||
|
||||
const deleteRow = useCallback(async (itemId: string) => {
|
||||
try {
|
||||
await fetch(`/api/projects/${projectId}/modules/${module.id}/items/${itemId}`, { method: 'DELETE' })
|
||||
setItems(prev => prev.filter(i => i.id !== itemId))
|
||||
notifyChanged()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete module item:', err)
|
||||
}
|
||||
}, [projectId, module.id, notifyChanged])
|
||||
|
||||
// Grid-Template aus Spalten ableiten (+ Lösch-Spalte)
|
||||
const gridCols = columns.map(c =>
|
||||
c.type === 'check' ? '32px' : c.type === 'time' ? '52px' : 'minmax(0,1fr)'
|
||||
).join(' ') + (canEdit ? ' 24px' : '')
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Spalten-Kopf */}
|
||||
<div className="grid text-[10px] font-semibold text-muted-foreground border-b border-border/50" style={{ gridTemplateColumns: gridCols }}>
|
||||
{columns.map(c => (
|
||||
<div key={c.key} className={`px-1.5 py-1 truncate ${c.type !== 'text' ? 'text-center' : ''}`}>{c.label}</div>
|
||||
))}
|
||||
{canEdit && <div />}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4 text-muted-foreground text-xs">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin mr-1.5" /> Laden…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-3 text-xs">Keine Einträge</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{items.map((item, idx) => (
|
||||
<div key={item.id} className={`grid items-center text-xs print:text-[9px] group ${idx % 2 === 0 ? '' : 'bg-stone-50 dark:bg-muted/20'}`} style={{ gridTemplateColumns: gridCols }}>
|
||||
{columns.map(c => {
|
||||
if (c.type === 'check') {
|
||||
const checked = !!item.data[c.key]
|
||||
const at = item.data[`${c.key}At`]
|
||||
return (
|
||||
<div key={c.key} className="flex items-center justify-center py-1.5">
|
||||
<button
|
||||
onClick={() => toggleCheck(item, c.key)}
|
||||
disabled={!canEdit}
|
||||
title={at ? `${c.label}: ${formatTime(at)}` : c.label}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
checked ? 'bg-green-500 border-green-500 text-white' : 'border-muted-foreground/30 hover:border-green-400'
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (c.type === 'time') {
|
||||
// Zeit-Spalte: automatisch = Erstellzeit (oder explizit gesetzter Wert)
|
||||
const t = item.data[c.key] || item.createdAt
|
||||
return (
|
||||
<div key={c.key} className="px-1.5 py-1.5 font-mono tabular-nums text-muted-foreground text-center text-[11px]">
|
||||
{t ? formatTime(t) : '–'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={c.key} className="px-1.5 py-1.5 break-words min-w-0">
|
||||
{item.data[c.key] || <span className="text-muted-foreground">–</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => deleteRow(item.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive flex items-center justify-center print:hidden"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neue Zeile */}
|
||||
{canEdit && (
|
||||
<div className="flex gap-1 px-1.5 py-1.5 border-t border-border/50 print:hidden">
|
||||
{textColumns.map((c, i) => (
|
||||
<Input
|
||||
key={c.key}
|
||||
placeholder={c.label + '…'}
|
||||
value={draft[c.key] || ''}
|
||||
onChange={(e) => setDraft(prev => ({ ...prev, [c.key]: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addRow()}
|
||||
className={`h-6 text-[11px] ${i === 0 ? 'flex-1 min-w-0' : 'w-20 shrink-0'}`}
|
||||
/>
|
||||
))}
|
||||
<Button size="sm" variant="ghost" onClick={addRow} disabled={!textColumns.some(c => (draft[c.key] || '').trim())} className="h-6 px-1.5 shrink-0">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
src/components/journal/types.ts
Normal file
40
src/components/journal/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** Gemeinsame Typen der Cockpit-Module */
|
||||
|
||||
export interface JournalEntry {
|
||||
id: string
|
||||
time: string
|
||||
what: string
|
||||
who: string | null
|
||||
done: boolean
|
||||
doneAt: string | null
|
||||
isCorrected?: boolean
|
||||
correctionOfId?: string | null
|
||||
}
|
||||
|
||||
export interface JournalCheckItem {
|
||||
id: string
|
||||
label: string
|
||||
confirmed: boolean
|
||||
confirmedAt: string | null
|
||||
ok: boolean
|
||||
okAt: string | null
|
||||
}
|
||||
|
||||
export interface JournalPendenz {
|
||||
id: string
|
||||
what: string
|
||||
who: string | null
|
||||
whenHow: string | null
|
||||
done: boolean
|
||||
doneAt: string | null
|
||||
}
|
||||
|
||||
export function formatTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ' ' +
|
||||
d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
152
src/lib/modules.ts
Normal file
152
src/lib/modules.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Modul-Baukasten für das Einsatz-Cockpit (Journal-Bereich).
|
||||
*
|
||||
* Jede Feuerwehr stellt sich ihr Cockpit selbst zusammen:
|
||||
* - Eingebaute Module: Journal, SOMA, Pendenzen
|
||||
* - Generische Tabellen-Module ("table"): frei definierbare Spalten
|
||||
* (Checklisten, Atemschutz-Überwachung, Kräfte vor Ort, Lagemeldungen …)
|
||||
*
|
||||
* Die Konfiguration liegt pro Mandant in `tenants.modulesConfig` (JSONB).
|
||||
* Ohne Konfiguration gelten die DEFAULT_MODULES (heutiges Verhalten).
|
||||
*/
|
||||
|
||||
export type ModuleColumnType = 'text' | 'check' | 'time'
|
||||
|
||||
export interface ModuleColumn {
|
||||
key: string
|
||||
label: string
|
||||
type: ModuleColumnType
|
||||
/** Optionale Tailwind-Breite für die Spalte, z.B. 'w-20' */
|
||||
width?: string
|
||||
}
|
||||
|
||||
export type ModuleType = 'journal' | 'soma' | 'pendenzen' | 'table'
|
||||
|
||||
export interface CockpitModule {
|
||||
/** Eindeutig pro Mandant. Eingebaute Module: 'journal' | 'soma' | 'pendenzen' */
|
||||
id: string
|
||||
type: ModuleType
|
||||
name: string
|
||||
/** Lucide-Icon-Schlüssel (siehe MODULE_ICONS im Cockpit) */
|
||||
icon: string
|
||||
/** 'main' = breite Hauptspalte, 'side' = schmale Seitenspalte */
|
||||
slot: 'main' | 'side'
|
||||
enabled: boolean
|
||||
sortOrder: number
|
||||
/** Nur für type 'table': Spaltendefinition */
|
||||
columns?: ModuleColumn[]
|
||||
}
|
||||
|
||||
/** Standard-Cockpit — entspricht dem bisherigen festen Aufbau */
|
||||
export const DEFAULT_MODULES: CockpitModule[] = [
|
||||
{ id: 'journal', type: 'journal', name: 'Journal', icon: 'clipboard-list', slot: 'main', enabled: true, sortOrder: 0 },
|
||||
{ id: 'soma', type: 'soma', name: 'SOMA', icon: 'alert-triangle', slot: 'side', enabled: true, sortOrder: 1 },
|
||||
{ id: 'pendenzen', type: 'pendenzen', name: 'Pendenzen', icon: 'check-square', slot: 'side', enabled: true, sortOrder: 2 },
|
||||
]
|
||||
|
||||
/** Vorlagen für neue Tabellen-Module — ein Klick, fertig konfiguriert */
|
||||
export const MODULE_PRESETS: { key: string; name: string; icon: string; slot: 'main' | 'side'; description: string; columns: ModuleColumn[] }[] = [
|
||||
{
|
||||
key: 'checkliste',
|
||||
name: 'Checkliste',
|
||||
icon: 'list-checks',
|
||||
slot: 'side',
|
||||
description: 'Einfache Abhak-Liste (z.B. Rückzugskontrolle, Alarmstufen)',
|
||||
columns: [
|
||||
{ key: 'label', label: 'Punkt', type: 'text' },
|
||||
{ key: 'done', label: 'Ok', type: 'check', width: 'w-10' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'atemschutz',
|
||||
name: 'Atemschutz-Überwachung',
|
||||
icon: 'shield',
|
||||
slot: 'side',
|
||||
description: 'Trupps mit Druck und Zeit überwachen',
|
||||
columns: [
|
||||
{ key: 'trupp', label: 'Trupp', type: 'text' },
|
||||
{ key: 'druck', label: 'Druck (bar)', type: 'text', width: 'w-20' },
|
||||
{ key: 'start', label: 'Eingesetzt', type: 'time', width: 'w-16' },
|
||||
{ key: 'out', label: 'Draussen', type: 'check', width: 'w-10' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'kraefte',
|
||||
name: 'Kräfte vor Ort',
|
||||
icon: 'users',
|
||||
slot: 'side',
|
||||
description: 'Wer/was ist am Einsatzort (AdF, Fahrzeuge, Partner)',
|
||||
columns: [
|
||||
{ key: 'name', label: 'Name / Mittel', type: 'text' },
|
||||
{ key: 'funktion', label: 'Funktion', type: 'text', width: 'w-24' },
|
||||
{ key: 'seit', label: 'Vor Ort seit', type: 'time', width: 'w-16' },
|
||||
{ key: 'abgemeldet', label: 'Weg', type: 'check', width: 'w-10' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'lagemeldung',
|
||||
name: 'Lagemeldungen',
|
||||
icon: 'radio',
|
||||
slot: 'main',
|
||||
description: 'Strukturierte Lagemeldungen (Zeit / Lage / Massnahmen / Bedarf)',
|
||||
columns: [
|
||||
{ key: 'zeit', label: 'Zeit', type: 'time', width: 'w-16' },
|
||||
{ key: 'lage', label: 'Lage', type: 'text' },
|
||||
{ key: 'massnahmen', label: 'Massnahmen', type: 'text' },
|
||||
{ key: 'bedarf', label: 'Bedarf', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'leer',
|
||||
name: 'Eigenes Modul',
|
||||
icon: 'table',
|
||||
slot: 'side',
|
||||
description: 'Leere Tabelle — Spalten komplett selbst definieren',
|
||||
columns: [
|
||||
{ key: 'text', label: 'Text', type: 'text' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Konfiguration validieren/reparieren: eingebaute Module sicherstellen,
|
||||
* Unbekanntes verwerfen, stabil sortieren.
|
||||
*/
|
||||
export function normalizeModules(raw: unknown): CockpitModule[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return DEFAULT_MODULES
|
||||
|
||||
const seen = new Set<string>()
|
||||
const result: CockpitModule[] = []
|
||||
|
||||
for (const m of raw as any[]) {
|
||||
if (!m || typeof m.id !== 'string' || seen.has(m.id)) continue
|
||||
const type: ModuleType = ['journal', 'soma', 'pendenzen', 'table'].includes(m.type) ? m.type : 'table'
|
||||
if (type === 'table' && (!Array.isArray(m.columns) || m.columns.length === 0)) continue
|
||||
seen.add(m.id)
|
||||
result.push({
|
||||
id: m.id,
|
||||
type,
|
||||
name: typeof m.name === 'string' && m.name.trim() ? m.name.trim().slice(0, 60) : 'Modul',
|
||||
icon: typeof m.icon === 'string' ? m.icon : 'table',
|
||||
slot: m.slot === 'main' ? 'main' : 'side',
|
||||
enabled: m.enabled !== false,
|
||||
sortOrder: typeof m.sortOrder === 'number' ? m.sortOrder : result.length,
|
||||
columns: type === 'table'
|
||||
? (m.columns as any[]).slice(0, 8).map((c, i) => ({
|
||||
key: typeof c.key === 'string' && c.key ? c.key : `col${i}`,
|
||||
label: typeof c.label === 'string' ? c.label.slice(0, 40) : `Spalte ${i + 1}`,
|
||||
type: (['text', 'check', 'time'] as const).includes(c.type) ? c.type : 'text',
|
||||
width: typeof c.width === 'string' ? c.width : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// Eingebaute Module ergänzen, falls sie in der Config fehlen (dann deaktiviert lassen? → aktiv,
|
||||
// damit ein kaputter Config-Stand nie das Journal verschwinden lässt)
|
||||
for (const def of DEFAULT_MODULES) {
|
||||
if (!seen.has(def.id)) result.push({ ...def, sortOrder: result.length })
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
}
|
||||
Reference in New Issue
Block a user