Split 1260-LOC client page into _types.ts and six tab components under _components/ (Overview, Policies, SoA, Objectives, Audits, Reviews) plus a shared helpers module. Behavior preserved exactly; page.tsx is now a thin wiring shell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
6.4 KiB
TypeScript
146 lines
6.4 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { API, ManagementReview } from '../_types'
|
|
import { EmptyState, LoadingSpinner, StatusBadge } from './shared'
|
|
|
|
// =============================================================================
|
|
// TAB: MANAGEMENT REVIEWS
|
|
// =============================================================================
|
|
|
|
export function ReviewsTab() {
|
|
const [reviews, setReviews] = useState<ManagementReview[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch(`${API}/management-reviews`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setReviews(data.reviews || [])
|
|
}
|
|
} catch { /* ignore */ }
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const createReview = async (form: Record<string, unknown>) => {
|
|
try {
|
|
const res = await fetch(`${API}/management-reviews?created_by=admin`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(form),
|
|
})
|
|
if (res.ok) { setShowCreate(false); load() }
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const approveReview = async (reviewId: string) => {
|
|
const nextYear = new Date()
|
|
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
|
try {
|
|
await fetch(`${API}/management-reviews/${reviewId}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
approved_by: 'admin',
|
|
next_review_date: nextYear.toISOString().split('T')[0],
|
|
}),
|
|
})
|
|
load()
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
if (loading) return <LoadingSpinner />
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-gray-600">{reviews.length} Management-Reviews</h3>
|
|
<button onClick={() => setShowCreate(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">Neue Review</button>
|
|
</div>
|
|
|
|
{reviews.length === 0 ? (
|
|
<EmptyState text="Keine Management-Reviews vorhanden" action="Review planen" onAction={() => setShowCreate(true)} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{reviews.map(r => (
|
|
<div key={r.id} className="bg-white border rounded-xl p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-gray-500">{r.review_id}</span>
|
|
<span className="text-sm font-medium text-gray-900">{r.title}</span>
|
|
<StatusBadge status={r.status} />
|
|
</div>
|
|
<div className="flex gap-3 text-xs text-gray-500 mt-1">
|
|
<span>Datum: {new Date(r.review_date).toLocaleDateString('de-DE')}</span>
|
|
<span>Zeitraum: {new Date(r.review_period_start).toLocaleDateString('de-DE')} - {new Date(r.review_period_end).toLocaleDateString('de-DE')}</span>
|
|
<span>Vorsitz: {r.chairperson}</span>
|
|
{r.next_review_date && <span>Naechste Review: {new Date(r.next_review_date).toLocaleDateString('de-DE')}</span>}
|
|
</div>
|
|
</div>
|
|
{r.status === 'draft' && (
|
|
<button onClick={() => approveReview(r.id)} className="px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs">Genehmigen</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<ReviewCreateModal onClose={() => setShowCreate(false)} onSave={createReview} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ReviewCreateModal({ onClose, onSave }: { onClose: () => void; onSave: (data: Record<string, unknown>) => void }) {
|
|
const today = new Date().toISOString().split('T')[0]
|
|
const [form, setForm] = useState({
|
|
title: '', review_date: today,
|
|
review_period_start: '', review_period_end: today,
|
|
chairperson: '', attendees: [] as Record<string, unknown>[],
|
|
})
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg p-6">
|
|
<h3 className="text-lg font-semibold mb-4">Neue Management-Review</h3>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Titel</label>
|
|
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" placeholder="Q1 2026 Management Review" />
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Review-Datum</label>
|
|
<input type="date" value={form.review_date} onChange={e => setForm({ ...form, review_date: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Zeitraum von</label>
|
|
<input type="date" value={form.review_period_start} onChange={e => setForm({ ...form, review_period_start: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Zeitraum bis</label>
|
|
<input type="date" value={form.review_period_end} onChange={e => setForm({ ...form, review_period_end: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Vorsitzender</label>
|
|
<input value={form.chairperson} onChange={e => setForm({ ...form, chairperson: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 mt-6">
|
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
|
<button onClick={() => onSave(form)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|