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,
|
- Offen (später): Berechnungsparameter pro Mandant, mehrere Leitungen/Pumpenkette,
|
||||||
Elevation-Proxy mit Cache, Ergebnis als speicherbares Lageplan-Objekt.
|
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
|
## Phase 5 – Kür
|
||||||
|
|
||||||
- [x] **5.1 – Linien-Typ-Abfrage** 🟡 ✅
|
- [x] **5.1 – Linien-Typ-Abfrage** 🟡 ✅
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lageplan",
|
"name": "lageplan",
|
||||||
"version": "1.4.17",
|
"version": "1.5.0",
|
||||||
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
"description": "Feuerwehr Lageplan - Krokier-App für Einsatzdokumentation",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ async function migrate() {
|
|||||||
// Journal
|
// Journal
|
||||||
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "isCorrected" BOOLEAN DEFAULT false`,
|
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "isCorrected" BOOLEAN DEFAULT false`,
|
||||||
`ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS "correctionOfId" TEXT`,
|
`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
|
let added = 0
|
||||||
for (const sql of columnMigrations) {
|
for (const sql of columnMigrations) {
|
||||||
@@ -131,6 +133,17 @@ async function migrate() {
|
|||||||
"updatedAt" 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
|
"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) {
|
for (const sql of tableMigrations) {
|
||||||
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
try { await prisma.$executeRawUnsafe(sql) } catch (e) { /* table might already exist */ }
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ model Tenant {
|
|||||||
notes String?
|
notes String?
|
||||||
hiddenIconIds String[] @default([])
|
hiddenIconIds String[] @default([])
|
||||||
journalSuggestions String[] @default([])
|
journalSuggestions String[] @default([])
|
||||||
|
// Modul-Baukasten: Cockpit-Konfiguration (siehe src/lib/modules.ts). NULL = Standard.
|
||||||
|
modulesConfig Json?
|
||||||
|
|
||||||
// Privacy consent
|
// Privacy consent
|
||||||
privacyAccepted Boolean @default(false)
|
privacyAccepted Boolean @default(false)
|
||||||
@@ -193,6 +195,7 @@ model Project {
|
|||||||
journalPendenzen JournalPendenz[]
|
journalPendenzen JournalPendenz[]
|
||||||
exerciseGoals ExerciseGoal[]
|
exerciseGoals ExerciseGoal[]
|
||||||
rapports Rapport[]
|
rapports Rapport[]
|
||||||
|
moduleItems ModuleItem[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
@@ -349,6 +352,24 @@ model JournalPendenz {
|
|||||||
@@map("journal_pendenzen")
|
@@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 {
|
model JournalCheckTemplate {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
label String
|
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 { useState, useEffect, useCallback, useRef, MutableRefObject } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import {
|
import {
|
||||||
Plus, Trash2, Check, Clock, CheckSquare,
|
Plus, Check, Clock, CheckSquare,
|
||||||
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
|
AlertTriangle, ClipboardList, Loader2, Printer, Pencil, Send, FileText,
|
||||||
|
Blocks, ListChecks, Shield, Users, Radio, Table as TableIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getSocket } from '@/lib/socket'
|
import { getSocket } from '@/lib/socket'
|
||||||
import { RapportDialog } from '@/components/journal/rapport-dialog'
|
import { RapportDialog } from '@/components/journal/rapport-dialog'
|
||||||
|
import { ModuleManagerDialog } from '@/components/journal/module-manager-dialog'
|
||||||
interface JournalEntry {
|
import { SomaModule } from '@/components/journal/modules/soma-module'
|
||||||
id: string
|
import { PendenzenModule } from '@/components/journal/modules/pendenzen-module'
|
||||||
time: string
|
import { TableModule } from '@/components/journal/modules/table-module'
|
||||||
what: string
|
import { DEFAULT_MODULES, type CockpitModule } from '@/lib/modules'
|
||||||
who: string | null
|
import { type JournalEntry, type JournalCheckItem, type JournalPendenz, formatTime } from '@/components/journal/types'
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JournalViewProps {
|
interface JournalViewProps {
|
||||||
projectId: string | null
|
projectId: string | null
|
||||||
@@ -56,14 +33,38 @@ interface JournalViewProps {
|
|||||||
mapScreenshot?: string
|
mapScreenshot?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(dateStr: string) {
|
/** Lucide-Icons für Modul-Karten (Schlüssel siehe src/lib/modules.ts) */
|
||||||
return new Date(dateStr).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
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) {
|
/** Karten-Hülle für Seitenspalten-Module — einheitliche rote Titelzeile */
|
||||||
const d = new Date(dateStr)
|
function ModuleCard({ icon, name, count, children }: {
|
||||||
return d.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' }) + ' ' +
|
icon: string
|
||||||
d.toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })
|
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) {
|
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 [isLoading, setIsLoading] = useState(false)
|
||||||
const initDoneRef = useRef(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
|
// New entry form
|
||||||
const [newWhat, setNewWhat] = useState('')
|
const [newWhat, setNewWhat] = useState('')
|
||||||
const [newWho, setNewWho] = 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)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Rapport creation
|
// Rapport creation
|
||||||
@@ -147,6 +145,17 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
|||||||
}
|
}
|
||||||
}, [projectId])
|
}, [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)
|
// Init check items from templates if none exist (guarded against double-call)
|
||||||
const initCheckItems = useCallback(async () => {
|
const initCheckItems = useCallback(async () => {
|
||||||
if (!projectId || initDoneRef.current) return
|
if (!projectId || initDoneRef.current) return
|
||||||
@@ -290,24 +299,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
|||||||
}, [projectId, notifyJournalChanged])
|
}, [projectId, notifyJournalChanged])
|
||||||
|
|
||||||
// Add custom check item
|
// Add custom check item
|
||||||
const addCheckItem = useCallback(async () => {
|
const addCheckItem = useCallback(async (label: string) => {
|
||||||
if (!projectId || !newCheckLabel.trim()) return
|
if (!projectId || !label.trim()) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${projectId}/journal/check-items`, {
|
const res = await fetch(`/api/projects/${projectId}/journal/check-items`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (res.ok) {
|
||||||
const item = await res.json()
|
const item = await res.json()
|
||||||
setCheckItems(prev => [...prev, item])
|
setCheckItems(prev => [...prev, item])
|
||||||
setNewCheckLabel('')
|
|
||||||
notifyJournalChanged()
|
notifyJournalChanged()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add check item:', err)
|
console.error('Failed to add check item:', err)
|
||||||
}
|
}
|
||||||
}, [projectId, newCheckLabel, checkItems.length, notifyJournalChanged])
|
}, [projectId, checkItems.length, notifyJournalChanged])
|
||||||
|
|
||||||
// Delete check item
|
// Delete check item
|
||||||
const deleteCheckItem = useCallback(async (itemId: string) => {
|
const deleteCheckItem = useCallback(async (itemId: string) => {
|
||||||
@@ -322,26 +330,23 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
|||||||
}, [projectId, notifyJournalChanged])
|
}, [projectId, notifyJournalChanged])
|
||||||
|
|
||||||
// Add pendenz
|
// Add pendenz
|
||||||
const addPendenz = useCallback(async () => {
|
const addPendenz = useCallback(async (what: string, who: string, whenHow: string) => {
|
||||||
if (!projectId || !newPendWhat.trim()) return
|
if (!projectId || !what.trim()) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/projects/${projectId}/journal/pendenzen`, {
|
const res = await fetch(`/api/projects/${projectId}/journal/pendenzen`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (res.ok) {
|
||||||
const item = await res.json()
|
const item = await res.json()
|
||||||
setPendenzen(prev => [...prev, item])
|
setPendenzen(prev => [...prev, item])
|
||||||
setNewPendWhat('')
|
|
||||||
setNewPendWho('')
|
|
||||||
setNewPendWhen('')
|
|
||||||
notifyJournalChanged()
|
notifyJournalChanged()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add pendenz:', err)
|
console.error('Failed to add pendenz:', err)
|
||||||
}
|
}
|
||||||
}, [projectId, newPendWhat, newPendWho, newPendWhen, notifyJournalChanged])
|
}, [projectId, notifyJournalChanged])
|
||||||
|
|
||||||
// Toggle pendenz done
|
// Toggle pendenz done
|
||||||
const togglePendenzDone = useCallback(async (p: JournalPendenz) => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Print styles: landscape, compact */}
|
{/* Print styles: landscape, compact */}
|
||||||
@@ -437,6 +447,12 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
|||||||
{isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
|
{isUebung ? 'Übungs-Journal' : 'Einsatz-Journal'}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-1.5 print:hidden">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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 className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-1 text-sm print:text-xs">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatz</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground text-xs print:text-[9px]">Standort</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground text-xs print:text-[9px]">Einsatzleiter</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground text-xs print:text-[9px]">Journalführer</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -543,372 +559,265 @@ export function JournalView({ projectId, projectTitle, projectLocation, mode, ei
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Table header */}
|
{mainModules.map(mod => {
|
||||||
<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">
|
if (mod.type === 'journal') {
|
||||||
<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>
|
return (
|
||||||
<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">Was</div>
|
<div key={mod.id}>
|
||||||
<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">Wer</div>
|
{/* Table header */}
|
||||||
<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 text-center border-b border-red-200 dark:border-border">Ok</div>
|
<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-1 py-1.5 print:hidden border-b border-red-200 dark:border-border"></div>
|
<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>
|
||||||
</div>
|
<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">Was</div>
|
||||||
|
<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">Wer</div>
|
||||||
|
<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 text-center border-b border-red-200 dark:border-border">Ok</div>
|
||||||
|
<div className="bg-stone-100 dark:bg-muted px-1 py-1.5 print:hidden border-b border-red-200 dark:border-border"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||||
Lade Journal...
|
Lade Journal...
|
||||||
</div>
|
</div>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-8 text-sm">
|
<div className="text-center text-muted-foreground py-8 text-sm">
|
||||||
Noch keine Einträge.
|
Noch keine Einträge.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{entries.map((entry, idx) => (
|
{entries.map((entry, idx) => (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
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 print:text-[9px] ${entry.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : idx % 2 === 0 ? 'bg-white dark:bg-card' : 'bg-stone-50 dark:bg-muted/30'}`}
|
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 print:text-[9px] ${entry.done ? 'bg-green-50 dark:bg-green-950/20 print:bg-green-50' : idx % 2 === 0 ? 'bg-white dark:bg-card' : 'bg-stone-50 dark:bg-muted/30'}`}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1.5 print:py-1 font-mono tabular-nums text-muted-foreground">
|
<div className="px-2 py-1.5 print:py-1 font-mono tabular-nums text-muted-foreground">
|
||||||
{formatTime(entry.time)}
|
{formatTime(entry.time)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`px-2 py-1.5 print:py-1 break-words ${entry.done ? 'text-muted-foreground' : ''} ${(entry as any).isCorrected ? 'line-through opacity-50' : ''} ${(entry as any).correctionOfId ? 'text-amber-700 dark:text-amber-400 italic' : ''}`}>
|
<div className={`px-2 py-1.5 print:py-1 break-words ${entry.done ? 'text-muted-foreground' : ''} ${(entry as any).isCorrected ? 'line-through opacity-50' : ''} ${(entry as any).correctionOfId ? 'text-amber-700 dark:text-amber-400 italic' : ''}`}>
|
||||||
{entry.what}
|
{entry.what}
|
||||||
{(entry as any).isCorrected && (
|
{(entry as any).isCorrected && (
|
||||||
<span className="ml-1.5 text-red-500 text-[10px] print:text-[8px] font-medium no-underline">
|
<span className="ml-1.5 text-red-500 text-[10px] print:text-[8px] font-medium no-underline">
|
||||||
(korrigiert)
|
(korrigiert)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.done && entry.doneAt && (
|
{entry.done && entry.doneAt && (
|
||||||
<span className="ml-1.5 text-green-600 text-[10px] print:text-[8px] font-medium">
|
<span className="ml-1.5 text-green-600 text-[10px] print:text-[8px] font-medium">
|
||||||
(erledigt um {formatTime(entry.doneAt)})
|
(erledigt um {formatTime(entry.doneAt)})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
|
<div className="px-2 py-1.5 print:py-1 text-muted-foreground truncate">
|
||||||
{entry.who || '\u2013'}
|
{entry.who || '–'}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
|
<div className="px-1 py-1.5 print:py-1 flex items-center justify-center">
|
||||||
{canEdit && !(entry as any).isCorrected ? (
|
{canEdit && !(entry as any).isCorrected ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleEntryDone(entry)}
|
onClick={() => toggleEntryDone(entry)}
|
||||||
className={`w-4 h-4 md:w-5 md:h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
className={`w-4 h-4 md:w-5 md:h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||||
entry.done
|
entry.done
|
||||||
? 'bg-green-500 border-green-500 text-white'
|
? 'bg-green-500 border-green-500 text-white'
|
||||||
: 'border-muted-foreground/30 hover:border-primary'
|
: 'border-muted-foreground/30 hover:border-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{entry.done && <Check className="w-2.5 h-2.5 md:w-3 md:h-3" />}
|
{entry.done && <Check className="w-2.5 h-2.5 md:w-3 md:h-3" />}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
entry.done ? <Check className="w-3 h-3 text-green-500 opacity-40" /> : (entry as any).isCorrected ? <span className="w-4 h-4 md:w-5 md:h-5 rounded border-2 border-muted-foreground/20 bg-muted/30" /> : null
|
entry.done ? <Check className="w-3 h-3 text-green-500 opacity-40" /> : (entry as any).isCorrected ? <span className="w-4 h-4 md:w-5 md:h-5 rounded border-2 border-muted-foreground/20 bg-muted/30" /> : null
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1 py-1.5 flex items-center justify-center print:hidden">
|
<div className="px-1 py-1.5 flex items-center justify-center print:hidden">
|
||||||
{canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && (
|
{canEdit && !(entry as any).isCorrected && !(entry as any).correctionOfId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setCorrectionEntryId(entry.id); setCorrectionText(entry.what) }}
|
onClick={() => { setCorrectionEntryId(entry.id); setCorrectionText(entry.what) }}
|
||||||
className="text-muted-foreground hover:text-amber-600 p-0.5"
|
className="text-muted-foreground hover:text-amber-600 p-0.5"
|
||||||
title="Korrektur erstellen"
|
title="Korrektur erstellen"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Correction inline form */}
|
{/* Correction inline form */}
|
||||||
{correctionEntryId && (
|
{correctionEntryId && (
|
||||||
<div className="border-t-2 border-amber-400 p-2 bg-amber-50 dark:bg-amber-950/30 print:hidden">
|
<div className="border-t-2 border-amber-400 p-2 bg-amber-50 dark:bg-amber-950/30 print:hidden">
|
||||||
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1.5">Korrektur erstellen:</p>
|
<p className="text-xs font-semibold text-amber-700 dark:text-amber-400 mb-1.5">Korrektur erstellen:</p>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Korrekturtext..."
|
placeholder="Korrekturtext..."
|
||||||
value={correctionText}
|
value={correctionText}
|
||||||
onChange={(e) => setCorrectionText(e.target.value)}
|
onChange={(e) => setCorrectionText(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && correctEntry()}
|
onKeyDown={(e) => e.key === 'Enter' && correctEntry()}
|
||||||
className="flex-1 h-7 text-xs border-amber-300"
|
className="flex-1 h-7 text-xs border-amber-300"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onClick={correctEntry} disabled={!correctionText.trim()} className="h-7 px-2 bg-amber-600 hover:bg-amber-700">
|
<Button size="sm" onClick={correctEntry} disabled={!correctionText.trim()} className="h-7 px-2 bg-amber-600 hover:bg-amber-700">
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => { setCorrectionEntryId(null); setCorrectionText('') }} className="h-7 px-2">
|
<Button size="sm" variant="outline" onClick={() => { setCorrectionEntryId(null); setCorrectionText('') }} className="h-7 px-2">
|
||||||
✕
|
✕
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* New entry form */}
|
|
||||||
{canEdit && (
|
|
||||||
<div className="border-t border-border p-2 bg-white dark:bg-card print:hidden sticky bottom-0 shadow-[0_-2px_4px_rgba(0,0,0,0.05)]">
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono w-[55px] md:w-[70px] shrink-0">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
{/* iPhone-style suggestion bar */}
|
|
||||||
{showSuggestions && filteredSuggestions.length > 0 && (
|
|
||||||
<div ref={suggestionsRef} className="absolute bottom-full left-0 right-0 mb-1 z-50">
|
|
||||||
<div className="flex gap-1 overflow-x-auto scrollbar-hide py-1 px-0.5">
|
|
||||||
{filteredSuggestions.map((s, i) => {
|
|
||||||
const lower = newWhat.toLowerCase()
|
|
||||||
const idx = s.toLowerCase().indexOf(lower)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
className={`shrink-0 px-2.5 py-1 text-xs rounded-full border transition-all whitespace-nowrap ${
|
|
||||||
i === selectedSuggestionIdx
|
|
||||||
? 'bg-red-600 text-white border-red-600'
|
|
||||||
: 'bg-white dark:bg-card border-border text-foreground hover:bg-red-50 hover:border-red-200 shadow-sm'
|
|
||||||
}`}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setNewWhat(s)
|
|
||||||
setShowSuggestions(false)
|
|
||||||
setSelectedSuggestionIdx(-1)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{idx >= 0 ? (
|
|
||||||
<>{s.slice(0, idx)}<strong className="font-bold">{s.slice(idx, idx + lower.length)}</strong>{s.slice(idx + lower.length)}</>
|
|
||||||
) : s}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Input
|
|
||||||
placeholder="Was..."
|
|
||||||
value={newWhat}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNewWhat(e.target.value)
|
|
||||||
filterSuggestions(e.target.value)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (showSuggestions && filteredSuggestions.length > 0) {
|
|
||||||
if (e.key === 'ArrowRight' || e.key === 'Tab') {
|
|
||||||
e.preventDefault()
|
|
||||||
setSelectedSuggestionIdx(prev => (prev + 1) % filteredSuggestions.length)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault()
|
|
||||||
setSelectedSuggestionIdx(prev => prev <= 0 ? filteredSuggestions.length - 1 : prev - 1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter' && selectedSuggestionIdx >= 0) {
|
|
||||||
e.preventDefault()
|
|
||||||
setNewWhat(filteredSuggestions[selectedSuggestionIdx])
|
|
||||||
setShowSuggestions(false)
|
|
||||||
setSelectedSuggestionIdx(-1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter') { setShowSuggestions(false); addEntry() }
|
|
||||||
if (e.key === 'Escape') { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }
|
|
||||||
}}
|
|
||||||
onFocus={() => filterSuggestions(newWhat)}
|
|
||||||
onBlur={() => setTimeout(() => { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }, 150)}
|
|
||||||
className="h-7 text-xs w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder="Wer"
|
|
||||||
value={newWho}
|
|
||||||
onChange={(e) => setNewWho(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && addEntry()}
|
|
||||||
className="w-16 md:w-20 h-7 text-xs"
|
|
||||||
/>
|
|
||||||
<Button size="sm" onClick={addEntry} disabled={!newWhat.trim()} className="h-7 px-2">
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right column: SOMA + Pendenzen */}
|
{/* New entry form */}
|
||||||
<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">
|
{canEdit && (
|
||||||
{/* SOMA Checklist */}
|
<div className="border-t border-border p-2 bg-white dark:bg-card print:hidden sticky bottom-0 shadow-[0_-2px_4px_rgba(0,0,0,0.05)]">
|
||||||
<div className="border-b border-border">
|
<div className="flex gap-1.5">
|
||||||
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
|
<div className="flex items-center gap-1 text-[11px] text-muted-foreground font-mono w-[55px] md:w-[70px] shrink-0">
|
||||||
<h3 className="font-semibold text-xs md:text-sm print:text-[10px] flex items-center gap-1 text-red-800 dark:text-red-400">
|
<Clock className="w-3 h-3" />
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
{new Date().toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}
|
||||||
SOMA
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground font-normal">
|
<div className="flex-1 relative">
|
||||||
({checkItems.filter(c => c.confirmed).length}/{checkItems.length})
|
{/* iPhone-style suggestion bar */}
|
||||||
</span>
|
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||||
</h3>
|
<div ref={suggestionsRef} className="absolute bottom-full left-0 right-0 mb-1 z-50">
|
||||||
</div>
|
<div className="flex gap-1 overflow-x-auto scrollbar-hide py-1 px-0.5">
|
||||||
{/* SOMA table with column headers */}
|
{filteredSuggestions.map((s, i) => {
|
||||||
<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">
|
const lower = newWhat.toLowerCase()
|
||||||
<div className="px-2 py-1"></div>
|
const idx = s.toLowerCase().indexOf(lower)
|
||||||
<div className="px-0.5 py-1 text-center text-red-600">JA</div>
|
return (
|
||||||
<div className="px-0.5 py-1 text-center text-red-500">Ok</div>
|
<button
|
||||||
</div>
|
key={i}
|
||||||
<div className="divide-y divide-border/50">
|
type="button"
|
||||||
{checkItems.map((item, idx) => (
|
className={`shrink-0 px-2.5 py-1 text-xs rounded-full border transition-all whitespace-nowrap ${
|
||||||
<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'}`}>
|
i === selectedSuggestionIdx
|
||||||
<div className="px-2 py-1.5 flex items-center gap-1 min-w-0">
|
? 'bg-red-600 text-white border-red-600'
|
||||||
<span className="truncate">{item.label}</span>
|
: 'bg-white dark:bg-card border-border text-foreground hover:bg-red-50 hover:border-red-200 shadow-sm'
|
||||||
{item.confirmedAt && item.confirmed && (
|
}`}
|
||||||
<span className="text-[9px] text-red-500 shrink-0">{formatTime(item.confirmedAt)}</span>
|
onMouseDown={(e) => {
|
||||||
)}
|
e.preventDefault()
|
||||||
{canEdit && (
|
setNewWhat(s)
|
||||||
<button
|
setShowSuggestions(false)
|
||||||
onClick={() => deleteCheckItem(item.id)}
|
setSelectedSuggestionIdx(-1)
|
||||||
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" />
|
{idx >= 0 ? (
|
||||||
</button>
|
<>{s.slice(0, idx)}<strong className="font-bold">{s.slice(idx, idx + lower.length)}</strong>{s.slice(idx + lower.length)}</>
|
||||||
)}
|
) : s}
|
||||||
</div>
|
</button>
|
||||||
<div className="flex items-center justify-center py-1.5">
|
)
|
||||||
<button
|
})}
|
||||||
onClick={() => canEdit && toggleCheck(item, 'confirmed')}
|
</div>
|
||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
</div>
|
||||||
item.confirmed
|
)}
|
||||||
? 'bg-blue-500 border-blue-500 text-white'
|
<Input
|
||||||
: 'border-muted-foreground/30 hover:border-blue-400'
|
placeholder="Was..."
|
||||||
}`}
|
value={newWhat}
|
||||||
title={item.confirmedAt ? `JA: ${formatDateTime(item.confirmedAt)}` : 'JA'}
|
onChange={(e) => {
|
||||||
disabled={!canEdit}
|
setNewWhat(e.target.value)
|
||||||
>
|
filterSuggestions(e.target.value)
|
||||||
{item.confirmed && <Check className="w-3 h-3" />}
|
}}
|
||||||
</button>
|
onKeyDown={(e) => {
|
||||||
</div>
|
if (showSuggestions && filteredSuggestions.length > 0) {
|
||||||
<div className="flex items-center justify-center py-1.5">
|
if (e.key === 'ArrowRight' || e.key === 'Tab') {
|
||||||
<button
|
e.preventDefault()
|
||||||
onClick={() => canEdit && toggleCheck(item, 'ok')}
|
setSelectedSuggestionIdx(prev => (prev + 1) % filteredSuggestions.length)
|
||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
return
|
||||||
item.ok
|
}
|
||||||
? 'bg-green-500 border-green-500 text-white'
|
if (e.key === 'ArrowLeft') {
|
||||||
: 'border-muted-foreground/30 hover:border-green-400'
|
e.preventDefault()
|
||||||
}`}
|
setSelectedSuggestionIdx(prev => prev <= 0 ? filteredSuggestions.length - 1 : prev - 1)
|
||||||
title={item.okAt ? `Ok: ${formatDateTime(item.okAt)}` : 'Ok'}
|
return
|
||||||
disabled={!canEdit}
|
}
|
||||||
>
|
if (e.key === 'Enter' && selectedSuggestionIdx >= 0) {
|
||||||
{item.ok && <Check className="w-3 h-3" />}
|
e.preventDefault()
|
||||||
</button>
|
setNewWhat(filteredSuggestions[selectedSuggestionIdx])
|
||||||
</div>
|
setShowSuggestions(false)
|
||||||
</div>
|
setSelectedSuggestionIdx(-1)
|
||||||
))}
|
return
|
||||||
</div>
|
}
|
||||||
{canEdit && (
|
}
|
||||||
<div className="flex gap-1 px-2 py-1.5 border-t border-border/50 print:hidden">
|
if (e.key === 'Enter') { setShowSuggestions(false); addEntry() }
|
||||||
<Input
|
if (e.key === 'Escape') { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }
|
||||||
placeholder="Neuer Punkt..."
|
}}
|
||||||
value={newCheckLabel}
|
onFocus={() => filterSuggestions(newWhat)}
|
||||||
onChange={(e) => setNewCheckLabel(e.target.value)}
|
onBlur={() => setTimeout(() => { setShowSuggestions(false); setSelectedSuggestionIdx(-1) }, 150)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && addCheckItem()}
|
className="h-7 text-xs w-full"
|
||||||
className="h-6 text-[11px]"
|
/>
|
||||||
/>
|
</div>
|
||||||
<Button size="sm" variant="ghost" onClick={addCheckItem} disabled={!newCheckLabel.trim()} className="h-6 px-1.5">
|
<Input
|
||||||
<Plus className="w-3 h-3" />
|
placeholder="Wer"
|
||||||
</Button>
|
value={newWho}
|
||||||
</div>
|
onChange={(e) => setNewWho(e.target.value)}
|
||||||
)}
|
onKeyDown={(e) => e.key === 'Enter' && addEntry()}
|
||||||
</div>
|
className="w-16 md:w-20 h-7 text-xs"
|
||||||
|
/>
|
||||||
{/* Pendenzen */}
|
<Button size="sm" onClick={addEntry} disabled={!newWhat.trim()} className="h-7 px-2">
|
||||||
<div>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
<div className="px-2 py-1.5 bg-stone-100 dark:bg-muted/50 border-b-2 border-red-400 dark:border-red-800">
|
</Button>
|
||||||
<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>
|
||||||
</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]"
|
|
||||||
/>
|
|
||||||
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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>
|
||||||
)}
|
)
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
{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}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modul-Verwaltung */}
|
||||||
|
<ModuleManagerDialog
|
||||||
|
open={showModuleManager}
|
||||||
|
onOpenChange={setShowModuleManager}
|
||||||
|
modules={modules}
|
||||||
|
onSaved={setModules}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Rapport Dialog */}
|
{/* Rapport Dialog */}
|
||||||
{showRapportDialog && projectId && (
|
{showRapportDialog && projectId && (
|
||||||
<RapportDialog
|
<RapportDialog
|
||||||
|
|||||||
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