7 Commits

19 changed files with 841 additions and 62 deletions
+346
View File
@@ -0,0 +1,346 @@
# LeanderShiftPlannerV2 Technische Dokumentation
**Version:** 2.0.1
**Sprache:** C# / .NET 9.0
**Framework:** Avalonia UI 11.2.1
**Repository:** https://gitea.leanderserver.de/leander19961/LeanderShiftPlannerV2
---
## Inhaltsverzeichnis
1. [Projektübersicht](#1-projektübersicht)
2. [Systemvoraussetzungen & Abhängigkeiten](#2-systemvoraussetzungen--abhängigkeiten)
3. [Projektstruktur](#3-projektstruktur)
4. [Datenmodell](#4-datenmodell)
- 4.1 [Person](#41-person)
- 4.2 [Shift (Schicht)](#42-shift-schicht)
- 4.3 [ShiftPlan](#43-shiftplan)
- 4.4 [Timesheet (Stundenzettel)](#44-timesheet-stundenzettel)
- 4.5 [Filter](#45-filter)
5. [Services (Geschäftslogik)](#5-services-geschäftslogik)
- 5.1 [AppService](#51-appservice)
- 5.2 [PersonService](#52-personservice)
- 5.3 [ShiftService](#53-shiftservice)
- 5.4 [ShiftPlanService](#54-shiftplanservice)
- 5.5 [FilterService](#55-filterservice)
- 5.6 [TimesheetService](#56-timesheetservice)
- 5.7 [ViewService](#57-viewservice)
6. [Berechnungsalgorithmus](#6-berechnungsalgorithmus)
7. [Datei-I/O (FileIO)](#7-datei-io-fileio)
8. [Benutzeroberfläche (View)](#8-benutzeroberfläche-view)
9. [Ausgabedateien](#9-ausgabedateien)
10. [Hinweise & Einschränkungen](#10-hinweise--einschränkungen)
---
## 1. Projektübersicht
LeanderShiftPlannerV2 ist eine Desktop-Anwendung zur **automatisierten Erstellung von Schichtplänen**. Das Tool erlaubt das Anlegen, Verwalten und Verknüpfen von Personen und Schichten, die anschließende Berechnung aller validen Schichtplankombinationen sowie die automatische Generierung von Stundenzetteln.
**Kernfunktionen:**
- Verwaltung von Personen (inkl. Wochenstunden und Stundenzettel-Quelle)
- Verwaltung von Schichten (Wochentag, Uhrzeit, Min./Max.-Personenanzahl)
- Bidirektionale Verknüpfung von Personen und Schichten
- Parallele Berechnung aller gültigen Schichtplankombinationen
- Filterung der berechneten Pläne
- Ausgabe als PNG und/oder Textdatei
- Automatische Generierung von Stundenzetteln (Excel-Format, optional Google Calendar-Integration)
> **Wichtig:** Alle Stundenangaben müssen im **Wochenstunden-Format** erfolgen!
---
## 2. Systemvoraussetzungen & Abhängigkeiten
| Anforderung | Details |
|---|---|
| .NET-Version | .NET 9.0 |
| UI-Framework | Avalonia UI 11.2.1 (Cross-Platform: Windows, Linux, macOS) |
| Ausgabetyp | WinExe (Desktop-Anwendung) |
**NuGet-Pakete:**
| Paket | Version | Verwendung |
|---|---|---|
| Avalonia | 11.2.1 | UI-Framework |
| Avalonia.Desktop | 11.2.1 | Desktop-Unterstützung |
| Avalonia.Themes.Fluent | 11.2.1 | Fluent Design Theme |
| Avalonia.Fonts.Inter | 11.2.1 | Schriftarten |
| EPPlus | 7.5.3 | Excel-Generierung (Stundenzettel) |
| Google.Apis.Calendar.v3 | 1.69.0.3746 | Google Calendar Integration |
| SkiaSharp | 3.118.0-preview | PNG-Rendering der Schichtpläne |
---
## 3. Projektstruktur
```
LeanderShiftPlannerV2/
├── LeanderShiftPlannerV2.sln
└── LeanderShiftPlannerV2/
├── App.axaml / App.axaml.cs # Avalonia-Anwendungseinstieg
├── Program.cs # Main-Einstiegspunkt
├── app.manifest
├── LeanderShiftPlannerV2.csproj
├── Model/ # Datenmodelle (POCOs)
│ ├── Person.cs
│ ├── Shift.cs
│ ├── ShiftPlan.cs
│ ├── Timesheet.cs
│ └── Filter.cs
├── Service/ # Geschäftslogik / Services
│ ├── AppService.cs # Zentraler Service-Container
│ ├── PersonService.cs
│ ├── ShiftService.cs
│ ├── ShiftPlanService.cs # Kernalgorithmus
│ ├── FilterService.cs
│ ├── TimesheetService.cs
│ └── ViewService.cs
├── FileIO/ # Laden/Speichern von Daten
├── View/ # Avalonia AXAML Views
├── Util/ # Hilfsfunktionen
└── Resources/
├── icon/ # Anwendungssymbol
├── font/ # Eingebettete Schriften (Emblem.ttf, Roboto.ttf)
└── templates/
├── timesheet.xlsx # Excel-Vorlage für Stundenzettel
└── googleapi.txt # Google API-Konfiguration
```
---
## 4. Datenmodell
### 4.1 Person
**Datei:** `Model/Person.cs`
**Implementiert:** `INotifyPropertyChanged`, `[Serializable]`
Repräsentiert eine Person, die in Schichten eingeteilt werden kann.
| Eigenschaft | Typ | Beschreibung |
|---|---|---|
| `FirstName` | `string` | Vorname der Person |
| `Surname` | `string` | Nachname der Person |
| `Hours` | `int` | Wöchentliche Arbeitsstunden (Pflichtangabe) |
| `TimeSheetSource` | `string` | Pfad/Quelle für die Stundenzettel-Daten |
| `Shifts` | `ObservableCollection<Shift>` | Liste verknüpfter Schichten (nicht serialisiert) |
| `Timesheets` | `List<Timesheet>` | Liste der Stundenzettel (nicht serialisiert) |
| `ShiftsString` | `string` (readonly) | Lesbare Darstellung aller Schichten für die UI |
**Methoden:**
- `AddShift(Shift)` / `RemoveShift(Shift)` / `ClearShifts()` Verwaltung der Schichtverknüpfungen (lösen jeweils `PropertyChanged` aus)
- `GetTimeSheetByMonth(string month)` Gibt den Stundenzettel für einen bestimmten Monat zurück
---
### 4.2 Shift (Schicht)
**Datei:** `Model/Shift.cs`
**Implementiert:** `INotifyPropertyChanged`, `[Serializable]`
Repräsentiert eine einzelne Schicht an einem bestimmten Wochentag.
| Eigenschaft | Typ | Beschreibung |
|---|---|---|
| `Day` | `string` | Wochentag (z.B. "Montag") |
| `StartTime` | `string` | Startzeit (Format: "HH:mm") |
| `EndTime` | `string` | Endzeit (Format: "HH:mm") |
| `Duration` | `int` | Dauer der Schicht in Stunden |
| `ShiftInDay` | `int` | Position der Schicht am Tag (1 = erste, 2 = zweite, 3 = dritte) |
| `MaxPersonCount` | `int` | Maximale Anzahl Personen pro Schicht |
| `MinPersonCount` | `int` | Minimale Anzahl Personen pro Schicht |
| `Persons` | `ObservableCollection<Person>` | Verknüpfte Personen (nicht serialisiert) |
| `PersonsString` | `string` (readonly) | Lesbare Darstellung aller Personen für die UI |
| `Time` | `string` (readonly) | Formatierte Zeitanzeige "HH:MM - HH:MM" |
**Methoden:**
- `AddPerson(Person)` / `RemovePerson(Person)` / `ClearPersons()` Verwaltung der Personenverknüpfungen
---
### 4.3 ShiftPlan
**Datei:** `Model/ShiftPlan.cs`
Repräsentiert einen vollständigen Schichtplan, der aus einer Kombination von Schichten über alle konfigurierten Wochentage besteht. Wird vom `ShiftPlanService` erzeugt.
---
### 4.4 Timesheet (Stundenzettel)
**Datei:** `Model/Timesheet.cs`
Repräsentiert den Stundenzettel einer Person für einen bestimmten Monat. Beinhaltet die Arbeitszeiterfassung und wird wahlweise aus einer lokalen Quelle oder über Google Calendar bezogen.
| Eigenschaft | Typ | Beschreibung |
|---|---|---|
| `Month` | `string` | Monat im Format "MM.YYYY" o.ä. |
---
### 4.5 Filter
**Datei:** `Model/Filter.cs`
Hält die aktuell gesetzten Filterkriterien für die Anzeige der berechneten Schichtpläne.
---
## 5. Services (Geschäftslogik)
### 5.1 AppService
**Datei:** `Service/AppService.cs`
Zentraler Service-Container, der alle anderen Services hält und als einheitlicher Zugriffspunkt dient. Wird bei Anwendungsstart instanziiert und an alle abhängigen Komponenten weitergegeben.
**Hält Referenzen auf:** `PersonService`, `ShiftService`, `ShiftPlanService`, `FilterService`, `TimesheetService`, `ViewService`
---
### 5.2 PersonService
**Datei:** `Service/PersonService.cs`
Verwaltet die Liste aller Personen sowie deren Stundenzettel-Daten.
**Kernfunktionen:**
- CRUD-Operationen für `Person`-Objekte
- Laden von Stundenzetteln aus einer externen Quelle (`TimeSheetSource`)
- `GenerateRandomTimeSheets()` Generiert zufällige Stundenzettel auf Basis von Google Calendar-Einträgen und passt sie an die Wochenstunden der Person an
---
### 5.3 ShiftService
**Datei:** `Service/ShiftService.cs`
Verwaltet die Liste aller Schichten.
**Kernfunktionen:**
- CRUD-Operationen für `Shift`-Objekte
- `GetShiftList()` Gibt alle konfigurierten Schichten zurück
---
### 5.4 ShiftPlanService
**Datei:** `Service/ShiftPlanService.cs`
Der **Kernservice** der Anwendung. Enthält den gesamten Berechnungsalgorithmus für Schichtpläne.
**Wichtige Methoden:**
| Methode | Beschreibung |
|---|---|
| `StartCalculation(CancellationToken)` | Startet die vollständige Berechnung (Listen initialisieren → Schichten generieren → Pläne berechnen) |
| `GeneratePlannedShifts()` | Erzeugt alle möglichen Schichtkombinationen (Permutationen nach Personenanzahl: 1, 2 oder 3 Personen pro Schicht) |
| `CalculateShiftPlans(CancellationToken)` | Kombiniert die Tagesschichten zu vollständigen Wochenplänen, **parallel** mit `Parallel.ForEach` |
| `CheckShift(List<...>)` | Prüft, ob ein Schichtplan die Wochenstunden aller Personen exakt erfüllt (nicht über- oder unterschreitet) |
| `GetShiftPlans()` | Gibt die aktuell gefilterte `ObservableCollection<ShiftPlan>` zurück |
| `GetAllShiftPlans()` | Gibt alle berechneten Pläne (ungefiltert) zurück |
| `ResetShiftPlanList()` | Setzt den gefilterten Stand auf alle berechneten Pläne zurück |
---
### 5.5 FilterService
**Datei:** `Service/FilterService.cs`
Wendet einen `Filter` auf die Liste der Schichtpläne an und reduziert die angezeigte Liste entsprechend.
---
### 5.6 TimesheetService
**Datei:** `Service/TimesheetService.cs`
Generiert Stundenzettel-Dateien im Excel-Format (`Data/Output/Timesheets`) auf Basis der `Timesheet`-Daten der Personen und der bereitgestellten `timesheet.xlsx`-Vorlage.
---
### 5.7 ViewService
**Datei:** `Service/ViewService.cs`
Übernimmt die Darstellung eines `ShiftPlan` als PNG-Bild mittels **SkiaSharp**. Speichert alle Pläne im Verzeichnis `Data/Output/ShiftPlans`.
---
## 6. Berechnungsalgorithmus
Der Algorithmus in `ShiftPlanService` arbeitet in drei Phasen:
### Phase 1 Generierung der Einzelschicht-Varianten (`GeneratePlannedShifts`)
Für jede konfigurierte Schicht werden alle Kombinationen von Personen gebildet, die dieser Schicht zugewiesen sind von `MinPersonCount` bis `MaxPersonCount`. Das Ergebnis ist eine Liste aller möglichen "besetzten Einzelschichten":
- **1 Person:** Jede Person einzeln
- **2 Personen:** Alle Zweier-Kombinationen (ohne Wiederholung)
- **3 Personen:** Alle Dreier-Kombinationen (ohne Wiederholung)
### Phase 2 Aufbau der Tagesschichten (`GenerateDayShifts`)
Die Einzelschicht-Varianten werden nach Wochentag gruppiert. Innerhalb eines Tages werden die bis zu 3 möglichen Positionen (ShiftInDay = 1, 2, 3) zu Dreier-Tupeln `(Shift, Shift, Shift)` kombiniert. Fehlt eine Position, wird `null` eingesetzt.
### Phase 3 Kombination zum Wochenplan & Validierung (`CalculateShiftPlans`)
Alle Tagesschichten werden durch **verschachtelte `Parallel.ForEach`-Schleifen** kombiniert (je ein Level pro Wochentag). Für jede entstehende Wochenplan-Kombination wird `CheckShift` aufgerufen:
- Es werden die kumulierten Stunden aller Personen berechnet.
- Ein Plan gilt nur dann als **gültig**, wenn jede Person **exakt** ihre konfigurierten Wochenstunden arbeitet (weder mehr noch weniger).
- Gültige Pläne werden in die `ObservableCollection<ShiftPlan>` eingetragen.
> **Hinweis zur Performance:** Die Berechnung ist exponentiell in der Anzahl der Schichten und Personen. Die parallele Ausführung über `Parallel.ForEach` nutzt alle verfügbaren CPU-Kerne. Bei komplexen Konfigurationen kann die Berechnung dennoch mehrere Sekunden bis Minuten dauern.
---
## 7. Datei-I/O (FileIO)
Das `FileIO`-Verzeichnis enthält Klassen zum Laden und Speichern des Anwendungszustands (Personen, Schichten) als XML-Dateien mittels `XmlSerializer`.
- Personen und Schichten werden als XML serialisiert/deserialisiert.
- Verknüpfungen (`Shifts` in Person, `Persons` in Shift) sind mit `[XmlIgnore]` dekoriert und werden nach dem Laden aus dem XML neu aufgebaut, um zirkuläre Referenzen zu vermeiden.
---
## 8. Benutzeroberfläche (View)
Die Oberfläche ist mit **Avalonia UI** und dem **Fluent Theme** umgesetzt. Sie besteht aus mehreren Tabs:
| Tab | Funktion |
|---|---|
| **Persons** | Personen anlegen (`Add new person`), bearbeiten (`Edit person`), löschen (`Delete person`), Schichten zuweisen (`Select shifts for person`) |
| **Shifts** | Schichten anlegen (`Add new shift`), bearbeiten (`Edit shift`), löschen (`Delete shift`), Personen zuweisen (`Select persons for shift`) |
| **ShiftPlans** | Berechnung starten (`Start calculation`), Pläne filtern (`Filter shiftplans`), Filter zurücksetzen (`Reset all filters`), Plan öffnen (`Open selected ShiftPlan`), alle Pläne speichern (`Save all ShiftPlans`), Stundenzettel generieren (`Generate Timesheets`) |
**Bidirektionale Verknüpfung:** Die Zuweisung von Personen und Schichten erfolgt stets automatisch in beide Richtungen. Eine über "Select shifts for person" hinzugefügte Schicht wird der Person **und** gleichzeitig der Person zur Schicht hinzugefügt ohne manuellen Zweischritt.
---
## 9. Ausgabedateien
| Pfad | Inhalt |
|---|---|
| `Data/Output/ShiftPlans/` | Alle Schichtpläne als `.png`-Dateien (via `Save all ShiftPlans`) |
| `Data/Output/Timesheets/` | Stundenzettel als `.xlsx`-Dateien (via `Generate Timesheets`) |
---
## 10. Hinweise & Einschränkungen
- **Stundenformat:** Alle Stunden müssen als **Wochenstunden** angegeben werden. Tagesstunden oder Monatsstunden werden nicht unterstützt.
- **Personenanzahl pro Schicht:** Der Algorithmus unterstützt aktuell maximal **3 Personen** pro Schicht. Höhere Werte für `MaxPersonCount` werden im Code nicht berücksichtigt.
- **Anzahl Wochentage:** Der Algorithmus ist für genau **5 Wochentage** ausgelegt (die Schleifentiefe in `CalculateShiftPlans` ist auf 5 verschachtelt).
- **Stundenzettel-Quelle:** Die `TimeSheetSource` einer Person verweist auf eine externe Datenquelle (Google Calendar oder lokale Datei), die vor der Generierung verfügbar sein muss.
- **Google API:** Die Konfiguration der Google Calendar API erfolgt über die eingebettete Ressource `Resources/templates/googleapi.txt`.
- **Performance:** Bei sehr vielen Personen und Schichten kann die Anzahl der Permutationen exponentiell wachsen. Eine Abbruchmöglichkeit via `CancellationToken` ist implementiert.
---
*Dokumentation erstellt am 22.04.2026*
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.IO;
using LeanderShiftPlannerV2.Util;
namespace LeanderShiftPlannerV2.FileIO;
public static class GoogleApiLoader
{
public static bool CheckFileSystem()
{
if (!Directory.Exists(Constants.DataPath))
Directory.CreateDirectory(Constants.DataPath);
if (!File.Exists(Constants.DataPath + Constants.GoogleApiFilePath))
{
File.Create(Constants.DataPath + Constants.GoogleApiFilePath).Close();
File.WriteAllLines(Constants.DataPath + Constants.GoogleApiFilePath,
File.ReadAllLines(Constants.GoogleApiFileResource));
return false;
}
return true;
}
public static Dictionary<string, string> ReadFile()
{
if (!CheckFileSystem()) return new Dictionary<string, string>();
Dictionary<string, string> apiSecrets = new Dictionary<string, string>();
try
{
string[] lines = File.ReadAllLines(Constants.DataPath + Constants.GoogleApiFilePath);
foreach (string line in lines)
{
string[] parts = line.Split(new[] { '=' }, 2);
if (parts.Length == 2)
{
string pattern = parts[0].Trim();
string value = parts[1].Trim();
if (!string.IsNullOrEmpty(pattern))
{
apiSecrets[pattern] = value;
}
}
}
}
catch (Exception ex)
{
//TODO
Console.WriteLine($"Fehler beim Lesen der Datei: {ex.Message}");
}
return apiSecrets;
}
}
+6 -5
View File
@@ -18,7 +18,7 @@ namespace LeanderShiftPlannerV2.FileIO
if (!Directory.Exists(Constants.ShiftPlanPath)) Directory.CreateDirectory(Constants.ShiftPlanPath);
}
public static SKImage GenerateShiftPlanPng(ShiftPlan shiftPlan)
public static SKImage GenerateShiftPlanPng(ShiftPlan shiftPlan, string font)
{
Dictionary<(int, int), List<string>> names = GetNamesDict(shiftPlan);
@@ -30,7 +30,7 @@ namespace LeanderShiftPlannerV2.FileIO
using (SKBitmap bitmap = new SKBitmap(1000, 850))
using (SKCanvas canvas = new SKCanvas(bitmap))
{
SKFont smallTextFont = new SKFont(SKTypeface.FromFile(Constants.FontPath)) { Size = 24 };
SKFont smallTextFont = new SKFont(SKTypeface.FromFile($"Resources/font/{font}")) { Size = 24 };
SKPaint rectColorGray = new SKPaint
{ Color = SKColors.LightGray, Style = SKPaintStyle.Stroke, StrokeWidth = 5 };
SKPaint textColorGray = new SKPaint { Color = SKColors.Gray };
@@ -40,7 +40,7 @@ namespace LeanderShiftPlannerV2.FileIO
canvas.Clear(SKColors.White);
// Draw Topic
using (SKFont topic = new SKFont(SKTypeface.FromFile(Constants.FontPath)))
using (SKFont topic = new SKFont(SKTypeface.FromFile($"Resources/font/{font}")))
{
topic.Size = 40;
string text1 = "Schichtplan ";
@@ -106,12 +106,13 @@ namespace LeanderShiftPlannerV2.FileIO
}
}
public static void ExportShiftPlan(SKImage image, int id)
public static void ExportShiftPlan(SKImage image, int id, string font)
{
CheckFileSystem();
using SKData data = image.Encode(SKEncodedImageFormat.Png, 100);
using FileStream dataStream = File.OpenWrite(Constants.ShiftPlanPath + $"ShiftPlan_{id}.png");
if (!Directory.Exists(Constants.ShiftPlanPath + $"/{font}/")) Directory.CreateDirectory(Constants.ShiftPlanPath + $"/{font}/");
using FileStream dataStream = File.OpenWrite(Constants.ShiftPlanPath + $"/{font}/" + $"ShiftPlan_{id}.png");
data.SaveTo(dataStream);
}
+4 -4
View File
@@ -86,9 +86,9 @@ public static class TimesheetIO
foreach ((DateTime, DateTime, bool) tuple in timesheet.Value)
{
worksheet.Cells[17 + tuple.Item1.Day, 2].Value = tuple.Item1.Hour + (tuple.Item1.Minute == 0 ? ":00" : tuple.Item1.Minute.ToString());
worksheet.Cells[17 + tuple.Item1.Day, 3].Value = tuple.Item2.Hour + (tuple.Item2.Minute == 0 ? ":00" : tuple.Item1.Minute.ToString());
if (tuple.Item3) worksheet.Cells[17 + tuple.Item1.Day, 5].Value = "1:00";
worksheet.Cells[16 + tuple.Item1.Day, 2].Value = tuple.Item1.Hour + ":" + (tuple.Item1.Minute == 0 ? "00" : tuple.Item1.Minute.ToString());
worksheet.Cells[16 + tuple.Item1.Day, 3].Value = tuple.Item2.Hour + ":" + (tuple.Item2.Minute == 0 ? "00" : tuple.Item1.Minute.ToString());
if (tuple.Item3) worksheet.Cells[16 + tuple.Item1.Day, 5].Value = "1:00";
}
for (int i = 17; i <= 47; i++)
@@ -98,7 +98,7 @@ public static class TimesheetIO
}
worksheet.Cells[48, 6].Formula = "SUM(F17:F47)";
package.SaveAs(new FileInfo(@$"{filePath}/{month}.xlsx"));
package.SaveAs(new FileInfo(@$"{filePath}/{Constants.MonthsGerman.IndexOf(month):D2} - {month}.xlsx"));
}
}
}
@@ -8,10 +8,10 @@
<ApplicationIcon>Resources/icon/ShiftPlannerIcon.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<PackageIcon>Resources\icon\ShiftPlannerIcon.png</PackageIcon>
<AssemblyVersion>1.0.4</AssemblyVersion>
<FileVersion>1.0.4</FileVersion>
<AssemblyVersion>1.0.5</AssemblyVersion>
<FileVersion>1.0.5</FileVersion>
<NeutralLanguage>en</NeutralLanguage>
<Version>1.0.1</Version>
<Version>1.0.5</Version>
</PropertyGroup>
<ItemGroup>
@@ -25,6 +25,7 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="EPPlus" Version="7.5.3" />
<PackageReference Include="Google.Apis.Calendar.v3" Version="1.69.0.3746" />
<PackageReference Include="SkiaSharp" Version="3.118.0-preview.2.3" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.118.0-preview.2.3" />
<PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="3.118.0-preview.2.3" />
@@ -35,12 +36,22 @@
<EmbeddedResource Include="Resources\font\Emblem.ttf">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\font\Roboto.ttf">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\templates\timesheet.xlsx">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\templates\googleapi.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Folder Include="Resources\icon\" />
</ItemGroup>
<ItemGroup>
<UpToDateCheckInput Remove="View\TimeSheetView\TimeSheetView.axaml" />
</ItemGroup>
</Project>
+24 -1
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Xml.Serialization;
using LeanderShiftPlannerV2.Util;
namespace LeanderShiftPlannerV2.Model
{
@@ -12,6 +13,7 @@ namespace LeanderShiftPlannerV2.Model
private string _firstFirstName = null!;
private string _surname = null!;
private int _hours;
private string _timeSheetSource;
private ObservableCollection<Shift> _shifts;
[XmlIgnore]
@@ -30,11 +32,12 @@ namespace LeanderShiftPlannerV2.Model
_timesheets = new List<Timesheet>();
}
public Person(string firstFirstName, string surname, int hours)
public Person(string firstFirstName, string surname, int hours, string timeSheetSource)
{
this._firstFirstName = firstFirstName;
this._surname = surname;
this._hours = hours;
this._timeSheetSource = timeSheetSource;
_shifts = new ObservableCollection<Shift>();
_timesheets = new List<Timesheet>();
}
@@ -69,6 +72,16 @@ namespace LeanderShiftPlannerV2.Model
}
}
public string TimeSheetSource
{
get => _timeSheetSource;
set
{
_timeSheetSource = value;
OnPropertyChanged(nameof(TimeSheetSource));
}
}
[XmlIgnore]
public string ShiftsString
{
@@ -115,5 +128,15 @@ namespace LeanderShiftPlannerV2.Model
{
return _firstFirstName;
}
public Timesheet GetTimeSheetByMonth(string month)
{
foreach (Timesheet timesheet in _timesheets)
{
if (timesheet.Month == month) return timesheet;
}
return null;
}
}
}
Binary file not shown.
@@ -0,0 +1,2 @@
GoogleClientId=
GoogleClientSecret=
+66 -4
View File
@@ -1,7 +1,9 @@
using LeanderShiftPlannerV2.FileIO;
using System;
using LeanderShiftPlannerV2.FileIO;
using LeanderShiftPlannerV2.Model;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using LeanderShiftPlannerV2.Util;
namespace LeanderShiftPlannerV2.Service
{
@@ -9,11 +11,17 @@ namespace LeanderShiftPlannerV2.Service
{
private readonly AppService _appService;
private readonly ObservableCollection<Person> _persons;
private readonly List<Person> _randomTimeSheets;
private readonly List<Person> _googleTimeSheets;
private readonly List<Person> _randomGoogleTimeSheets;
public PersonService(AppService appService)
{
this._appService = appService;
_persons = new ObservableCollection<Person>();
_randomTimeSheets = new List<Person>();
_googleTimeSheets = new List<Person>();
_randomGoogleTimeSheets = new List<Person>();
LoadPersonsFromFile();
}
@@ -22,6 +30,7 @@ namespace LeanderShiftPlannerV2.Service
foreach (Person person in LoadDataFromFile.LoadPersons())
{
_persons.Add(person);
AddToTimeSheetList(person);
}
}
@@ -30,14 +39,30 @@ namespace LeanderShiftPlannerV2.Service
return _persons;
}
public Person AddNewPerson(string firstName, string surname, string hours)
public List<Person> GetRandomTimeSheetList()
{
Person newPerson = new Person(firstName, surname, int.Parse(hours));
return _randomTimeSheets;
}
public List<Person> GetGoogleTimeSheetList()
{
return _googleTimeSheets;
}
public List<Person> GetRandomGoogleTimeSheetList()
{
return _randomGoogleTimeSheets;
}
public Person AddNewPerson(string firstName, string surname, string hours, string timeSheetSource)
{
Person newPerson = new Person(firstName, surname, int.Parse(hours), timeSheetSource);
_persons.Add(newPerson);
AddToTimeSheetList(newPerson);
return newPerson;
}
public void EditPerson(Person person, string? firstName, string? surname, int? hours)
public void EditPerson(Person person, string? firstName, string? surname, int? hours, string? timeSheetSource)
{
if (firstName != null)
{
@@ -53,6 +78,35 @@ namespace LeanderShiftPlannerV2.Service
{
person.Hours = (int)hours;
}
if (timeSheetSource != null)
{
person.TimeSheetSource = timeSheetSource;
}
AddToTimeSheetList(person);
}
private void AddToTimeSheetList(Person person)
{
if (person.TimeSheetSource == Constants.TimeSheetSourceRandom)
{
_randomTimeSheets.Add(person);
_googleTimeSheets.Remove(person);
_randomGoogleTimeSheets.Remove(person);
}
else if (person.TimeSheetSource == Constants.TimeSheetSourceGoogle)
{
_randomTimeSheets.Remove(person);
_googleTimeSheets.Add(person);
_randomGoogleTimeSheets.Remove(person);
}
else if (person.TimeSheetSource == Constants.TimeSheetSourceRandomGoogle)
{
_randomGoogleTimeSheets.Remove(person);
_googleTimeSheets.Remove(person);
_randomGoogleTimeSheets.Add(person);
}
}
public void DeletePerson(Person person)
@@ -104,5 +158,13 @@ namespace LeanderShiftPlannerV2.Service
RemoveShiftFromPerson(person, shift);
}
}
public void ClearTimeSheets()
{
foreach (Person person in _persons)
{
person.Timesheets.Clear();
}
}
}
}
+213 -10
View File
@@ -1,12 +1,19 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using LeanderShiftPlannerV2.Model;
using LeanderShiftPlannerV2.Util;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Calendar.v3;
using Google.Apis.Services;
using LeanderShiftPlannerV2.FileIO;
using Newtonsoft.Json.Linq;
namespace LeanderShiftPlannerV2.Service;
@@ -21,13 +28,99 @@ public class TimesheetService
_appService = appService;
}
public async Task GenerateTimeSheets()
public async Task GenerateGoogleTimeSheets(List<Person> persons, bool randomize)
{
List<Person> persons = _appService.PersonService.GetPersonList().ToList();
string[] scopes = { CalendarService.Scope.CalendarReadonly };
const string applicationName = "LeanderShiftPlanner";
Dictionary<string, string> secrets = GoogleApiLoader.ReadFile();
if (!secrets.ContainsKey(Constants.GoogleClientId) || !secrets.ContainsKey(Constants.GoogleClientSecret))
return;
if (secrets[Constants.GoogleClientId].Equals(string.Empty) || secrets[Constants.GoogleClientSecret].Equals(string.Empty))
return;
ClientSecrets clientSecrets = new ClientSecrets();
clientSecrets.ClientId = secrets[Constants.GoogleClientId];
clientSecrets.ClientSecret = secrets[Constants.GoogleClientSecret];
//clientSecrets.ClientId = "823920428427-ub5n9a9dkfpuvjlut4eneg2058so49o3.apps.googleusercontent.com";
//clientSecrets.ClientSecret = "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";
UserCredential credential = await GoogleWebAuthorizationBroker.AuthorizeAsync(clientSecrets, scopes, "user", CancellationToken.None);
// Erstelle den CalendarService
var service = new CalendarService(new BaseClientService.Initializer()
{
HttpClientInitializer = credential,
ApplicationName = applicationName,
});
foreach (Person person in persons)
{
List<int> pattern = GetPattern(person);
foreach (string month in Constants.Months)
{
if (month == Constants.Months[0]) continue;
List<(DateTime, DateTime, bool)> timesheetValue = new List<(DateTime, DateTime, bool)>();
// Aktuelles Datum
DateTime now = DateTime.Now;
// Erster Tag des aktuellen Monats (00:00:00 Uhr)
DateTime firstDayOfMonth = new DateTime(now.Year, Constants.Months.IndexOf(month), 1, 0, 0, 0);
// Letzter Tag des aktuellen Monats (23:59:59 Uhr)
DateTime lastDayOfMonth = new DateTime(now.Year, Constants.Months.IndexOf(month), DateTime.DaysInMonth(now.Year, Constants.Months.IndexOf(month)),
23, 59, 59);
// Beispiel: Liste die nächsten 10 Ereignisse im ITM Kalender auf
var requestItm = service.Events.List("on089es7kq4ugi3rqvslkn7720@group.calendar.google.com");
requestItm.TimeMinDateTimeOffset = firstDayOfMonth;
requestItm.TimeMaxDateTimeOffset = lastDayOfMonth;
requestItm.SingleEvents = true;
var requestWebService = service.Events.List("s4rdo3uk2on8ualv1goaktbiec@group.calendar.google.com");
requestWebService.TimeMinDateTimeOffset = firstDayOfMonth;
requestWebService.TimeMaxDateTimeOffset = lastDayOfMonth;
requestWebService.SingleEvents = true;
var eventsItm = await requestItm.ExecuteAsync();
var eventsWebService = await requestWebService.ExecuteAsync();
foreach (var eventItem in eventsItm.Items)
{
if (eventItem.Start.DateTime == null || eventItem.End.DateTime == null) continue;
if (eventItem.Summary.Contains(person.FirstName))
{
DateTime dayStart = (DateTime) eventItem.Start.DateTime;
DateTime dayEnd = (DateTime) eventItem.End.DateTime;
timesheetValue.Add((dayStart, dayEnd, (dayEnd.Hour > 13)));
}
}
foreach (var eventItem in eventsWebService.Items)
{
if (eventItem.Start.DateTime == null || eventItem.End.DateTime == null) continue;
if (eventItem.Summary.Contains(person.FirstName))
{
DateTime dayStart = (DateTime) eventItem.Start.DateTime;
DateTime dayEnd = (DateTime) eventItem.End.DateTime;
timesheetValue.Add((dayStart, dayEnd, false));
}
}
person.Timesheets.Add(new Timesheet(month, timesheetValue));
}
if (randomize)
{
CheckForTooMuchHours(person);
await CheckForNotEnoughHours(person);
}
}
}
public async Task GenerateRandomTimeSheets(List<Person> persons)
{
foreach (Person person in persons)
{
List<int> pattern = GetPattern(person.Hours);
if (pattern.Count == 0) continue;
List<DateTime> holidays = await GetHolidays();
List<DateTime> weekends = GetWeekends();
@@ -153,33 +246,33 @@ public class TimesheetService
return holidays;
}
private List<int> GetPattern(Person person)
private List<int> GetPattern(int personHours)
{
int hours = 0;
List<int> pattern = new List<int>();
while (hours < person.Hours)
while (hours < personHours)
{
if (hours + 6 <= person.Hours)
if (hours + 6 <= personHours)
{
pattern.Add(6);
hours += 6;
}
else if (hours + 4 <= person.Hours)
else if (hours + 4 <= personHours)
{
pattern.Add(4);
hours += 4;
}
else if (hours + 2 <= person.Hours)
else if (hours + 2 <= personHours)
{
pattern.Add(2);
hours += 2;
}
else if (hours + 1 <= person.Hours)
else if (hours + 1 <= personHours)
{
pattern.Add(1);
hours += 1;
}
else if (hours + 2 > person.Hours)
else if (hours + 2 > personHours)
{
return new List<int>();
}
@@ -187,4 +280,114 @@ public class TimesheetService
return pattern;
}
private void CheckForTooMuchHours(Person person)
{
foreach (Timesheet timesheet in person.Timesheets)
{
while (person.Hours * 4 < CalcHours(timesheet))
{
timesheet.Value.RemoveAt(timesheet.Value.Count - 1);
}
}
}
private int CalcHours(Timesheet timesheet)
{
double hours = 0;
foreach ((DateTime, DateTime, bool) tuple in timesheet.Value)
{
if (tuple.Item3)
{
hours += (tuple.Item2 - tuple.Item1).TotalHours - 1;
}
else
{
hours += (tuple.Item2 - tuple.Item1).TotalHours;
}
}
return (int) hours;
}
private async Task CheckForNotEnoughHours(Person person)
{
List<DateTime> holidays = await GetHolidays();
List<DateTime> weekends = GetWeekends();
int monthHours = person.Hours * 4;
foreach (string month in Constants.Months)
{
if (month == Constants.Months[0]) continue;
Timesheet timesheet = person.GetTimeSheetByMonth(month);
List<(DateTime, DateTime, bool)> timesheetValue = timesheet.Value;
int currentPersonHours = CalcHours(timesheet);
List<int> pattern = GetPattern((person.Hours * 4) - currentPersonHours);
int currentPatternIndex = 0;
int dayOfMonth = 1;
int lastDayOfMonth = new DateTime(DateTime.Now.Year, Constants.Months.IndexOf(month), dayOfMonth).AddMonths(1).AddDays(-1).Day;
while (currentPersonHours != monthHours)
{
DateTime currentDay = new DateTime(DateTime.Now.Year, Constants.Months.IndexOf(month), dayOfMonth);
if (holidays.Contains(currentDay) || weekends.Contains(currentDay) || CheckForDayIsNotEmpty(currentDay, timesheetValue))
{
dayOfMonth++;
continue;
}
switch (pattern[currentPatternIndex])
{
case 6:
{
DateTime dayStart = new DateTime(currentDay.Year, currentDay.Month, currentDay.Day, 9, 0, 0);
DateTime dayEnd = new DateTime(currentDay.Year, currentDay.Month, currentDay.Day, 16, 0, 0);
timesheetValue.Add((dayStart, dayEnd, true));
currentPersonHours += 6;
break;
}
case 4:
{
DateTime dayStart = new DateTime(currentDay.Year, currentDay.Month, currentDay.Day, 9, 0, 0);
DateTime dayEnd = new DateTime(currentDay.Year, currentDay.Month, currentDay.Day, 13, 0, 0);
timesheetValue.Add((dayStart, dayEnd, false));
currentPersonHours += 4;
break;
}
case 2:
{
DateTime dayStart = new DateTime(currentDay.Year, currentDay.Month, currentDay.Day, 14, 0, 0);
DateTime dayEnd = new DateTime(currentDay.Year, currentDay.Month, currentDay.Day, 16, 0, 0);
timesheetValue.Add((dayStart, dayEnd, false));
currentPersonHours += 2;
break;
}
case 1:
{
DateTime dayStart = new DateTime(currentDay.Year, currentDay.Month, currentDay.Day, 9, 0, 0);
DateTime dayEnd = new DateTime(currentDay.Year, currentDay.Month, currentDay.Day, 10, 0, 0);
timesheetValue.Add((dayStart, dayEnd, false));
currentPersonHours += 1;
break;
}
}
dayOfMonth++;
if (dayOfMonth > lastDayOfMonth) throw new Exception($"Too many hours for Person {person.FirstName}!");
currentPatternIndex = (currentPatternIndex + 1) % pattern.Count;
}
}
}
private bool CheckForDayIsNotEmpty(DateTime currentDay, List<(DateTime, DateTime, bool)> timesheetValue)
{
foreach ((DateTime, DateTime, bool) tuple in timesheetValue)
{
if (tuple.Item1.Date.Day == currentDay.Day) return true;
}
return false;
}
}
+11 -1
View File
@@ -14,9 +14,19 @@ namespace LeanderShiftPlannerV2.Util
public const string ShiftPlanPath = ExportPath + @"/ShiftPlans/";
public const string TimesheetPath = ExportPath + "/Timesheets/";
public const string TimesheetResource = @"Resources/templates/timesheet.xlsx";
public const string FontPath = @"Resources/font/Emblem.ttf";
public const string FontPath = @"Resources/font/";
public const string FontEmblem = "Emblem.ttf";
public const string FontRoboto = "Roboto.ttf";
public const string GoogleApiFilePath = @"/googleapi.txt";
public const string GoogleClientId = "GoogleClientId";
public const string GoogleClientSecret = "GoogleClientSecret";
public const string GoogleApiFileResource = @"Resources/templates/googleapi.txt";
// TimeSheets
public static readonly string TimeSheetSourceRandom = "Random";
public static readonly string TimeSheetSourceGoogle = "Google";
public static readonly string TimeSheetSourceRandomGoogle = "RandomGoogle";
public static readonly List<string> Months = new List<string>
{
"ERROR!",
@@ -15,10 +15,11 @@
<ListBox.Template>
<ControlTemplate>
<DockPanel>
<Grid DockPanel.Dock="Top" Height="30" ColumnDefinitions="100,70,1030" Margin="10,0, 0, 0">
<Grid DockPanel.Dock="Top" Height="30" ColumnDefinitions="100,70,130,900" Margin="10,0, 0, 0">
<TextBlock Grid.Column="0" Text="Name" TextAlignment="Center" Width="100"/>
<TextBlock Grid.Column="1" Text="Hours" TextAlignment="Center" Width="70"/>
<TextBlock Grid.Column="2" Text="Shifts" TextAlignment="Center" Width="1030"/>
<TextBlock Grid.Column="2" Text="TimeSheetSource" TextAlignment="Center" Width="130"/>
<TextBlock Grid.Column="3" Text="Shifts" TextAlignment="Center" Width="900"/>
</Grid>
<ItemsPresenter/>
</DockPanel>
@@ -26,10 +27,11 @@
</ListBox.Template>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="100,70,1030">
<Grid ColumnDefinitions="100,70,130,900">
<TextBlock Grid.Column="0" Text="{Binding FirstName, Mode=OneWay}" TextAlignment="Center" Width="100" x:DataType="model:Person"/>
<TextBlock Grid.Column="1" Text="{Binding Hours, Mode=OneWay}" TextAlignment="Center" Width="70" x:DataType="model:Person"/>
<TextBlock Grid.Column="2" Text="{Binding ShiftsString, Mode=OneWay}" TextAlignment="Center" Width="1030" x:DataType="model:Person"/>
<TextBlock Grid.Column="2" Text="{Binding TimeSheetSource, Mode=OneWay}" TextAlignment="Center" Width="130" x:DataType="model:Person"/>
<TextBlock Grid.Column="3" Text="{Binding ShiftsString, Mode=OneWay}" TextAlignment="Center" Width="900" x:DataType="model:Person"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -11,6 +11,7 @@ using Avalonia.Threading;
using LeanderShiftPlannerV2.FileIO;
using LeanderShiftPlannerV2.Model;
using LeanderShiftPlannerV2.Service;
using LeanderShiftPlannerV2.Util;
using LeanderShiftPlannerV2.View.ErrorView;
using LeanderShiftPlannerV2.View.LeanderControls;
@@ -30,6 +31,7 @@ public partial class LeanderShiftPlannerMainWindow : Window
InitializeComponent();
LoadData();
CreateApiFile();
DeactivateView();
ViewModePersons();
@@ -43,6 +45,11 @@ public partial class LeanderShiftPlannerMainWindow : Window
_appService.FilterService.Filters.CollectionChanged += FilterListChanged;
}
private void CreateApiFile()
{
GoogleApiLoader.CheckFileSystem();
}
// ------------------------------------------------------UI-Interactions------------------------------------------------------
private void ButtonAddPerson_Click(object? sender, RoutedEventArgs e)
@@ -129,14 +136,22 @@ public partial class LeanderShiftPlannerMainWindow : Window
{
try
{
await _appService.TimesheetService.GenerateTimeSheets();
int error = TimesheetIO.ExportTimesheets(_appService.PersonService.GetPersonList().ToList());
_appService.PersonService.ClearTimeSheets();
ProgressbarCalculaion.IsIndeterminate = true;
await _appService.TimesheetService.GenerateGoogleTimeSheets(_appService.PersonService.GetGoogleTimeSheetList(), false);
await _appService.TimesheetService.GenerateGoogleTimeSheets(_appService.PersonService.GetRandomGoogleTimeSheetList(), true);
await _appService.TimesheetService.GenerateRandomTimeSheets(_appService.PersonService.GetRandomTimeSheetList());
ProgressbarCalculaion.IsIndeterminate = false;
int error = TimesheetIO.ExportTimesheets(_appService.PersonService.GetPersonList().ToList());
if (error == 1) ViewService.ShowGeneralError(this).ErrorText = "There was an error exporting the timesheets.";
if (error == 2) ViewService.ShowGeneralError(this).ErrorText = "There was an error creating the timesheets.";
if (error == 1) ;
}
catch (Exception exception)
{
ViewService.ShowGeneralError(this).ErrorText = exception.Message;
Console.WriteLine(exception);
}
}
@@ -156,8 +171,11 @@ public partial class LeanderShiftPlannerMainWindow : Window
private void ButtonSaveShiftPlans_Click(object? sender, RoutedEventArgs e)
{
foreach (ShiftPlan shiftPlan in _appService.ShiftPlanService.GetShiftPlans())
ShiftPlanIO.ExportShiftPlan(ShiftPlanIO.GenerateShiftPlanPng(shiftPlan),
_appService.ShiftPlanService.GetNextID());
{
int currentId = _appService.ShiftPlanService.GetNextID();
ShiftPlanIO.ExportShiftPlan(ShiftPlanIO.GenerateShiftPlanPng(shiftPlan, Constants.FontEmblem), currentId, Constants.FontEmblem);
ShiftPlanIO.ExportShiftPlan(ShiftPlanIO.GenerateShiftPlanPng(shiftPlan ,Constants.FontRoboto), currentId, Constants.FontRoboto);
}
}
private void ButtonStartCalculation_Click(object? sender, RoutedEventArgs e)
@@ -8,7 +8,7 @@
<Grid>
<Label Content="Create new person" Height="25" VerticalAlignment="Top" Margin="0,15,0,0" HorizontalContentAlignment="Center" VerticalContentAlignment="Center"/>
<Grid ColumnDefinitions="Auto, Auto" RowDefinitions="Auto, Auto, Auto" HorizontalAlignment="Center" VerticalAlignment="Center">
<Grid ColumnDefinitions="Auto, Auto" RowDefinitions="Auto, Auto, Auto, Auto" HorizontalAlignment="Center" VerticalAlignment="Center">
<Label Grid.Column="0" Grid.Row="0" Content="Enter person name: " Height="25" HorizontalContentAlignment="Right" VerticalContentAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="0" x:Name="TextBoxName" Height="32" Width="100" HorizontalContentAlignment="Center"/>
@@ -17,9 +17,12 @@
<Label Grid.Column="0" Grid.Row="2" Content="Enter person hours: " Height="25" HorizontalContentAlignment="Right" VerticalContentAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="2" x:Name="TextBoxHours" Height="32" Width="100" HorizontalContentAlignment="Center" TextInput="TextBoxHours_PreviewTextInput" />
<Label Grid.Column="0" Grid.Row="3" Content="Select TimeSheetSource: " Height="25" HorizontalContentAlignment="Right" VerticalContentAlignment="Center" />
<ComboBox Grid.Column="1" Grid.Row="3" x:Name="ComboBoxTimeSheetSource" Height="32" Width="100" HorizontalContentAlignment="Center" />
</Grid>
<Button x:Name="ButtonCreatePerson" Content="Create new person" HorizontalContentAlignment="Center" Height="32" Width="125" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,15" Click="ButtonCreatePerson_Click"/>
<Button x:Name="ButtonCreatePerson" Content="Create new person" HorizontalContentAlignment="Center" Height="32" Width="150" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,15" Click="ButtonCreatePerson_Click"/>
</Grid>
</Window>
@@ -15,14 +15,18 @@ public partial class PersonCreator : Window
InitializeComponent();
this._appService = appService;
ComboBoxTimeSheetSource.Items.Add(Constants.TimeSheetSourceRandom);
ComboBoxTimeSheetSource.Items.Add(Constants.TimeSheetSourceGoogle);
ComboBoxTimeSheetSource.Items.Add(Constants.TimeSheetSourceRandomGoogle);
}
private void ButtonCreatePerson_Click(object sender, RoutedEventArgs e)
{
if (TextBoxName.Text != "" && TextBoxHours.Text != "" && TextBoxSurname.Text != ""
&& TextBoxName.Text != null && TextBoxHours.Text != null && TextBoxSurname.Text != null)
&& TextBoxName.Text != null && TextBoxHours.Text != null && TextBoxSurname.Text != null
&& ComboBoxTimeSheetSource.SelectedItem != null)
{
_appService.PersonService.AddNewPerson(TextBoxName.Text, TextBoxSurname.Text, TextBoxHours.Text);
_appService.PersonService.AddNewPerson(TextBoxName.Text, TextBoxSurname.Text, TextBoxHours.Text, ComboBoxTimeSheetSource.SelectedItem.ToString()!);
this.Close();
}
else
@@ -8,7 +8,7 @@
<Grid>
<Label Content="Edit person" Height="25" VerticalAlignment="Top" Margin="0,15,0,0" HorizontalContentAlignment="Center" VerticalContentAlignment="Center"/>
<Grid ColumnDefinitions="Auto, Auto" RowDefinitions="Auto, Auto, Auto" HorizontalAlignment="Center" VerticalAlignment="Center">
<Grid ColumnDefinitions="Auto, Auto" RowDefinitions="Auto, Auto, Auto, Auto" HorizontalAlignment="Center" VerticalAlignment="Center">
<Label Grid.Column="0" Grid.Row="0" Content="Enter person name: " Height="25" HorizontalContentAlignment="Right" VerticalContentAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="0" x:Name="TextBoxName" Height="32" Width="100" HorizontalContentAlignment="Center"/>
@@ -17,6 +17,9 @@
<Label Grid.Column="0" Grid.Row="2" Content="Enter person hours: " Height="25" HorizontalContentAlignment="Right" VerticalContentAlignment="Center" />
<TextBox Grid.Column="1" Grid.Row="2" x:Name="TextBoxHours" Height="32" Width="100" HorizontalContentAlignment="Center" TextInput="TextBoxHours_PreviewTextInput" />
<Label Grid.Column="0" Grid.Row="3" Content="Select TimeSheetSource: " Height="25" HorizontalContentAlignment="Right" VerticalContentAlignment="Center" />
<ComboBox Grid.Column="1" Grid.Row="3" x:Name="ComboBoxTimeSheetSource" Height="32" Width="100" HorizontalContentAlignment="Center" />
</Grid>
<Button x:Name="ButtonEditPerson" Content="Edit person" HorizontalContentAlignment="Center" Height="32" Width="125" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,15" Click="ButtonEditPerson_Click" />
@@ -22,19 +22,24 @@ public partial class PersonEditor : Window
TextBoxName.Text = _model.FirstName;
TextBoxSurname.Text = _model.Surname;
TextBoxHours.Text = _model.Hours.ToString();
ComboBoxTimeSheetSource.Items.Add(Constants.TimeSheetSourceRandom);
ComboBoxTimeSheetSource.Items.Add(Constants.TimeSheetSourceGoogle);
ComboBoxTimeSheetSource.Items.Add(Constants.TimeSheetSourceRandomGoogle);
}
private void ButtonEditPerson_Click(object sender, RoutedEventArgs e)
{
if (TextBoxName.Text != "" && TextBoxHours.Text != "" && TextBoxSurname.Text != ""
&& TextBoxName.Text != null && TextBoxHours.Text != null && TextBoxSurname.Text != null)
&& TextBoxName.Text != null && TextBoxHours.Text != null && TextBoxSurname.Text != null
&& ComboBoxTimeSheetSource.SelectedItem != null)
{
_appService.PersonService.EditPerson(_model, TextBoxName.Text, TextBoxSurname.Text, int.Parse(TextBoxHours.Text));
_appService.PersonService.EditPerson(_model, TextBoxName.Text, TextBoxSurname.Text, int.Parse(TextBoxHours.Text), ComboBoxTimeSheetSource.SelectedItem.ToString()!);
this.Close();
}
else
{
//TODO: Error, no input!
ViewService.ShowNoInputError(this);
}
}
@@ -3,6 +3,7 @@ using Avalonia.Interactivity;
using LeanderShiftPlannerV2.FileIO;
using LeanderShiftPlannerV2.Model;
using LeanderShiftPlannerV2.Service;
using LeanderShiftPlannerV2.Util;
using SkiaSharp;
namespace LeanderShiftPlannerV2.View.ShiftPlanView;
@@ -19,7 +20,7 @@ public partial class ShiftPlanViewer : Window
this._appService = appService;
this._model = shiftPlan;
this._image = ShiftPlanIO.GenerateShiftPlanPng(shiftPlan);
this._image = ShiftPlanIO.GenerateShiftPlanPng(shiftPlan, Constants.FontEmblem);
InitListView();
InitImageView();
@@ -39,6 +40,8 @@ public partial class ShiftPlanViewer : Window
private void ButtonSaveShiftPlan_Click(object sender, RoutedEventArgs e)
{
ShiftPlanIO.ExportShiftPlan(_image, _appService.ShiftPlanService.GetNextID());
int currentId = _appService.ShiftPlanService.GetNextID();
ShiftPlanIO.ExportShiftPlan(_image, currentId, Constants.FontEmblem);
ShiftPlanIO.ExportShiftPlan(_image, currentId, Constants.FontRoboto);
}
}
+44 -15
View File
@@ -1,46 +1,75 @@
# LeanderShiftPlanner
## Einleitung
LeanderShiftPlanner ist ein Tool zur automatisierten Erstellung von Schichtplänen. Es ermöglicht das Anlegen von Personen und Schichten, deren Verknüpfung sowie die Berechnung und Filterung von Schichtplänen. Zusätzlich können Stundenzettel automatisch generiert werden.
LeanderShiftPlanner ist ein Tool zur automatisierten Erstellung von Schichtplänen. Es ermöglicht das Anlegen von Personen und Schichten, deren Verknüpfung sowie die Berechnung und Filterung von Schichtplänen. Zusätzlich können Stundenzettel automatisch generiert werden entweder vollständig zufällig oder auf Basis eines Google Kalenders.
#### Alle Stundenangaben müssen im Wochenstunden-Format erfolgen!
---
# Hauptfunktionen
## 1. Personen und Schichten verwalten
Personen hinzufügen: Klicke auf den Tab „Persons und nutze die Schaltfläche „Add new person, um eine neue Person zu erstellen.
**Personen hinzufügen:** Klicke auf den Tab „Persons" und nutze die Schaltfläche „Add new person", um eine neue Person anzulegen. Dabei gibst du Vorname, Nachname, die wöchentlichen Arbeitsstunden sowie optional eine Stundenzettel-Quelle an.
Personen bearbeiten/löschen: Über die Buttons „Edit person und „Delete person kannst du bestehende Einträge ändern oder entfernen.
**Personen bearbeiten/löschen:** Über die Buttons „Edit person" und „Delete person" kannst du bestehende Einträge ändern oder entfernen.
Schichten hinzufügen: Wechsle in den Tab „Shifts und klicke auf „Add new shift, um eine neue Schicht zu erstellen.
**Schichten hinzufügen:** Wechsle in den Tab „Shifts" und klicke auf „Add new shift", um eine neue Schicht zu erstellen. Pro Schicht legst du den Wochentag, die Uhrzeit (Start und Ende), die Dauer in Stunden, die Position am Tag (1., 2. oder 3. Schicht) sowie die minimale und maximale Personenanzahl fest.
Schichten bearbeiten/löschen: Ändere oder entferne Schichten mit „Edit shift und „Delete shift.
**Schichten bearbeiten/löschen:** Ändere oder entferne Schichten mit „Edit shift" und „Delete shift".
---
## 2. Personen und Schichten zuweisen
Person einer Schicht zuweisen: Wähle eine Schicht aus und klicke auf „Select persons for shift.
**Person einer Schicht zuweisen:** Wähle eine Schicht aus und klicke auf „Select persons for shift".
Schicht einer Person zuweisen: Wähle eine Person aus und nutze „Select shifts for person.
**Schicht einer Person zuweisen:** Wähle eine Person aus und nutze „Select shifts for person".
_Die Verlinkung von Personen und Schichten wird automatisch in beide Richtungen durchgeführt. Eine Schicht, welche per "Select shifts for person" einer Person hinzugefügt wurde, erhält also automatisch auch einen Link zu der Person, ohne dass dies per "Select persons for shift" noch einmal extra erfolgen muss!_
*Die Verlinkung von Personen und Schichten wird automatisch in beide Richtungen durchgeführt. Eine Schicht, welche per Select shifts for person" einer Person hinzugefügt wurde, erhält also automatisch auch einen Link zu der Person ohne dass dies per Select persons for shift" noch einmal extra erfolgen muss!*
---
## 3. Schichtpläne berechnen und filtern
Schichtpläne generieren: Drücke „Start calculation, um eine automatische Planung der Schichten basierend auf den hinterlegten Personen und Schichtzeiten durchzuführen.
**Schichtpläne generieren:** Drücke „Start calculation", um eine automatische Planung der Schichten durchzuführen. Das Programm berechnet dabei **alle möglichen Kombinationen**, bei denen jede Person exakt ihre eingetragenen Wochenstunden arbeitet nicht mehr und nicht weniger. Jede gültige Kombination wird als eigener Schichtplan gespeichert.
Schichtpläne filtern: Wechsle in den Tab „ShiftPlans und nutze „Filter shiftplans, um die Ergebnisse nach bestimmten Kriterien einzugrenzen.
**Schichtpläne filtern:** Wechsle in den Tab „ShiftPlans" und nutze „Filter shiftplans", um die Ergebnisse nach bestimmten Kriterien einzugrenzen.
Filter zurücksetzen: Mit „Reset all filters werden alle angewendeten Filter entfernt.
**Filter zurücksetzen:** Mit „Reset all filters" werden alle angewendeten Filter entfernt und alle berechneten Pläne wieder angezeigt.
_Die Berechnung der Schichtpläne kann je nach Parametern etwas Zeit in Anspruch nehmen. Hier also einfach etwas Geduld mitbringen._
*Die Berechnung der Schichtpläne kann je nach Anzahl der Personen und Schichten etwas Zeit in Anspruch nehmen, da alle möglichen Kombinationen parallel berechnet werden. Hier also einfach etwas Geduld mitbringen.*
---
## 4. Schichtpläne anzeigen und speichern
Schichtplan öffnen: Wähle einen erstellten Plan aus und nutze „Open selected ShiftPlan, um ihn als PNG oder Text einzusehen.
**Schichtplan öffnen:** Wähle einen erstellten Plan aus und nutze „Open selected ShiftPlan", um ihn als PNG-Bild oder Text einzusehen.
Alle Schichtpläne speichern: Mit „Save all ShiftPlans werden alle gefilterten Pläne als PNG im Verzeichnis `Data/Output/ShiftPlans` gespeichert.
**Alle Schichtpläne speichern:** Mit „Save all ShiftPlans" werden alle aktuell gefilterten Pläne als PNG-Bilder im Verzeichnis `Data/Output/ShiftPlans` gespeichert.
---
## 5. Stundenzettel generieren
Automatisch Stundenzettel erstellen: Über „Generate Timesheets“ können alle Stundenzettel automatisch generiert und im Ordner `Data/Output/Timesheets` abgelegt werden.
LeanderShiftPlanner bietet drei Möglichkeiten, Stundenzettel zu erstellen:
**Automatisch per Google Kalender:** Über „Generate Timesheets" können Stundenzettel direkt aus einem Google Kalender ausgelesen werden. Dazu muss eine gültige Google API-Konfiguration hinterlegt sein (siehe `Resources/templates/googleapi.txt`). Das Programm sucht im Kalender nach Einträgen, die den Namen der jeweiligen Person enthalten, und erstellt daraus einen vollständigen Stundenzettel für jeden Monat.
**Google Kalender mit Zufalls-Auffüllung:** Dieselbe Funktion steht auch mit der Option „Randomize" zur Verfügung. Hierbei werden die aus dem Kalender gelesenen Einträge automatisch angepasst: Zu viele Stunden werden gekürzt, zu wenige Stunden werden mit zufällig generierten Einträgen aufgefüllt sodass jede Person am Ende exakt ihre Wochenstunden (×4 Wochen pro Monat) erreicht.
**Vollständig zufällig:** Über „Generate Random Timesheets" werden Stundenzettel komplett ohne Kalenderanbindung generiert. Das Programm verteilt die Arbeitsstunden anhand eines festen Musters (Tage mit 6, 4, 2 oder 1 Stunde) gleichmäßig über die Werktage des Monats. Feiertage (Hessen) und Wochenenden werden dabei automatisch ausgespart.
Alle generierten Stundenzettel werden als Excel-Dateien (`.xlsx`) im Verzeichnis `Data/Output/Timesheets` abgelegt.
---
# Hinweise
- Alle Stunden müssen als **Wochenstunden** angegeben werden, nicht als Tages- oder Monatsstunden.
- Pro Schicht werden bis zu **3 Personen gleichzeitig** unterstützt.
- Das Programm ist für eine **5-Tage-Woche** ausgelegt.
- Für die Google Kalender-Integration müssen Client ID und Client Secret in der Datei `Resources/templates/googleapi.txt` hinterlegt sein.
- Feiertage werden automatisch über die öffentliche API [feiertage-api.de](https://feiertage-api.de) für das Bundesland **Hessen** abgerufen.