Gleiche Länge
Dieser Commit ist enthalten in:
@ -47,7 +47,7 @@ const OrganizationNode = ({ data }: { data: any }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg border-2 border-gray-200 min-w-[250px]">
|
||||
<div className="bg-white rounded-lg shadow-lg border-2 border-gray-200 w-[300px]">
|
||||
<div
|
||||
className="p-2 text-white rounded-t-md"
|
||||
style={{ background: getGradient(data.level || 0) }}
|
||||
@ -89,8 +89,6 @@ export default function OrganizationEditor() {
|
||||
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)
|
||||
@ -212,13 +210,7 @@ export default function OrganizationEditor() {
|
||||
)
|
||||
|
||||
const onNodeClick = useCallback((_: any, node: 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)
|
||||
}
|
||||
setSelectedNode(node)
|
||||
}, [])
|
||||
|
||||
const onNodeDragStop = useCallback(async (_: any, node: Node) => {
|
||||
@ -379,12 +371,24 @@ export default function OrganizationEditor() {
|
||||
const topYDirector = 10
|
||||
const topYRootRow = topYDirector + yGap
|
||||
// Spaltenstart mit Abstand unterhalb der Top-Row, um Überlappungen zu vermeiden
|
||||
const yStart = topYRootRow + yGap
|
||||
// yStart wird nach Messung der tatsächlichen Kartenhöhen berechnet
|
||||
let yStart = topYRootRow + yGap
|
||||
|
||||
const newNodes: Node[] = nodes.map(n => ({ ...n, position: { ...n.position } }))
|
||||
const byId: Record<string, Node> = {}
|
||||
newNodes.forEach(n => { byId[n.id] = n })
|
||||
|
||||
// Hilfsfunktion: tatsächliche Höhe einer Node messen (Fallback 130px)
|
||||
const getNodeHeight = (id: string): number => {
|
||||
const el = document.querySelector(`[data-id="${id}"]`) as HTMLElement | null
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
// Mindestens 100, damit sehr kleine Wrapper nicht zu nahe rücken
|
||||
return Math.max(100, Math.ceil(rect.height))
|
||||
}
|
||||
return 130
|
||||
}
|
||||
|
||||
const getLaneKey = (n: Node): string => {
|
||||
const code: string = n.data?.code || ''
|
||||
const type: string = n.data?.type || ''
|
||||
@ -457,26 +461,36 @@ export default function OrganizationEditor() {
|
||||
// 1) Direktion zentriert ganz oben
|
||||
const directorIndex = rootNodes.findIndex(n => (String(n.data?.type).toLowerCase() === 'direktion') || String(n.data?.code).toUpperCase() === 'DIR')
|
||||
let placedIndices = new Set<number>()
|
||||
let directorRowHeight = 0
|
||||
if (directorIndex >= 0) {
|
||||
const centerX = xMargin + totalWidth / 2 - cardHalfWidth
|
||||
const x = Math.max(xMargin, centerX)
|
||||
rootNodes[directorIndex].position = { x, y: topYDirector }
|
||||
placedIndices.add(directorIndex)
|
||||
directorRowHeight = getNodeHeight(rootNodes[directorIndex].id)
|
||||
}
|
||||
|
||||
// 2) Übrige ROOT-Knoten in einer zweiten Top-Reihe gleichmäßig verteilen
|
||||
const others: Node[] = rootNodes.filter((_, idx) => !placedIndices.has(idx))
|
||||
let rootRowHeight = 0
|
||||
if (others.length === 1) {
|
||||
const x = Math.max(xMargin, xMargin + totalWidth / 2 - cardHalfWidth)
|
||||
others[0].position = { x, y: topYRootRow }
|
||||
rootRowHeight = getNodeHeight(others[0].id)
|
||||
} else if (others.length > 1) {
|
||||
const step = others.length > 1 ? (totalWidth / (others.length - 1)) : 0
|
||||
others.forEach((n, i) => {
|
||||
const xi = xMargin + i * step - cardHalfWidth
|
||||
n.position = { x: Math.max(xMargin, xi), y: topYRootRow }
|
||||
rootRowHeight = Math.max(rootRowHeight, getNodeHeight(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
// Spaltenstart abhängig von den tatsächlichen Höhen berechnen
|
||||
if (directorRowHeight || rootRowHeight) {
|
||||
yStart = topYDirector + (directorRowHeight || 0) + yGap + (rootRowHeight ? (rootRowHeight + yGap) : 0) + 16
|
||||
}
|
||||
|
||||
// Arrange lanes
|
||||
let laneIndex = 0
|
||||
laneOrder.forEach(key => {
|
||||
@ -490,19 +504,19 @@ export default function OrganizationEditor() {
|
||||
let yCursor = yStart
|
||||
if (abteilung) {
|
||||
abteilung.position = { x: xBase, y: yCursor }
|
||||
yCursor += yGap
|
||||
yCursor += getNodeHeight(abteilung.id) + 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
|
||||
yCursor += getNodeHeight(dez.id) + 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
|
||||
yCursor += getNodeHeight(c.id) + yGap
|
||||
})
|
||||
// small gap after each dezernat block
|
||||
yCursor += 12
|
||||
@ -511,7 +525,7 @@ export default function OrganizationEditor() {
|
||||
// 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
|
||||
yCursor += getNodeHeight(n.id) + yGap
|
||||
})
|
||||
|
||||
laneIndex += 1
|
||||
@ -521,35 +535,7 @@ export default function OrganizationEditor() {
|
||||
}
|
||||
|
||||
// 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 (
|
||||
@ -604,25 +590,7 @@ export default function OrganizationEditor() {
|
||||
>
|
||||
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>
|
||||
|
||||
@ -894,32 +862,7 @@ export default function OrganizationEditor() {
|
||||
</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