From dcabdd1be85a40e6c78b47f30400ff2aa33d39b3 Mon Sep 17 00:00:00 2001 From: Tinigrifi Date: Mon, 2 Mar 2026 10:33:23 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20commit=20=E2=80=94=20CLI,=20W?= =?UTF-8?q?inForms,=20Domain=20lib,=20tests,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CalculateBestDaysOff.Domain : jours fériés français + optimiseur de congés - CalculateBestDaysOff : CLI console colorée (Spectre.Console) - Conges : interface WinForms dark theme - CalculateBestDaysOff.Tests : 21 tests unitaires xUnit - .github/workflows/ci.yml : build + test sur windows-latest / .NET 10 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 28 ++ .gitignore | 10 + .../CalculateBestDaysOff.Domain.csproj | 9 + .../FrenchPublicHolidays.cs | 43 +++ .../VacationOpportunity.cs | 10 + .../VacationOptimizer.cs | 68 +++++ .../CalculateBestDaysOff.Tests.csproj | 25 ++ .../FrenchPublicHolidaysTests.cs | 50 ++++ .../VacationOptimizerTests.cs | 73 ++++++ CalculateBestDaysOff.slnx | 6 + .../CalculateBestDaysOff.csproj | 18 ++ CalculateBestDaysOff/Program.cs | 162 ++++++++++++ Conges/Conges.csproj | 15 ++ Conges/MainForm.Designer.cs | 20 ++ Conges/MainForm.cs | 247 ++++++++++++++++++ Conges/Program.cs | 11 + README.md | 105 ++++++++ 17 files changed, 900 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 CalculateBestDaysOff.Domain/CalculateBestDaysOff.Domain.csproj create mode 100644 CalculateBestDaysOff.Domain/FrenchPublicHolidays.cs create mode 100644 CalculateBestDaysOff.Domain/VacationOpportunity.cs create mode 100644 CalculateBestDaysOff.Domain/VacationOptimizer.cs create mode 100644 CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj create mode 100644 CalculateBestDaysOff.Tests/FrenchPublicHolidaysTests.cs create mode 100644 CalculateBestDaysOff.Tests/VacationOptimizerTests.cs create mode 100644 CalculateBestDaysOff.slnx create mode 100644 CalculateBestDaysOff/CalculateBestDaysOff.csproj create mode 100644 CalculateBestDaysOff/Program.cs create mode 100644 Conges/Conges.csproj create mode 100644 Conges/MainForm.Designer.cs create mode 100644 Conges/MainForm.cs create mode 100644 Conges/Program.cs create mode 100644 README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98538c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build-and-test: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release --verbosity normal diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9326ded --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Visual Studio +.vs/ +*.user + +# Build output +**/bin/ +**/obj/ + +# NuGet +*.nupkg diff --git a/CalculateBestDaysOff.Domain/CalculateBestDaysOff.Domain.csproj b/CalculateBestDaysOff.Domain/CalculateBestDaysOff.Domain.csproj new file mode 100644 index 0000000..6d36c6d --- /dev/null +++ b/CalculateBestDaysOff.Domain/CalculateBestDaysOff.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/CalculateBestDaysOff.Domain/FrenchPublicHolidays.cs b/CalculateBestDaysOff.Domain/FrenchPublicHolidays.cs new file mode 100644 index 0000000..9655468 --- /dev/null +++ b/CalculateBestDaysOff.Domain/FrenchPublicHolidays.cs @@ -0,0 +1,43 @@ +namespace CalculateBestDaysOff.Domain; + +public static class FrenchPublicHolidays +{ + public static IReadOnlyDictionary For(int year) + { + var easter = ComputeEaster(year); + return new Dictionary + { + [new(year, 1, 1)] = "Jour de l'An", + [easter.AddDays(1)] = "Lundi de Pâques", + [new(year, 5, 1)] = "Fête du Travail", + [new(year, 5, 8)] = "Victoire 1945", + [easter.AddDays(39)] = "Ascension", + [easter.AddDays(50)] = "Lundi de Pentecôte", + [new(year, 7, 14)] = "Fête Nationale", + [new(year, 8, 15)] = "Assomption", + [new(year, 11, 1)] = "Toussaint", + [new(year, 11, 11)] = "Armistice", + [new(year, 12, 25)] = "Noël", + }; + } + + // Algorithme de Meeus/Jones/Butcher + public static DateOnly ComputeEaster(int year) + { + int a = year % 19; + int b = year / 100; + int c = year % 100; + int d = b / 4; + int e = b % 4; + int f = (b + 8) / 25; + int g = (b - f + 1) / 3; + int h = (19 * a + b - d - g + 15) % 30; + int i = c / 4; + int k = c % 4; + int l = (32 + 2 * e + 2 * i - h - k) % 7; + int m = (a + 11 * h + 22 * l) / 451; + int month = (h + l - 7 * m + 114) / 31; + int day = ((h + l - 7 * m + 114) % 31) + 1; + return new DateOnly(year, month, day); + } +} diff --git a/CalculateBestDaysOff.Domain/VacationOpportunity.cs b/CalculateBestDaysOff.Domain/VacationOpportunity.cs new file mode 100644 index 0000000..db620ca --- /dev/null +++ b/CalculateBestDaysOff.Domain/VacationOpportunity.cs @@ -0,0 +1,10 @@ +namespace CalculateBestDaysOff.Domain; + +public sealed record VacationOpportunity( + DateOnly Start, + DateOnly End, + int TotalDaysOff, + int DaysTaken, + double Ratio, + IReadOnlyList HolidaysIncluded, + IReadOnlyList WorkDaysTaken); diff --git a/CalculateBestDaysOff.Domain/VacationOptimizer.cs b/CalculateBestDaysOff.Domain/VacationOptimizer.cs new file mode 100644 index 0000000..3c1dff8 --- /dev/null +++ b/CalculateBestDaysOff.Domain/VacationOptimizer.cs @@ -0,0 +1,68 @@ +namespace CalculateBestDaysOff.Domain; + +public static class VacationOptimizer +{ + /// + /// Trouve toutes les opportunités de congés pour l'année donnée. + /// Un fenêtre [start, end] est valide si elle contient entre 1 et maxDaysTaken jours ouvrés + /// et est maximale (ne peut pas être étendue sans ajouter un jour ouvré). + /// + public static IReadOnlyList FindBest(int year, int maxDaysTaken) + { + var holidays = FrenchPublicHolidays.For(year); + var jan1 = new DateOnly(year, 1, 1); + var dec31 = new DateOnly(year, 12, 31); + + bool IsFree(DateOnly d) => + d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday + || holidays.ContainsKey(d); + + var opportunities = new List(); + + for (var start = jan1; start <= dec31; start = start.AddDays(1)) + { + // On ne démarre une fenêtre qu'au début d'un bloc libre (ou en Jan 1) + // pour garantir que la fenêtre est maximale à gauche. + if (start != jan1 && IsFree(start.AddDays(-1))) + continue; + + int workCount = 0; + for (var end = start; end <= dec31; end = end.AddDays(1)) + { + if (!IsFree(end)) workCount++; + if (workCount > maxDaysTaken) break; + + // La fenêtre est maximale à droite si le lendemain est ouvré (ou fin d'année) + bool rightBlocked = end == dec31 || !IsFree(end.AddDays(1)); + if (rightBlocked && workCount > 0) + { + int total = end.DayNumber - start.DayNumber + 1; + var holidaysInWindow = EnumerateDays(start, end) + .Where(holidays.ContainsKey) + .Select(d => holidays[d]) + .ToList(); + + var workDaysInWindow = EnumerateDays(start, end) + .Where(d => !IsFree(d)) + .ToList(); + + opportunities.Add(new VacationOpportunity( + start, end, total, workCount, + (double)total / workCount, + holidaysInWindow, + workDaysInWindow)); + } + } + } + + return [.. opportunities + .OrderByDescending(o => o.Ratio) + .ThenByDescending(o => o.TotalDaysOff)]; + } + + private static IEnumerable EnumerateDays(DateOnly start, DateOnly end) + { + for (var d = start; d <= end; d = d.AddDays(1)) + yield return d; + } +} diff --git a/CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj b/CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj new file mode 100644 index 0000000..0441cfd --- /dev/null +++ b/CalculateBestDaysOff.Tests/CalculateBestDaysOff.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CalculateBestDaysOff.Tests/FrenchPublicHolidaysTests.cs b/CalculateBestDaysOff.Tests/FrenchPublicHolidaysTests.cs new file mode 100644 index 0000000..e3193ab --- /dev/null +++ b/CalculateBestDaysOff.Tests/FrenchPublicHolidaysTests.cs @@ -0,0 +1,50 @@ +using CalculateBestDaysOff.Domain; + +namespace CalculateBestDaysOff.Tests; + +public class FrenchPublicHolidaysTests +{ + [Theory] + [InlineData(2024, 3, 31)] // Pâques 2024 + [InlineData(2025, 4, 20)] // Pâques 2025 + [InlineData(2026, 4, 5)] // Pâques 2026 + public void ComputeEaster_ReturnsKnownDate(int year, int month, int day) + { + var easter = FrenchPublicHolidays.ComputeEaster(year); + Assert.Equal(new DateOnly(year, month, day), easter); + } + + [Fact] + public void For_Returns11Holidays() + { + var holidays = FrenchPublicHolidays.For(2025); + Assert.Equal(11, holidays.Count); + } + + [Theory] + [InlineData(2025, 1, 1, "Jour de l'An")] + [InlineData(2025, 5, 1, "Fête du Travail")] + [InlineData(2025, 5, 8, "Victoire 1945")] + [InlineData(2025, 7, 14, "Fête Nationale")] + [InlineData(2025, 8, 15, "Assomption")] + [InlineData(2025, 11, 1, "Toussaint")] + [InlineData(2025, 11, 11,"Armistice")] + [InlineData(2025, 12, 25,"Noël")] + public void For_ContainsFixedHolidays(int year, int month, int day, string name) + { + var holidays = FrenchPublicHolidays.For(year); + var date = new DateOnly(year, month, day); + Assert.True(holidays.ContainsKey(date)); + Assert.Equal(name, holidays[date]); + } + + [Fact] + public void For_2025_EasterBasedHolidaysAreCorrect() + { + // Pâques 2025 = 20 avril + var holidays = FrenchPublicHolidays.For(2025); + Assert.True(holidays.ContainsKey(new DateOnly(2025, 4, 21))); // Lundi de Pâques + Assert.True(holidays.ContainsKey(new DateOnly(2025, 5, 29))); // Ascension (+39) + Assert.True(holidays.ContainsKey(new DateOnly(2025, 6, 9))); // Lundi de Pentecôte (+50) + } +} diff --git a/CalculateBestDaysOff.Tests/VacationOptimizerTests.cs b/CalculateBestDaysOff.Tests/VacationOptimizerTests.cs new file mode 100644 index 0000000..7ef815b --- /dev/null +++ b/CalculateBestDaysOff.Tests/VacationOptimizerTests.cs @@ -0,0 +1,73 @@ +using CalculateBestDaysOff.Domain; + +namespace CalculateBestDaysOff.Tests; + +public class VacationOptimizerTests +{ + [Fact] + public void FindBest_ReturnsNonEmptyList() + { + var results = VacationOptimizer.FindBest(2025, 5); + Assert.NotEmpty(results); + } + + [Fact] + public void FindBest_ResultsAreSortedByRatioDescending() + { + var results = VacationOptimizer.FindBest(2025, 5); + for (int i = 0; i < results.Count - 1; i++) + Assert.True(results[i].Ratio >= results[i + 1].Ratio, + $"Résultat {i} (ratio {results[i].Ratio}) < résultat {i + 1} (ratio {results[i + 1].Ratio})"); + } + + [Fact] + public void FindBest_NoDaysTakenExceedsMax() + { + const int max = 3; + var results = VacationOptimizer.FindBest(2025, max); + Assert.All(results, o => Assert.True(o.DaysTaken <= max)); + } + + [Fact] + public void FindBest_RatioEqualsTotal_DividedBy_Taken() + { + var results = VacationOptimizer.FindBest(2025, 5); + Assert.All(results, o => + Assert.Equal((double)o.TotalDaysOff / o.DaysTaken, o.Ratio, precision: 10)); + } + + [Fact] + public void FindBest_NoWindowWithZeroDaysTaken() + { + var results = VacationOptimizer.FindBest(2025, 5); + Assert.All(results, o => Assert.True(o.DaysTaken >= 1)); + } + + [Fact] + public void FindBest_2025_BestOpportunityAroundMay1() + { + // 1er mai 2025 = jeudi (férié), 2 mai = vendredi (ouvré), 3-4 mai = week-end + // Poser 1 jour (2 mai) → 4 jours off (1-4 mai) → ratio 4 + // Pâques 2025 = 20 avril (dim), Lundi de Pâques = 21 avril + // Le week-end 19-20 + lundi férié 21 = 3 jours free + // Si on ajoute 22 avril (mardi, ouvré) → 4 jours (19-22), ratio 4 + var results = VacationOptimizer.FindBest(2025, 1); + Assert.True(results[0].Ratio >= 4.0, + $"Meilleur ratio attendu >= 4, obtenu {results[0].Ratio}"); + } + + [Fact] + public void FindBest_WindowEndIsAlwaysAfterOrEqualStart() + { + var results = VacationOptimizer.FindBest(2025, 10); + Assert.All(results, o => Assert.True(o.End >= o.Start)); + } + + [Fact] + public void FindBest_TotalDaysOffMatchesDateRange() + { + var results = VacationOptimizer.FindBest(2025, 5); + Assert.All(results, o => + Assert.Equal(o.End.DayNumber - o.Start.DayNumber + 1, o.TotalDaysOff)); + } +} diff --git a/CalculateBestDaysOff.slnx b/CalculateBestDaysOff.slnx new file mode 100644 index 0000000..01ad886 --- /dev/null +++ b/CalculateBestDaysOff.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/CalculateBestDaysOff/CalculateBestDaysOff.csproj b/CalculateBestDaysOff/CalculateBestDaysOff.csproj new file mode 100644 index 0000000..c14cc8d --- /dev/null +++ b/CalculateBestDaysOff/CalculateBestDaysOff.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/CalculateBestDaysOff/Program.cs b/CalculateBestDaysOff/Program.cs new file mode 100644 index 0000000..09e2628 --- /dev/null +++ b/CalculateBestDaysOff/Program.cs @@ -0,0 +1,162 @@ +using System.Globalization; +using CalculateBestDaysOff.Domain; +using Spectre.Console; + +// --- Parsing des arguments --- +int year = DateTime.Now.Year; +int maxDays = 10; +int top = 10; +int weeks = 0; // --weeks N : filtre min N semaines consécutives off +bool maxDaysExplicit = false; + +if (args.Contains("--help") || args.Contains("-h")) +{ + AnsiConsole.Write(new FigletText("Conges").Color(Color.Orange1)); + + var help = new Table().NoBorder().HideHeaders() + .AddColumn(new TableColumn("").PadRight(2)) + .AddColumn(""); + + help.AddRow("[bold yellow]USAGE[/]", ""); + help.AddRow("", "[grey]dotnet run --[/] [cyan][[année]][/] [grey][[options]][/]"); + help.AddRow("", ""); + help.AddRow("[bold yellow]ARGUMENT[/]", ""); + help.AddRow("[cyan]année[/]", $"Année à analyser [grey](défaut : {DateTime.Now.Year})[/]"); + help.AddRow("", ""); + help.AddRow("[bold yellow]OPTIONS[/]", ""); + help.AddRow("[cyan]--weeks [/][grey][/]", "Affiche uniquement les blocs d'au moins [bold]n semaines[/] consécutives\n[grey]Ajuste automatiquement --max-days si nécessaire[/]"); + help.AddRow("[cyan]--max-days[/] [grey][/]", $"Nombre maximum de jours de congé posés par opportunité [grey](défaut : 10)[/]"); + help.AddRow("[cyan]--top[/] [grey][/]", $"Nombre de résultats à afficher [grey](défaut : 10)[/]"); + help.AddRow("[cyan]--help[/][grey], -h[/]", "Affiche cette aide"); + help.AddRow("", ""); + help.AddRow("[bold yellow]EXEMPLES[/]", ""); + help.AddRow("", "[grey]dotnet run --[/] [cyan]2025[/]"); + help.AddRow("", "[grey]dotnet run --[/] [cyan]2026 --top 5[/]"); + help.AddRow("", "[grey]dotnet run --[/] [cyan]2025 --weeks 2[/] [dim]# meilleurs blocs de 2 semaines[/]"); + help.AddRow("", "[grey]dotnet run --[/] [cyan]2025 --weeks 2 --max-days 7 [dim]# idem, max 7 jours posés[/][/]"); + + AnsiConsole.Write(new Panel(help) + .Header("[bold]Aide[/]") + .BorderColor(Color.Orange1)); + return; +} + +for (int i = 0; i < args.Length; i++) +{ + if (args[i] == "--max-days" && i + 1 < args.Length && int.TryParse(args[i + 1], out int md)) + { + maxDays = md; maxDaysExplicit = true; i++; + } + else if (args[i] == "--top" && i + 1 < args.Length && int.TryParse(args[i + 1], out int t)) + { + top = t; i++; + } + else if (args[i] == "--weeks" && i + 1 < args.Length && int.TryParse(args[i + 1], out int w)) + { + weeks = w; i++; + } + else if (int.TryParse(args[i], out int y) && y is >= 1900 and <= 2100) + { + year = y; + } +} + +// Pour N semaines on a besoin d'au moins N*5 jours posables (cas sans aucun férié) +if (weeks > 0 && !maxDaysExplicit) + maxDays = Math.Max(maxDays, weeks * 5); + +int minDays = weeks > 0 ? weeks * 7 : 0; + +// --- En-tête --- +var fr = CultureInfo.GetCultureInfo("fr-FR"); + +AnsiConsole.Write(new FigletText("Conges").Color(Color.Orange1)); +AnsiConsole.MarkupLine($"[grey]Année :[/] [bold yellow]{year}[/] " + + $"[grey]Max jours posés :[/] [bold yellow]{maxDays}[/] " + + $"[grey]Top :[/] [bold yellow]{top}[/]" + + (weeks > 0 ? $" [grey]Filtre :[/] [bold cyan]≥ {weeks} sem. ({minDays} j)[/]" : "")); +AnsiConsole.WriteLine(); + +// --- Jours fériés --- +var holidaysPanel = new Table().NoBorder().HideHeaders(); +holidaysPanel.AddColumn(new TableColumn("").Padding(0, 0)); +holidaysPanel.AddColumn(new TableColumn("").Padding(0, 0)); + +var holidays = FrenchPublicHolidays.For(year); +foreach (var (date, name) in holidays.OrderBy(kv => kv.Key)) + holidaysPanel.AddRow( + $"[dim]{date.ToString("ddd d MMM", fr)}[/]", + $"[dim italic]{Markup.Escape(name)}[/]"); + +AnsiConsole.Write(new Panel(holidaysPanel) + .Header("[bold]Jours fériés[/]") + .BorderColor(Color.Grey)); +AnsiConsole.WriteLine(); + +// --- Calcul --- +var results = AnsiConsole + .Status() + .Spinner(Spinner.Known.Dots) + .Start("[yellow]Calcul en cours…[/]", _ => + VacationOptimizer.FindBest(year, maxDays) + .Where(o => minDays == 0 || o.TotalDaysOff >= minDays) + .Take(top) + .ToList()); + +if (results.Count == 0) +{ + AnsiConsole.MarkupLine($"[red]Aucune opportunité trouvée avec ≥ {minDays} jours consécutifs (max {maxDays} jours posés).[/]"); + AnsiConsole.MarkupLine("[dim]Essayez d'augmenter --max-days.[/]"); + return; +} + +// --- Tableau des résultats --- +string tableTitle = weeks > 0 + ? $"[bold gold1]🏖 Top {results.Count} blocs de {weeks} semaine(s) — {year}[/]" + : $"[bold gold1]🏖 Top {results.Count} opportunités de congés {year}[/]"; +var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .Title(tableTitle) + .AddColumn(new TableColumn("[bold]#[/]").RightAligned()) + .AddColumn("[bold]Période[/]") + .AddColumn(new TableColumn("[bold]Durée[/]").RightAligned()) + .AddColumn(new TableColumn("[bold]Posés[/]").RightAligned()) + .AddColumn(new TableColumn("[bold]Ratio[/]").RightAligned()) + .AddColumn("[bold]Fériés inclus[/]") + .AddColumn("[bold]Jours à poser[/]"); + +for (int rank = 0; rank < results.Count; rank++) +{ + var o = results[rank]; + + var (ratioColor, ratioEmoji) = o.Ratio switch + { + >= 7 => ("bold gold1", "✨"), + >= 5 => ("bold green", "🌟"), + >= 3 => ("green", "👍"), + >= 2 => ("yellow", ""), + _ => ("white", ""), + }; + + string rankStr = rank == 0 ? "[bold gold1]🥇 1[/]" + : rank == 1 ? "[bold silver]🥈 2[/]" + : rank == 2 ? "[bold orange1]🥉 3[/]" + : $"[dim]{rank + 1}[/]"; + + string period = $"{o.Start.ToString("ddd d MMM", fr)} [dim]→[/] {o.End.ToString("ddd d MMM", fr)}"; + string total = $"[bold]{o.TotalDaysOff} j[/]"; + string taken = $"{o.DaysTaken} j"; + string ratio = $"[{ratioColor}]{ratioEmoji} {o.Ratio:F1}[/]"; + string feries = o.HolidaysIncluded.Count > 0 + ? string.Join(", ", o.HolidaysIncluded.Select(Markup.Escape)) + : "[dim]-[/]"; + string workDays = string.Join(", ", o.WorkDaysTaken.Select(d => d.ToString("dd/MM", fr))); + + table.AddRow(rankStr, period, total, taken, ratio, feries, workDays); +} + +AnsiConsole.Write(table); +AnsiConsole.WriteLine(); +AnsiConsole.MarkupLine("[dim]Ratio = jours off consécutifs obtenus / jours de congé posés[/]"); + diff --git a/Conges/Conges.csproj b/Conges/Conges.csproj new file mode 100644 index 0000000..a8b3618 --- /dev/null +++ b/Conges/Conges.csproj @@ -0,0 +1,15 @@ + + + + + + + + WinExe + net10.0-windows + enable + true + enable + + + \ No newline at end of file diff --git a/Conges/MainForm.Designer.cs b/Conges/MainForm.Designer.cs new file mode 100644 index 0000000..7966bb1 --- /dev/null +++ b/Conges/MainForm.Designer.cs @@ -0,0 +1,20 @@ +namespace Conges; + +partial class MainForm +{ + private System.ComponentModel.IContainer components = null!; + + protected override void Dispose(bool disposing) + { + if (disposing && components != null) components.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + SuspendLayout(); + AutoScaleMode = AutoScaleMode.Font; + ResumeLayout(false); + } +} diff --git a/Conges/MainForm.cs b/Conges/MainForm.cs new file mode 100644 index 0000000..4f4db1a --- /dev/null +++ b/Conges/MainForm.cs @@ -0,0 +1,247 @@ +using System.Globalization; +using CalculateBestDaysOff.Domain; + +namespace Conges; + +public partial class MainForm : Form +{ + private static readonly CultureInfo Fr = CultureInfo.GetCultureInfo("fr-FR"); + + // ── toolbar ────────────────────────────────────────────────────────────── + private readonly NumericUpDown _nudYear = new() { Minimum = 1900, Maximum = 2100, Width = 70 }; + private readonly NumericUpDown _nudMaxDays = new() { Minimum = 1, Maximum = 60, Width = 55, Value = 10 }; + private readonly NumericUpDown _nudWeeks = new() { Minimum = 0, Maximum = 12, Width = 55, Value = 0 }; + private readonly NumericUpDown _nudTop = new() { Minimum = 1, Maximum = 200, Width = 55, Value = 10 }; + private readonly Button _btnCalc = new() { Text = "Calculer ▶", AutoSize = true, FlatStyle = FlatStyle.Flat }; + + // ── layout ─────────────────────────────────────────────────────────────── + private readonly DataGridView _grid = new(); + private readonly Label _lblStatus = new() { Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleLeft }; + + public MainForm() + { + InitializeComponent(); + SetupForm(); + _nudYear.Value = DateTime.Now.Year; + RunCalculation(); + } + + private void SetupForm() + { + // form + Text = "Congés"; + MinimumSize = new Size(1100, 620); + Size = new Size(1200, 700); + StartPosition = FormStartPosition.CenterScreen; + BackColor = Color.FromArgb(30, 30, 30); + ForeColor = Color.WhiteSmoke; + Font = new Font("Segoe UI", 10f); + + // ── toolbar panel ───────────────────────────────────────────────── + var toolbar = new FlowLayoutPanel + { + Dock = DockStyle.Top, + Height = 46, + BackColor = Color.FromArgb(40, 40, 40), + Padding = new Padding(8, 6, 8, 6), + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + }; + + toolbar.Controls.Add(MakeLabel("Année :")); + StyleNud(_nudYear); + toolbar.Controls.Add(_nudYear); + + toolbar.Controls.Add(MakeSep()); + toolbar.Controls.Add(MakeLabel("Max jours posés :")); + StyleNud(_nudMaxDays); + toolbar.Controls.Add(_nudMaxDays); + + toolbar.Controls.Add(MakeSep()); + toolbar.Controls.Add(MakeLabel("Semaines min :")); + StyleNud(_nudWeeks); + var weeksNote = MakeLabel("(0 = tous)"); + weeksNote.ForeColor = Color.Gray; + toolbar.Controls.Add(_nudWeeks); + toolbar.Controls.Add(weeksNote); + + toolbar.Controls.Add(MakeSep()); + toolbar.Controls.Add(MakeLabel("Top :")); + StyleNud(_nudTop); + toolbar.Controls.Add(_nudTop); + + toolbar.Controls.Add(MakeSep()); + StyleButton(_btnCalc); + toolbar.Controls.Add(_btnCalc); + + // ── status bar ──────────────────────────────────────────────────── + var statusBar = new Panel { Dock = DockStyle.Bottom, Height = 26, BackColor = Color.FromArgb(40, 40, 40) }; + _lblStatus.ForeColor = Color.Gray; + _lblStatus.Font = new Font("Segoe UI", 9f); + statusBar.Controls.Add(_lblStatus); + + // ── main area ──────────────────────────────────────────────────── + SetupGrid(); + + Controls.Add(_grid); + Controls.Add(toolbar); + Controls.Add(statusBar); + + _btnCalc.Click += (_, _) => RunCalculation(); + _nudYear.ValueChanged += (_, _) => RunCalculation(); + } + + private void SetupGrid() + { + _grid.Dock = DockStyle.Fill; + _grid.ReadOnly = true; + _grid.AllowUserToAddRows = false; + _grid.AllowUserToResizeRows = false; + _grid.RowHeadersVisible = false; + _grid.SelectionMode = DataGridViewSelectionMode.FullRowSelect; + _grid.BackgroundColor = Color.FromArgb(30, 30, 30); + _grid.GridColor = Color.FromArgb(60, 60, 60); + _grid.BorderStyle = BorderStyle.None; + _grid.CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal; + _grid.DefaultCellStyle = new DataGridViewCellStyle + { + BackColor = Color.FromArgb(38, 38, 38), + ForeColor = Color.WhiteSmoke, + SelectionBackColor = Color.FromArgb(70, 100, 140), + SelectionForeColor = Color.White, + Font = new Font("Segoe UI", 10f), + Padding = new Padding(4, 2, 4, 2), + }; + _grid.ColumnHeadersDefaultCellStyle = new DataGridViewCellStyle + { + BackColor = Color.FromArgb(50, 50, 50), + ForeColor = Color.Orange, + Font = new Font("Segoe UI", 10f, FontStyle.Bold), + Padding = new Padding(4, 4, 4, 4), + }; + _grid.EnableHeadersVisualStyles = false; + _grid.ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.DisableResizing; + _grid.ColumnHeadersHeight = 32; + _grid.RowTemplate.Height = 28; + + _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Rank", HeaderText = "#", Width = 36, DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleCenter } }); + _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Start", HeaderText = "Début", Width = 110 }); + _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "End", HeaderText = "Fin", Width = 110 }); + _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Total", HeaderText = "Jours off", Width = 80, DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleCenter } }); + _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Taken", HeaderText = "Posés", Width = 60, DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleCenter } }); + _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Ratio", HeaderText = "Ratio", Width = 70, DefaultCellStyle = { Alignment = DataGridViewContentAlignment.MiddleCenter } }); + _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "WorkDays", HeaderText = "Jours à poser", MinimumWidth = 120, AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill }); + _grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Holidays", HeaderText = "Fériés inclus", MinimumWidth = 120, AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill }); + + _grid.CellFormatting += Grid_CellFormatting; + } + + private void RunCalculation() + { + int year = (int)_nudYear.Value; + int maxDays = (int)_nudMaxDays.Value; + int weeks = (int)_nudWeeks.Value; + int top = (int)_nudTop.Value; + + if (weeks > 0) + maxDays = Math.Max(maxDays, weeks * 5); + + int minDays = weeks > 0 ? weeks * 7 : 0; + + var results = VacationOptimizer.FindBest(year, maxDays) + .Where(o => minDays == 0 || o.TotalDaysOff >= minDays) + .Take(top) + .ToList(); + + // grid + _grid.Rows.Clear(); + for (int i = 0; i < results.Count; i++) + { + var o = results[i]; + _grid.Rows.Add( + i + 1, + o.Start.ToString("ddd d MMM", Fr), + o.End.ToString("ddd d MMM", Fr), + o.TotalDaysOff, + o.DaysTaken, + $"{o.Ratio:F1}", + string.Join(", ", o.WorkDaysTaken.Select(d => d.ToString("dd/MM", Fr))), + string.Join(", ", o.HolidaysIncluded)); + } + + _lblStatus.Text = $" {results.Count} résultat(s) — ratio = jours off consécutifs / jours posés"; + } + + private static void Grid_CellFormatting(object? sender, DataGridViewCellFormattingEventArgs e) + { + if (e.RowIndex < 0) return; + var grid = (DataGridView)sender!; + var row = grid.Rows[e.RowIndex]; + + // alternating row color + row.DefaultCellStyle.BackColor = e.RowIndex % 2 == 0 + ? Color.FromArgb(38, 38, 38) + : Color.FromArgb(44, 44, 44); + + if (grid.Columns[e.ColumnIndex].Name == "Ratio" && e.Value is string ratioStr + && double.TryParse(ratioStr, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out double ratio)) + { + e.CellStyle.ForeColor = ratio switch + { + >= 7 => Color.Gold, + >= 5 => Color.LimeGreen, + >= 3 => Color.MediumSpringGreen, + >= 2 => Color.Khaki, + _ => Color.WhiteSmoke, + }; + e.CellStyle.Font = new Font("Segoe UI", 10f, FontStyle.Bold); + } + + if (grid.Columns[e.ColumnIndex].Name == "Rank" && e.RowIndex < 3) + { + e.CellStyle.ForeColor = e.RowIndex switch + { + 0 => Color.Gold, + 1 => Color.Silver, + 2 => Color.Peru, + _ => Color.WhiteSmoke, + }; + e.CellStyle.Font = new Font("Segoe UI", 10f, FontStyle.Bold); + } + } + + // ── helpers ─────────────────────────────────────────────────────────────── + private static Label MakeLabel(string text) => new() + { + Text = text, + AutoSize = true, + ForeColor = Color.Silver, + Margin = new Padding(6, 8, 2, 0), + }; + + private static Label MakeSep() => new() + { + Text = "|", + AutoSize = true, + ForeColor = Color.FromArgb(70, 70, 70), + Margin = new Padding(6, 8, 6, 0), + }; + + private static void StyleNud(NumericUpDown nud) + { + nud.BackColor = Color.FromArgb(55, 55, 55); + nud.ForeColor = Color.WhiteSmoke; + nud.Margin = new Padding(2, 5, 2, 0); + } + + private static void StyleButton(Button btn) + { + btn.BackColor = Color.FromArgb(200, 100, 20); + btn.ForeColor = Color.White; + btn.FlatAppearance.BorderSize = 0; + btn.Font = new Font("Segoe UI", 10f, FontStyle.Bold); + btn.Margin = new Padding(10, 4, 2, 0); + btn.Cursor = Cursors.Hand; + } +} diff --git a/Conges/Program.cs b/Conges/Program.cs new file mode 100644 index 0000000..0db81a1 --- /dev/null +++ b/Conges/Program.cs @@ -0,0 +1,11 @@ +namespace Conges; + +static class Program +{ + [STAThread] + static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm()); + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb35a16 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# 🏖 Congés + +Trouve les meilleures périodes de l'année pour poser des congés en maximisant le ratio : + +> **ratio = jours off consécutifs obtenus / jours de congé posés** + +Exemple : poser **2 jours** autour du pont du 1er mai peut donner **8 jours off** → ratio **4.0** + +--- + +## Projets + +| Projet | Description | +|---|---| +| `CalculateBestDaysOff.Domain` | Logique métier partagée (jours fériés, optimiseur) | +| `CalculateBestDaysOff` | Application console colorée | +| `Conges` | Application WinForms | +| `CalculateBestDaysOff.Tests` | Tests unitaires (xUnit) | + +--- + +## Console + +```bash +dotnet run --project CalculateBestDaysOff -- [année] [options] +``` + +### Arguments + +| Argument | Description | Défaut | +|---|---|---| +| `année` | Année à analyser | année courante | +| `--max-days ` | Max jours de congé posés par opportunité | `10` | +| `--weeks ` | Filtre : blocs d'au moins N semaines consécutives | `0` (tous) | +| `--top ` | Nombre de résultats à afficher | `10` | +| `--help`, `-h` | Affiche l'aide | | + +### Exemples + +```bash +# Top 10 opportunités de 2025 +dotnet run --project CalculateBestDaysOff -- 2025 + +# Meilleurs blocs de 2 semaines +dotnet run --project CalculateBestDaysOff -- 2025 --weeks 2 + +# Top 5, max 3 jours posés, pour 2026 +dotnet run --project CalculateBestDaysOff -- 2026 --max-days 3 --top 5 +``` + +### Exemple de sortie + +``` +╭──────┬─────────────────────────────┬───────┬───────┬────────┬────────────────────┬──────────────╮ +│ # │ Période │ Durée │ Posés │ Ratio │ Fériés inclus │ Jours à │ +│ │ │ │ │ │ │ poser │ +├──────┼─────────────────────────────┼───────┼───────┼────────┼────────────────────┼──────────────┤ +│ 🥇 1 │ ven. 18 avr. → lun. 21 avr. │ 4 j │ 1 j │ 👍 4.0 │ Lundi de Pâques │ 18/04 │ +│ 🥈 2 │ jeu. 1 mai → dim. 4 mai │ 4 j │ 1 j │ 👍 4.0 │ Fête du Travail │ 02/05 │ +│ 🥉 3 │ sam. 19 avr. → mar. 22 avr. │ 4 j │ 1 j │ 👍 4.0 │ Lundi de Pâques │ 22/04 │ +╰──────┴─────────────────────────────┴───────┴───────┴────────┴────────────────────┴──────────────╯ +``` + +--- + +## WinForms + +Lance `Conges.exe` ou : + +```bash +dotnet run --project Conges +``` + +--- + +## Jours fériés pris en compte + +Les 11 jours fériés nationaux français : + +- Jour de l'An (1er janvier) +- Lundi de Pâques *(calculé)* +- Fête du Travail (1er mai) +- Victoire 1945 (8 mai) +- Ascension *(calculé)* +- Lundi de Pentecôte *(calculé)* +- Fête Nationale (14 juillet) +- Assomption (15 août) +- Toussaint (1er novembre) +- Armistice (11 novembre) +- Noël (25 décembre) + +> Pâques est calculé avec l'algorithme de [Meeus/Jones/Butcher](https://en.wikipedia.org/wiki/Date_of_Easter#Anonymous_Gregorian_algorithm). + +--- + +## Prérequis + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +## Build & tests + +```bash +dotnet build +dotnet test +```