Wie erstellt man mit Excel Makros automatisch einen Projektstrukturplan? Das geht gar nicht so schwer und bringt Ihnen dauerhaft ungeahnte Zeiteinsparungen.
Vor 4 Jahren habe ich zum ersten Mal das Buch von Paul A. Akers 2 Second Lean gelesen. Bei Paul dreht sich alles um stetige Verbesserung. Das Buch handelt von seiner Lean Reise: einmal für ihn persönlich und einmal mit seiner Firma. Sie können sich die zahlreichen Videos bei Youtube anschauen. Ich las es damals noch in Englisch – fand es aber so unterhaltsam geschrieben, dass ich es in einem Rutsch an zwei Tagen gelesen habe.
Paul Aker schreibt:
I said to my people, “Just give me a single 2 second improvement a day. That’s it. That’s all I ask for. A 2 second improvement.”
Paul Aker
Das hat mich damals ziemlich beeindruckt. Jeden Tag eine Verbesserung, die nur 2 Sekunden einspart. Das klingt am Anfang nach extrem wenig, hat aber riesige Auswirkungen. Seitdem verbessere ich auch in kleinen Schritten meine Arbeitsabläufe.
Und in diesem 2-Second-Lean geht es um die automatische Erstellung von Projektstrukturplänen.
Die Funktion können Sie sich in den beiden Artikeln anschauen:
Was passiert?
Bevor es losgeht, schauen wir uns die Slow-Motion Variante des Makros an.
- Aktuellen Projektstrukturplan löschen
- Einstellung holen
- Projektstrukturplan zeilenweise aufbauen und Positionen berechnen und Shapes einfügen
- Oberstes Shape zentrieren
Erst mal alles löschen
Zuerst wird ein evtl. bereits vorhandener PSP gelöscht. Der Plan wird vollständig über Shape-Elemente aufgebaut: Rechtecke und Verbindungslinien. Alle Elemente bekommen ein Kennzeichen über den Namen mit. Sie beginnen mit N_. Deswegen ist es möglich, sie einfach über den Namen zu finden und zulöschen.
Sub DeleteWorkBreakdownStructure()
Dim i As Integer
Dim shape1
For Each shape1 In ActiveSheet.Shapes
If Left$(shape1.name, 2) = "N_" Then
shape1.Delete
End If
Next
End Sub
Der automatische Aufbau des Projektstrukturplans wird über die Sub CreateWorkBreakdownStructure durchgeführt.
Zuerst holen wir uns die Einstellungen, die in der Tabelle Setup hinterlegt sind. Die entsprechenden Zellen haben einen Namen bekommen. Dadurch können wir die Werte immer finden, auch wenn sich die Zellposition mal ändert.

Der Zugriff auf einen Wert geht über das Range-Objekt. Basis ist immer die jeweilige Tabelle. Bei uns ist das Setup.
spaceYLevel0 = Sheets("Setup").Range("LEVEL0_SPACE_Y")
spaceXLevel1 = Sheets("Setup").Range("LEVEL1_SPACE_X")
spaceYLevel3 = Sheets("Setup").Range("LEVEL3_SPACE_Y")
spaceXLevel3 = Sheets("Setup").Range("LEVEL3_SPACE_X")
Danach initialisieren wir noch ein paar Variablen mit Ihren Startwerten und beginnen mit der Hauptschleife.
Den WBS-PSP Code knacken
Jetzt geht es um Codes. Ich bin im Coding in der englischen Sprache geblieben. Deswegen heißt der PSP-Code, hier WBS-Code (work breakdown structure).
Diesen Code holen wir uns jetzt über die Hauptschleife. In dieser werden alle Einträge aus der Tabelle Start durchlaufen. Der steuernde Wert steht in der Spalte Code. Den lesen wir über das Objekt Cells(Zeile, Spalte) in eine Variable wbsCode ein.
Do
wbsCode = Sheets("Start").Cells(row, COL_CODE)
wbsCodeParent = GetParentWBS(wbsCode)
pParent = GetLastPosition(wbsCodeParent)
Damit wir wissen an welcher Hierachieebene und Position das neue Elemente angeordnet werden soll, brauchen wir auch das Eltern-Element dazu. Das ermitteln wir über eine eigene Funktion GetParentWBS(wbsCode) und übergeben unseren PSP-Code als Parameter.
Die Strukturierung des PSP-Codes erfolgt über Punkte. Um das Eltern-Element zu finden, müssen wir den PSP-Code von rechts durchlaufen, bis wir einen Punkt finden. Dann können wir den Teilstring von links ausgehend bis zu dieser Position-1 als Ergebnis zurück liefern.
Function GetParentWBS(wbsCode As String) As String
Dim i As Integer
Dim result As String
result = ""
For i = Len(wbsCode) To 1 Step -1
If Mid$(wbsCode, i, 1) = "." Then
result = Left$(wbsCode, i - 1)
Exit For
End If
Next
GetParentWBS = result
End Function
Die letzte Position
Haben wir das Eltern-Element gefunden, holen wir uns die aktuellen Positionsdaten. Das machen wir auch mit einer eigenen Funktion. Ich habe sie GetLastPosition genannt.
Function GetLastPosition(wbsCode As String) As Position
Dim row As Integer
Dim result As Position
Dim found As Boolean
Dim w As String
found = False
row = ROW_START
Do
w = Sheets("Start").Cells(row, COL_CODE)
If wbsCode = w Then
found = True
result.x = Sheets("Start").Cells(row, COL_X)
result.y = Sheets("Start").Cells(row, COL_Y)
result.count = Sheets("Start").Cells(row, COL_COUNT)
End If
row = row + 1
Loop Until w = "" Or found = True
GetLastPosition = result
End Function
Die Funktion durchläuft genauso, wie die Hauptschleife alle Elemente in der Tabelle Start. Sie schaut dabei in die berechneten Spalten X, Y und Count. Wie diese berechnet werden? Später mehr.
Die drei Werte werden als Ergebnis zurück geliefert. Dafür gibt es einen eigenen Datentyp Position.
Type Position x As Double y As Double count As Integer End Type
Mit dieser Positionsinformation (gespeichert in der Variable pParent) des Eltern-Elements kommen wir dann wieder zurück in die Hauptschleife.
Jetzt kommt Struktur
Unser Projektstrukturplan bekommt jetzt seine Struktur. In der Tabelle Setup können die ersten 3 Ebenen (Levels) frei formatiert werden. Diese Level müssen wir jetzt im Quellcode erkennen und entsprechend den Parameter, die wir bei Programmstart gelesen haben, positionieren.
Um den Level zu erkennen, zählen wir einfach die Punkte im PSP-Code. Das macht die Funktion CountPoints. 0 Punkte entspricht der obersten Ebene, 1 Punkt der zweiten. Für alle weiteren gilt die Formatierung der Ebene 3.
In den If-Abfragen der Level, holen wir uns weitere spezifische Parameter des Levels, wie Elementbreite oder Elementhöhe. Und natürlich auch den Ausgabetext.
Unser Projektstrukurplan besteht aus Rechteck-Shapes. Auf diese greifen wir in VBA mit dem Objekt Shapes(Name) zu. Den Namen hat das Shape in der Tabelle Setup vorher manuell bekommen.
Damit wir ein neues Shape auch richtig positionieren können, speichern wir die aktuelle Position über die Variable p. Sie ist auch vom Typ Position.
level = CountPoints(wbsCode)
If level = 0 Then 'usualy projectname
caption = Sheets("setup").Shapes("LEVEL_1").TextFrame.Characters.Text
w = Sheets("Setup").Shapes("LEVEL_1").Width
h = Sheets("Setup").Shapes("LEVEL_1").Height
p.y = h
ElseIf level = 1 Then 'phase or part project
caption = Sheets("setup").Shapes("LEVEL_2").TextFrame.Characters.Text
w = Sheets("Setup").Shapes("LEVEL_2").Width
h = Sheets("Setup").Shapes("LEVEL_2").Height
If pParent.count = 0 Then
p.x = 0
p.y = p.y + h * spaceYLevel0
Else
d = GetLastMaxPosition(wbsLevel1Old)
If d <> 0 Then
p.x = d + spaceXLevel1 * w
Else
p.x = pParent.x + spaceXLevel1 * w
End If
p.y = pParent.y
End If
Else
caption = Sheets("setup").Shapes("LEVEL_3").TextFrame.Characters.Text
w = Sheets("Setup").Shapes("LEVEL_3").Width
h = Sheets("Setup").Shapes("LEVEL_3").Height
If pParent.count = 0 Then
p.x = pParent.x + w * spaceXLevel3
If level = 2 Then
d = Sheets("Setup").Shapes("LEVEL_2").Height
Else
d = h
End If
p.y = pParent.y + d * spaceYLevel3
Else
p.x = pParent.x
p.y = pParent.y + h * spaceYLevel3
End If
End If
Mit den obigen Zeilen haben wir für das aktuelle Element für jeden möglichen Level die Position berechnet. Wir haben die geplante Höhe und Breite in den Variablen h und w gespeichert. Und der Titel ist in der Variable caption abgelegt.
Mit den folgenden Zeilen speichern wir uns die berechnete Position zum jeweiligen Element in der Tabelle Start ab. Sie werden dort als Spalte X, Y und Count abgelegt.
Alles wird gespeichert
Das machen wir auf drei Arten. Der Aufbau des Projektstrukturplans kann sehr groß sein und viele Levels enthalten. Bauen wir aber einen neuen Zweig der zweiten Ebene auf, brauchen wir die max. Position des Eltern-Elements. Deshalb speichern wir für alle Elemente auf der Eltern-Ebene die aktuelle X Position, sozusagen als maximaler Wert ab.
p.count = pParent.count + 1
Call SetPositions(wbsCodeParent, p)
Call SetPosition(wbsCodeParent, p)
p.count = 0
Call SetPosition(wbsCode, p)
Und nochmal speichern
Was passiert nun alles in der Prozedur SetPositions? Es werden wieder alle Element der Tabelle Start durchlaufen. Zuerst holen wir uns aber den PSP-Code des zweiten Levels, z. B. 1.2 aus 1.2.2.2.1.
Das ist nämlich unser Zweig. Unter diesem werden alle Elemente untereinander angeordnet, wie die Elemente im Windows Explorer. Beim Durchlaufen setzen wir die Y Koordinate bei allen Elemente gleichen Level auf den neuen Wert.
Sub SetPositions(wbsCode As String, p As Position)
Dim row As Integer
Dim result As Position
Dim w As String
Dim wbsLevel2 As String
wbsLevel2 = GetLevel2WBS(wbsCode)
row = ROW_START
Do
w = Sheets("Start").Cells(row, COL_CODE)
If (wbsCode = w) Or ((wbsLevel2 = Left$(w, Len(wbsLevel2))) And (wbsLevel2 <> "")) Then
Sheets("Start").Cells(row, COL_COUNT) = p.count
Sheets("Start").Cells(row, COL_Y) = p.y
End If
row = row + 1
Loop Until w = ""
End Sub
Bei der Prozedur SetPosition passiert fast das gleiche, nur wird hier auch die X Koordinate und Count gespeichert.
Sub SetPosition(wbsCode As String, p As Position)
Dim row As Integer
Dim result As Position
Dim found As Boolean
Dim w As String
found = False
row = ROW_START
Do
w = Sheets("Start").Cells(row, COL_CODE)
If wbsCode = w Then
found = True
Sheets("Start").Cells(row, COL_X) = p.x
Sheets("Start").Cells(row, COL_Y) = p.y
Sheets("Start").Cells(row, COL_COUNT) = p.count
End If
row = row + 1
Loop Until w = "" Or found = True
End Sub
Jetzt kommt die Beschriftung
Zurück in der Hauptschleife sind jetzt die Positionen gespeichert und können beim nächsten Durchlaufen als neue Position wieder gelesen werden.
Wir kümmern uns beim nächsten Schritt um die Beschriftung unseres PSP. In der Variable caption ist der Text auch als Variable abgelegt. Diese Variablen ersetzen wir jetzt mit den Daten aus Tabelle Start.
Zuerst prüfen wir, ob der Benutzer die Fortschrittsformatierung haben möchte. Wenn Ja, holen wir uns das entsprechende Format für den jeweiligen Leven in die Variable caption. Wenn nein, bleibt caption wie es war.
If UCase(Sheets("Setup").Range("PROGRESS_COLORS")) = "J" Then
If progress = 0 Then
caption = Replace(caption, "$PROGRESS", Sheets("Setup").Range("FORMAT_NOT_STARTED"))
ElseIf progress = 1 Then
caption = Replace(caption, "$PROGRESS", Sheets("Setup").Range("FORMAT_COMPLETED"))
Else
caption = Replace(caption, "$PROGRESS", Sheets("Setup").Range("FORMAT_IN_PROGRESS"))
End If
End If
Im nächsten Schritt ersetzen wir die internen Variablen mit den Daten aus Tabelle Start. Das geht über die VBA Funktion Replace.
caption = Replace(caption, "$CODE", Sheets("Start").Cells(row, COL_CODE))
caption = Replace(caption, "$NAME", Sheets("Start").Cells(row, COL_NAME))
caption = Replace(caption, "$PROGRESS", Format(progress, "0%"))
For i = 10 To 1 Step -1
caption = Replace(caption, "$F" & CStr(i), Sheets("Start").Cells(row, COL_FIELDS + i - 1))
Next
Shapes und Connectors
Dann kommt endlich der spannende Teil. Wir erzeugen uns ein Rechteck und fügen es ein.
Call InsertRectangle(wbsCode, caption, p.x, p.y, w, h, level, progress)
If level > 0 Then Call InsertConnector(wbsCodeParent, wbsCode, level)
Das machen wir auch über eine eigene Prozedure InsertRectangle. Das Einfügen soll auf das aktuelle Tabellenblatt erfolgen, deshalb nutzen wir ActiveSheet.
Ein neues Shape wird über die Methode Shapes.AddShape eingefügt. Sie müssen aber nicht unbedingt ein Rechteck-Shape nehmen.
Unter https://docs.microsoft.com/de-de/office/vba/api/office.msoautoshapetype finden Sie die Namen der weiteren Möglichkeiten.
Sub InsertRectangle(name As String, caption As String, x As Double, y As Double, w As Double, h As Double, level As Integer, progress As Double)
Dim s As String
Dim c As Long
Dim id As String
ActiveSheet.Shapes.AddShape(msoShapeRoundedRectangle, x, y, w, h).Select
id = "N_" & name
Selection.name = id
If level > 1 Then
s = "3"
Else
s = CStr(level + 1)
End If
Als Namen verwenden wir wieder den PSP-Code. Wir stellen ihm aber den Text „N_“ voran.
Um die Formatierung aus dem Tabellenblatt Setup zu bekommen, nutzen wir die Excel Funktion Format übertragen. Das geht mit dem Befehl Sheets(Name).Shapes(Name).PickUp und das Übertragen mit Apply.
Sheets("Setup").Shapes("LEVEL_" & s).PickUp
ActiveSheet.Shapes(id).Apply
If UCase(Sheets("Setup").Range("PROGRESS_COLORS")) = "J" Then
If progress = 0 Then
Selection.ShapeRange.Fill.ForeColor.RGB = Sheets("Setup").Range("PROGRESS_NOT_STARTED").Cells.Interior.Color
ElseIf progress = 1 Then
Selection.ShapeRange.Fill.ForeColor.RGB = Sheets("Setup").Range("PROGRESS_COMPLETED").Cells.Interior.Color
Else
Selection.ShapeRange.Fill.ForeColor.RGB = Sheets("Setup").Range("PROGRESS_IN_PROGRESS").Cells.Interior.Color
End If
End If
Selection.ShapeRange(1).TextFrame2.TextRange.Characters.Text = caption
End Sub
Danach erfolgt im Quellcode die Entscheidung, ob nach Fortschritt formatiert werden soll. Wenn Ja, werden die Farbeinstellungen entsprechend wieder gelesen und dem Shape Interior.Color übergeben.
Am Schluss wird noch der Titel des Shapes angepasst.
Das Einfügen der Verbindungslinien erfolgt nach dem gleichen Schema. Als Kontaktpunkte nutzen wir die Namen der Rechteck-Elemente. Und die kennen wir über den PSP-Code.
Sub InsertConnector(wbsCodeFrom As String, wbsCodeTo As String, level As Integer)
Dim pFrom As Integer
Dim pTo As Integer
If level = 1 Then
pFrom = 3
pTo = 1
Else
pFrom = 3
pTo = 2
End If
ActiveSheet.Shapes.AddConnector(msoConnectorElbow, 0, 0, 100, 100).Select
Selection.name = "N_" & wbsCodeFrom & "_" & wbsCodeTo
Selection.ShapeRange.Line.EndArrowheadStyle = msoArrowheadTriangle
Selection.ShapeRange.ConnectorFormat.BeginConnect ActiveSheet.Shapes("N_" & wbsCodeFrom), pFrom
Selection.ShapeRange.ConnectorFormat.EndConnect ActiveSheet.Shapes("N_" & wbsCodeTo), pTo
Selection.ShapeRange.Line.EndArrowheadStyle = msoArrowheadStealth
With Selection.ShapeRange.Line
.EndArrowheadLength = msoArrowheadLong
.EndArrowheadWidth = msoArrowheadWide
.Visible = msoTrue
.Weight = 1
.Transparency = 0
End With
Sheets("Setup").Shapes("CONNECTOR").PickUp
ActiveSheet.Shapes("N_" & wbsCodeFrom & "_" & wbsCodeTo).Apply
End Sub
Fast geschafft!
Am Ende der Hauptschleife prüfen wir noch, ob das eingefügte Element das erste war und merken uns den PSP-Code. Das brauchen wir am Ende, um das Element zu zentrieren.
Dann bereiten wir alles für den nächsten Durchlauf vor: row hochzählen, Level und PSP-Code merken – fertig für das nächste Element.
If row = ROW_START Then firstshape = "N_" & wbsCode
row = row + 1
levelOld = level
wbsOld = wbsCode
If level = 1 Then wbsLevel1Old = wbsCode
wbsCode = Sheets("Start").Cells(row, COL_CODE)
Loop Until wbsCode = ""
Am Schluss kommt noch das Zentrieren des ersten Elements. Da wir alle X Werte in der Tabelle Start gespeichert haben, können wir uns den maximalen X Wert suchen und über die Element-Breite zentrieren.
If firstshape <> "" Then
d = FindXmax() + Sheets("Setup").Shapes("LEVEL_3").Width
ActiveSheet.Shapes("N_1").Left = (d - Sheets("Setup").Shapes("LEVEL_1").Width) / 2
End If
ActiveSheet.Cells(1, 1).Select
In der letzten Zeile stellen wir den Cursor noch auf das Feld links oben und freuen uns über einen tollen Projektstrukturplan.
Vielleicht hat dieses Coding bei Ihnen die Lust zum Verbessern, wie bei Paul Aker und mir ein wenig entfacht.
Bleiben Sie dran!
Download
Möchten Sie über zukünftige Aktualisierungen informiert bleiben, dann abonnieren Sie am besten meinen Blog.
Download:
Automatic_WBS.xlsm
Haben Sie bei Excel-Makros Sicherheitsbedenken? Dann lesen Sie die Seite Excel-Makros und das Internet.
Links
E-Book 2 Second Lean (das Buch gibt es heute auch in Deutsch)
Artikel Projektstrukturplan in 3 Minuten
Artikel Projektstrukturplan mit automatischer Fortschrittsanzeige

