Gleiche Länge
Dieser Commit ist enthalten in:
@ -47,7 +47,7 @@ const OrganizationNode = ({ data }: { data: any }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="p-2 text-white rounded-t-md"
|
className="p-2 text-white rounded-t-md"
|
||||||
style={{ background: getGradient(data.level || 0) }}
|
style={{ background: getGradient(data.level || 0) }}
|
||||||
@ -89,8 +89,6 @@ export default function OrganizationEditor() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [clearExisting, setClearExisting] = useState(false)
|
const [clearExisting, setClearExisting] = useState(false)
|
||||||
const [issues, setIssues] = useState<{ orphans: Set<string> }>({ orphans: new Set() })
|
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 [preview, setPreview] = useState<any | null>(null)
|
||||||
const [pendingOverrides, setPendingOverrides] = useState<Record<string, { parentCode?: string; type?: string; name?: string }>>({})
|
const [pendingOverrides, setPendingOverrides] = useState<Record<string, { parentCode?: string; type?: string; name?: string }>>({})
|
||||||
const [rememberRules, setRememberRules] = useState(true)
|
const [rememberRules, setRememberRules] = useState(true)
|
||||||
@ -212,13 +210,7 @@ export default function OrganizationEditor() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onNodeClick = useCallback((_: any, node: Node) => {
|
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) => {
|
const onNodeDragStop = useCallback(async (_: any, node: Node) => {
|
||||||
@ -379,12 +371,24 @@ export default function OrganizationEditor() {
|
|||||||
const topYDirector = 10
|
const topYDirector = 10
|
||||||
const topYRootRow = topYDirector + yGap
|
const topYRootRow = topYDirector + yGap
|
||||||
// Spaltenstart mit Abstand unterhalb der Top-Row, um Überlappungen zu vermeiden
|
// 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 newNodes: Node[] = nodes.map(n => ({ ...n, position: { ...n.position } }))
|
||||||
const byId: Record<string, Node> = {}
|
const byId: Record<string, Node> = {}
|
||||||
newNodes.forEach(n => { byId[n.id] = n })
|
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 getLaneKey = (n: Node): string => {
|
||||||
const code: string = n.data?.code || ''
|
const code: string = n.data?.code || ''
|
||||||
const type: string = n.data?.type || ''
|
const type: string = n.data?.type || ''
|
||||||
@ -457,26 +461,36 @@ export default function OrganizationEditor() {
|
|||||||
// 1) Direktion zentriert ganz oben
|
// 1) Direktion zentriert ganz oben
|
||||||
const directorIndex = rootNodes.findIndex(n => (String(n.data?.type).toLowerCase() === 'direktion') || String(n.data?.code).toUpperCase() === 'DIR')
|
const directorIndex = rootNodes.findIndex(n => (String(n.data?.type).toLowerCase() === 'direktion') || String(n.data?.code).toUpperCase() === 'DIR')
|
||||||
let placedIndices = new Set<number>()
|
let placedIndices = new Set<number>()
|
||||||
|
let directorRowHeight = 0
|
||||||
if (directorIndex >= 0) {
|
if (directorIndex >= 0) {
|
||||||
const centerX = xMargin + totalWidth / 2 - cardHalfWidth
|
const centerX = xMargin + totalWidth / 2 - cardHalfWidth
|
||||||
const x = Math.max(xMargin, centerX)
|
const x = Math.max(xMargin, centerX)
|
||||||
rootNodes[directorIndex].position = { x, y: topYDirector }
|
rootNodes[directorIndex].position = { x, y: topYDirector }
|
||||||
placedIndices.add(directorIndex)
|
placedIndices.add(directorIndex)
|
||||||
|
directorRowHeight = getNodeHeight(rootNodes[directorIndex].id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Übrige ROOT-Knoten in einer zweiten Top-Reihe gleichmäßig verteilen
|
// 2) Übrige ROOT-Knoten in einer zweiten Top-Reihe gleichmäßig verteilen
|
||||||
const others: Node[] = rootNodes.filter((_, idx) => !placedIndices.has(idx))
|
const others: Node[] = rootNodes.filter((_, idx) => !placedIndices.has(idx))
|
||||||
|
let rootRowHeight = 0
|
||||||
if (others.length === 1) {
|
if (others.length === 1) {
|
||||||
const x = Math.max(xMargin, xMargin + totalWidth / 2 - cardHalfWidth)
|
const x = Math.max(xMargin, xMargin + totalWidth / 2 - cardHalfWidth)
|
||||||
others[0].position = { x, y: topYRootRow }
|
others[0].position = { x, y: topYRootRow }
|
||||||
|
rootRowHeight = getNodeHeight(others[0].id)
|
||||||
} else if (others.length > 1) {
|
} else if (others.length > 1) {
|
||||||
const step = others.length > 1 ? (totalWidth / (others.length - 1)) : 0
|
const step = others.length > 1 ? (totalWidth / (others.length - 1)) : 0
|
||||||
others.forEach((n, i) => {
|
others.forEach((n, i) => {
|
||||||
const xi = xMargin + i * step - cardHalfWidth
|
const xi = xMargin + i * step - cardHalfWidth
|
||||||
n.position = { x: Math.max(xMargin, xi), y: topYRootRow }
|
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
|
// Arrange lanes
|
||||||
let laneIndex = 0
|
let laneIndex = 0
|
||||||
laneOrder.forEach(key => {
|
laneOrder.forEach(key => {
|
||||||
@ -490,19 +504,19 @@ export default function OrganizationEditor() {
|
|||||||
let yCursor = yStart
|
let yCursor = yStart
|
||||||
if (abteilung) {
|
if (abteilung) {
|
||||||
abteilung.position = { x: xBase, y: yCursor }
|
abteilung.position = { x: xBase, y: yCursor }
|
||||||
yCursor += yGap
|
yCursor += getNodeHeight(abteilung.id) + yGap
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place Dezernate
|
// Place Dezernate
|
||||||
const dezernate = laneNodes.filter(n => n.data?.type === 'dezernat').sort(dezOrder)
|
const dezernate = laneNodes.filter(n => n.data?.type === 'dezernat').sort(dezOrder)
|
||||||
dezernate.forEach(dez => {
|
dezernate.forEach(dez => {
|
||||||
dez.position = { x: xBase, y: yCursor }
|
dez.position = { x: xBase, y: yCursor }
|
||||||
yCursor += yGap
|
yCursor += getNodeHeight(dez.id) + yGap
|
||||||
// children SG/TD under this dez vertically
|
// 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)
|
const children = laneNodes.filter(n => (n.data?.type === 'sachgebiet' || n.data?.type === 'teildezernat') && n.data?.parentId === dez.id).sort(childOrder)
|
||||||
children.forEach((c) => {
|
children.forEach((c) => {
|
||||||
c.position = { x: xBase + 40, y: yCursor }
|
c.position = { x: xBase + 40, y: yCursor }
|
||||||
yCursor += yGap
|
yCursor += getNodeHeight(c.id) + yGap
|
||||||
})
|
})
|
||||||
// small gap after each dezernat block
|
// small gap after each dezernat block
|
||||||
yCursor += 12
|
yCursor += 12
|
||||||
@ -511,7 +525,7 @@ export default function OrganizationEditor() {
|
|||||||
// Any remaining nodes (fallback)
|
// Any remaining nodes (fallback)
|
||||||
laneNodes.filter(n => !['abteilung','dezernat','sachgebiet','teildezernat'].includes(n.data?.type || '')).forEach(n => {
|
laneNodes.filter(n => !['abteilung','dezernat','sachgebiet','teildezernat'].includes(n.data?.type || '')).forEach(n => {
|
||||||
n.position = { x: xBase, y: yCursor }
|
n.position = { x: xBase, y: yCursor }
|
||||||
yCursor += yGap
|
yCursor += getNodeHeight(n.id) + yGap
|
||||||
})
|
})
|
||||||
|
|
||||||
laneIndex += 1
|
laneIndex += 1
|
||||||
@ -521,35 +535,7 @@ export default function OrganizationEditor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reparenting: select source then target
|
// 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@ -604,25 +590,7 @@ export default function OrganizationEditor() {
|
|||||||
>
|
>
|
||||||
Auto anordnen
|
Auto anordnen
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@ -894,32 +862,7 @@ 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren