Roll Back Punkt - Ansicht klappt so semi
Dieser Commit ist enthalten in:
@ -87,6 +87,15 @@ export default function OrganizationEditor() {
|
||||
const [showImportDialog, setShowImportDialog] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [clearExisting, setClearExisting] = useState(false)
|
||||
const [issues, setIssues] = useState<{ orphans: Set<string> }>({ orphans: new Set() })
|
||||
const [showValidation, setShowValidation] = useState(false)
|
||||
const [reparentMode, setReparentMode] = useState<{ source: Node | null }>({ source: null })
|
||||
const [preview, setPreview] = useState<any | null>(null)
|
||||
const [pendingOverrides, setPendingOverrides] = useState<Record<string, { parentCode?: string; type?: string; name?: string }>>({})
|
||||
const [rememberRules, setRememberRules] = useState(true)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [parentPicker, setParentPicker] = useState<{ open: boolean; target?: string; suggestions: any[] }>({ open: false, target: undefined, suggestions: [] })
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
code: '',
|
||||
@ -111,6 +120,7 @@ export default function OrganizationEditor() {
|
||||
const { nodes: flowNodes, edges: flowEdges } = convertToFlowElements(units)
|
||||
setNodes(flowNodes)
|
||||
setEdges(flowEdges)
|
||||
computeIssues(flowNodes)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load organization:', error)
|
||||
@ -202,7 +212,13 @@ export default function OrganizationEditor() {
|
||||
)
|
||||
|
||||
const onNodeClick = useCallback((_: any, node: Node) => {
|
||||
setSelectedNode(node)
|
||||
if (reparentMode.source && reparentMode.source.id !== node.id) {
|
||||
// Reparent action: set node as new parent
|
||||
handleReparent(reparentMode.source, node)
|
||||
setReparentMode({ source: null })
|
||||
} else {
|
||||
setSelectedNode(node)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onNodeDragStop = useCallback(async (_: any, node: Node) => {
|
||||
@ -249,6 +265,7 @@ export default function OrganizationEditor() {
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
setSelectedFile(file)
|
||||
|
||||
if (file.type !== 'application/pdf') {
|
||||
alert('Bitte laden Sie nur PDF-Dateien hoch.')
|
||||
@ -257,24 +274,21 @@ export default function OrganizationEditor() {
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('pdf', file)
|
||||
formData.append('clearExisting', 'false') // Don't clear existing by default
|
||||
formData.append('clearExisting', String(clearExisting))
|
||||
|
||||
setUploadProgress('PDF wird hochgeladen...')
|
||||
setUploadProgress('PDF wird analysiert (Vorschau)...')
|
||||
|
||||
try {
|
||||
const response = await api.post('/organization/import-pdf', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
},
|
||||
params: { previewOnly: 'true' }
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadProgress(`Erfolgreich! ${response.data.data.unitsImported} Einheiten importiert.`)
|
||||
await loadOrganization()
|
||||
setTimeout(() => {
|
||||
setShowImportDialog(false)
|
||||
setUploadProgress(null)
|
||||
}, 2000)
|
||||
setPreview(response.data.data)
|
||||
setUploadProgress(null)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('PDF import failed:', error)
|
||||
@ -282,6 +296,237 @@ export default function OrganizationEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const applyImport = async () => {
|
||||
try {
|
||||
setUploadProgress('Import wird angewendet...')
|
||||
const overrides = Object.entries(pendingOverrides).map(([code, v]) => ({ code, ...v }))
|
||||
const fd = new FormData()
|
||||
if (selectedFile) fd.append('pdf', selectedFile)
|
||||
fd.append('clearExisting', String(clearExisting))
|
||||
fd.append('overrides', JSON.stringify(overrides))
|
||||
fd.append('previewOnly', 'false')
|
||||
fd.append('rememberRules', String(rememberRules))
|
||||
const response = await api.post('/organization/import-pdf', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
if (response.data.success) {
|
||||
setUploadProgress('Erfolgreich importiert.')
|
||||
await loadOrganization()
|
||||
setTimeout(() => {
|
||||
setShowImportDialog(false)
|
||||
setUploadProgress(null)
|
||||
setPreview(null)
|
||||
setPendingOverrides({})
|
||||
}, 1200)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Import apply failed:', error)
|
||||
setUploadProgress(`Fehler: ${error.response?.data?.error?.message || 'Import fehlgeschlagen'}`)
|
||||
}
|
||||
}
|
||||
|
||||
const openParentPicker = async (targetCode: string) => {
|
||||
try {
|
||||
// Build suggestions from preview (preferred) + existing units
|
||||
const suggestions: any[] = []
|
||||
if (preview?.diff) {
|
||||
const push = (u: any) => { if (u && (u.type === 'dezernat' || u.type === 'abteilung' || u.type === 'direktion')) suggestions.push({ code: u.code, name: u.name, type: u.type }) }
|
||||
(preview.diff.create || []).forEach(push)
|
||||
;(preview.diff.update || []).forEach((d: any) => { push(d.incoming); push(d.existing) })
|
||||
}
|
||||
try {
|
||||
const res = await api.get('/organization/units')
|
||||
if (res.data.success) {
|
||||
res.data.data.forEach((u: any) => {
|
||||
if (u && (u.type === 'dezernat' || u.type === 'abteilung' || u.type === 'direktion')) suggestions.push({ code: u.code, name: u.name, type: u.type })
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
// de-duplicate by code
|
||||
const uniq: Record<string, any> = {}
|
||||
suggestions.forEach(s => { uniq[s.code] = uniq[s.code] || s })
|
||||
const list = Object.values(uniq)
|
||||
setParentPicker({ open: true, target: targetCode, suggestions: list as any[] })
|
||||
} catch (e) {
|
||||
setParentPicker({ open: true, target: targetCode, suggestions: [] })
|
||||
}
|
||||
}
|
||||
|
||||
// Compute simple validation issues (orphans)
|
||||
const computeIssues = (flowNodes: Node[]) => {
|
||||
const ids = new Set(flowNodes.map(n => n.id))
|
||||
const orphanIds = new Set<string>()
|
||||
flowNodes.forEach(n => {
|
||||
const parentId = (n.data?.parentId as string | undefined)
|
||||
if (parentId && !ids.has(parentId)) {
|
||||
orphanIds.add(n.id)
|
||||
}
|
||||
})
|
||||
setIssues({ orphans: orphanIds })
|
||||
// Apply visual styles
|
||||
setNodes(ns => ns.map(n => ({
|
||||
...n,
|
||||
style: orphanIds.has(n.id) ? { ...(n.style||{}), border: '2px solid #ef4444' } : { ...(n.style||{}), border: '2px solid #e5e7eb' }
|
||||
})))
|
||||
}
|
||||
|
||||
// Auto layout: Swimlanes by Abteilung (columns), vertical stacking within lanes
|
||||
const handleAutoLayout = () => {
|
||||
const laneWidth = 420
|
||||
const xMargin = 40
|
||||
const yStart = 80
|
||||
const yGap = 110
|
||||
|
||||
const newNodes: Node[] = nodes.map(n => ({ ...n, position: { ...n.position } }))
|
||||
const byId: Record<string, Node> = {}
|
||||
newNodes.forEach(n => { byId[n.id] = n })
|
||||
|
||||
const getLaneKey = (n: Node): string => {
|
||||
const code: string = n.data?.code || ''
|
||||
const type: string = n.data?.type || ''
|
||||
if (type === 'direktion') return 'ROOT'
|
||||
if (type === 'abteilung') {
|
||||
const m = code.match(/Abt\s+(ZA|\d)/i)
|
||||
return m ? m[1].toUpperCase() : 'UNK'
|
||||
}
|
||||
if (type === 'dezernat') {
|
||||
if (/ZA/i.test(code)) return 'ZA'
|
||||
const m = code.match(/Dez\s+(\d+)/i)
|
||||
return m ? m[1].charAt(0) : 'UNK'
|
||||
}
|
||||
if (type === 'sachgebiet' || type === 'teildezernat') {
|
||||
if (/ZA/i.test(code)) return 'ZA'
|
||||
const m = code.match(/\s(\d+)\./)
|
||||
return m ? m[1].charAt(0) : 'UNK'
|
||||
}
|
||||
if (type === 'stabsstelle' || type === 'sondereinheit' || type === 'fuehrungsstelle') {
|
||||
return 'ROOT'
|
||||
}
|
||||
// fallback: derive from parent's lane
|
||||
const parentId = n.data?.parentId
|
||||
if (parentId && byId[parentId]) return getLaneKey(byId[parentId])
|
||||
return 'UNK'
|
||||
}
|
||||
|
||||
const lanesMap = new Map<string, Node[]>()
|
||||
newNodes.forEach(n => {
|
||||
const lane = getLaneKey(n)
|
||||
if (!lanesMap.has(lane)) lanesMap.set(lane, [])
|
||||
lanesMap.get(lane)!.push(n)
|
||||
})
|
||||
|
||||
// Sort lanes: 1..6, ZA, ROOT, UNK
|
||||
const laneOrder = Array.from(lanesMap.keys()).sort((a, b) => {
|
||||
const rank = (k: string) => (k === 'ROOT' ? 99 : (k === 'UNK' ? 98 : (k === 'ZA' ? 7 : parseInt(k, 10))))
|
||||
return rank(a) - rank(b)
|
||||
})
|
||||
|
||||
// Helper to sort Dez/SG numerically within lane
|
||||
const dezOrder = (a: Node, b: Node) => {
|
||||
const parseDez = (n: Node) => {
|
||||
const c: string = n.data?.code || ''
|
||||
if (/ZA/i.test(c)) {
|
||||
const m = c.match(/Dez\s+ZA\s*(\d+)/i)
|
||||
return 100 + (m ? parseInt(m[1] || '0', 10) : 0)
|
||||
}
|
||||
const m = c.match(/Dez\s+(\d+)/i)
|
||||
return m ? parseInt(m[1], 10) : 0
|
||||
}
|
||||
return parseDez(a) - parseDez(b)
|
||||
}
|
||||
const childOrder = (a: Node, b: Node) => {
|
||||
const pa = (a.data?.code || '').match(/(\d+)\.(\d+)/)
|
||||
const pb = (b.data?.code || '').match(/(\d+)\.(\d+)/)
|
||||
const va = pa ? (parseInt(pa[1],10)*100 + parseInt(pa[2],10)) : 0
|
||||
const vb = pb ? (parseInt(pb[1],10)*100 + parseInt(pb[2],10)) : 0
|
||||
return va - vb
|
||||
}
|
||||
|
||||
// Position Direktion and root-like nodes at the top center
|
||||
const rootNodes = lanesMap.get('ROOT') || []
|
||||
let globalMinX = xMargin
|
||||
const totalLanes = laneOrder.filter(k => k !== 'ROOT' && k !== 'UNK').length
|
||||
const rootX = Math.max(xMargin, (totalLanes * laneWidth) / 2 - 150)
|
||||
let rootY = 10
|
||||
rootNodes.forEach(n => {
|
||||
n.position = { x: rootX, y: rootY }
|
||||
rootY += yGap
|
||||
})
|
||||
|
||||
// Arrange lanes
|
||||
let laneIndex = 0
|
||||
laneOrder.forEach(key => {
|
||||
if (key === 'ROOT' || key === 'UNK') return
|
||||
const laneNodes = lanesMap.get(key) || []
|
||||
const xBase = xMargin + laneIndex * laneWidth
|
||||
globalMinX = Math.min(globalMinX, xBase)
|
||||
|
||||
// Find the Abteilung node for this lane (if any)
|
||||
const abteilung = laneNodes.find(n => n.data?.type === 'abteilung')
|
||||
let yCursor = yStart
|
||||
if (abteilung) {
|
||||
abteilung.position = { x: xBase, y: yCursor }
|
||||
yCursor += yGap
|
||||
}
|
||||
|
||||
// Place Dezernate
|
||||
const dezernate = laneNodes.filter(n => n.data?.type === 'dezernat').sort(dezOrder)
|
||||
dezernate.forEach(dez => {
|
||||
dez.position = { x: xBase, y: yCursor }
|
||||
yCursor += yGap
|
||||
// children SG/TD under this dez vertically
|
||||
const children = laneNodes.filter(n => (n.data?.type === 'sachgebiet' || n.data?.type === 'teildezernat') && n.data?.parentId === dez.id).sort(childOrder)
|
||||
children.forEach((c) => {
|
||||
c.position = { x: xBase + 40, y: yCursor }
|
||||
yCursor += yGap
|
||||
})
|
||||
// small gap after each dezernat block
|
||||
yCursor += 12
|
||||
})
|
||||
|
||||
// Any remaining nodes (fallback)
|
||||
laneNodes.filter(n => !['abteilung','dezernat','sachgebiet','teildezernat'].includes(n.data?.type || '')).forEach(n => {
|
||||
n.position = { x: xBase, y: yCursor }
|
||||
yCursor += yGap
|
||||
})
|
||||
|
||||
laneIndex += 1
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
}
|
||||
|
||||
// Reparenting: select source then target
|
||||
const handleStartReparent = () => {
|
||||
setReparentMode({ source: selectedNode })
|
||||
}
|
||||
|
||||
const handleReparent = async (child: Node, newParent: Node) => {
|
||||
try {
|
||||
await api.put(`/organization/units/${child.id}`, {
|
||||
parentId: newParent.id,
|
||||
level: (newParent.data?.level ?? 0) + 1
|
||||
})
|
||||
await loadOrganization()
|
||||
} catch (error: any) {
|
||||
alert(error?.response?.data?.error?.message || 'Konnte Parent nicht ändern')
|
||||
console.error('Failed to reparent:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const persistPositions = async () => {
|
||||
try {
|
||||
await Promise.all(nodes.map(n => api.put(`/organization/units/${n.id}`, {
|
||||
positionX: Math.round(n.position.x),
|
||||
positionY: Math.round(n.position.y)
|
||||
})))
|
||||
alert('Positionen gespeichert')
|
||||
} catch (error) {
|
||||
console.error('Failed to persist positions:', error)
|
||||
alert('Fehler beim Speichern der Positionen')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
@ -329,6 +574,31 @@ export default function OrganizationEditor() {
|
||||
<Upload size={16} />
|
||||
PDF importieren
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoLayout}
|
||||
className="w-full px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Auto anordnen
|
||||
</button>
|
||||
<button
|
||||
onClick={persistPositions}
|
||||
className="w-full px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700"
|
||||
>
|
||||
Positionen speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowValidation(true)}
|
||||
className="w-full px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700"
|
||||
>
|
||||
Validierung anzeigen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartReparent}
|
||||
disabled={!selectedNode}
|
||||
className={`w-full px-4 py-2 rounded text-white ${selectedNode ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-400'}`}
|
||||
>
|
||||
Parent neu setzen {reparentMode.source ? '(Quelle gewählt)' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@ -341,6 +611,9 @@ export default function OrganizationEditor() {
|
||||
)}
|
||||
<p className="text-sm text-gray-600">Typ: {selectedNode.data.type}</p>
|
||||
<p className="text-sm text-gray-600">Ebene: {selectedNode.data.level}</p>
|
||||
{issues.orphans.has(selectedNode.id) && (
|
||||
<p className="text-xs text-red-600 mt-1">Warnung: Parent fehlt (Waisen-Knoten)</p>
|
||||
)}
|
||||
{selectedNode.data.description && (
|
||||
<p className="text-sm mt-2">{selectedNode.data.description}</p>
|
||||
)}
|
||||
@ -354,51 +627,149 @@ export default function OrganizationEditor() {
|
||||
{/* Import PDF Dialog */}
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||
<h2 className="text-xl font-bold mb-4">PDF Organigramm importieren</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Laden Sie ein PDF-Dokument mit der Organisationsstruktur hoch.
|
||||
Das System extrahiert automatisch die Hierarchie.
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
disabled={!!uploadProgress}
|
||||
>
|
||||
PDF auswählen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{uploadProgress && (
|
||||
<div className="p-3 bg-blue-50 text-blue-700 rounded">
|
||||
{uploadProgress}
|
||||
{!preview ? (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Laden Sie ein PDF hoch. Es wird zunächst nur eine Vorschau (Diff) berechnet.
|
||||
</p>
|
||||
<label className="flex items-center justify-center gap-2 text-sm mb-4">
|
||||
<input type="checkbox" checked={clearExisting} onChange={(e)=>setClearExisting(e.target.checked)} />
|
||||
Bestehende Einheiten löschen
|
||||
</label>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
disabled={!!uploadProgress}
|
||||
>
|
||||
PDF auswählen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadProgress && (
|
||||
<div className="p-3 bg-blue-50 text-blue-700 rounded">
|
||||
{uploadProgress}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-3 border rounded">
|
||||
<h3 className="font-semibold mb-2">Neu</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">{preview.counts?.create} Einheiten</p>
|
||||
<div className="max-h-40 overflow-auto text-sm">
|
||||
{preview.diff?.create?.slice(0,50).map((u: any)=> (
|
||||
<div key={u.code}>{u.code} – {u.name}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 border rounded">
|
||||
<h3 className="font-semibold mb-2">Änderungen</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">{preview.counts?.update} Einheiten</p>
|
||||
<div className="max-h-40 overflow-auto text-sm">
|
||||
{preview.diff?.update?.slice(0,50).map((d: any)=> (
|
||||
<div key={d.incoming.code}>{d.incoming.code} – {d.existing.name} → {d.incoming.name}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 border rounded">
|
||||
<h3 className="font-semibold mb-2">Waisen (Parent fehlt)</h3>
|
||||
<p className="text-sm text-gray-500 mb-2">{preview.counts?.orphans} Einheiten</p>
|
||||
<div className="max-h-40 overflow-auto text-sm space-y-2">
|
||||
{preview.diff?.orphans?.map((u: any)=> (
|
||||
<div key={u.code} className="flex items-center gap-2">
|
||||
<div className="flex-1 truncate" title={`${u.code} – ${u.name}`}>{u.code} – {u.name}</div>
|
||||
<input
|
||||
placeholder="Parent-Code (z.B. Dez 11 / Abt 1)"
|
||||
className="border rounded px-2 py-1 text-xs w-48"
|
||||
value={pendingOverrides[u.code]?.parentCode || ''}
|
||||
onChange={(e)=> setPendingOverrides(prev => ({...prev, [u.code]: { ...(prev[u.code]||{}), parentCode: e.target.value }}))}
|
||||
/>
|
||||
<button
|
||||
onClick={() => openParentPicker(u.code)}
|
||||
className="px-2 py-1 text-xs bg-slate-100 rounded border"
|
||||
title="Parent auswählen"
|
||||
>
|
||||
…
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={rememberRules} onChange={(e)=>setRememberRules(e.target.checked)} />
|
||||
Zuordnungen als Regeln speichern (für künftige Importe)
|
||||
</label>
|
||||
{uploadProgress && (
|
||||
<div className="p-3 bg-blue-50 text-blue-700 rounded">{uploadProgress}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowImportDialog(false)
|
||||
setUploadProgress(null)
|
||||
setPreview(null)
|
||||
setPendingOverrides({})
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
{preview && (
|
||||
<button
|
||||
onClick={applyImport}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Import anwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parent Picker Modal */}
|
||||
{parentPicker.open && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-4 max-w-lg w-full">
|
||||
<h3 className="font-semibold mb-3">Parent auswählen für {parentPicker.target}</h3>
|
||||
<div className="max-h-80 overflow-auto border rounded">
|
||||
{(parentPicker.suggestions || []).map((s: any) => (
|
||||
<button
|
||||
key={s.code}
|
||||
onClick={() => {
|
||||
if (parentPicker.target) {
|
||||
setPendingOverrides(prev => ({ ...prev, [parentPicker.target!]: { ...(prev[parentPicker.target!]||{}), parentCode: s.code } }))
|
||||
}
|
||||
setParentPicker({ open: false, target: undefined, suggestions: [] })
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 hover:bg-slate-100 border-b last:border-b-0"
|
||||
>
|
||||
<span className="font-mono text-xs mr-2">{s.code}</span>
|
||||
<span>{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end mt-3">
|
||||
<button onClick={() => setParentPicker({ open: false, target: undefined, suggestions: [] })} className="px-3 py-1 text-slate-600 hover:text-slate-800">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -498,6 +869,33 @@ export default function OrganizationEditor() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation Dialog */}
|
||||
{showValidation && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-lg w-full">
|
||||
<h2 className="text-xl font-bold mb-4">Validierung</h2>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-auto">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Waisen-Knoten (Parent fehlt)</h3>
|
||||
{issues.orphans.size === 0 ? (
|
||||
<p className="text-gray-600">Keine</p>
|
||||
) : (
|
||||
<ul className="list-disc ml-5 text-sm">
|
||||
{Array.from(issues.orphans).map(id => {
|
||||
const n = nodes.find(n=>n.id===id)
|
||||
return <li key={id}>{n?.data?.code || id} – {n?.data?.name}</li>
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button onClick={()=>setShowValidation(false)} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren