Compare commits
9 Commits
2f95fa0e3e
...
testbranch
| Author | SHA1 | Date | |
|---|---|---|---|
| a89779a00c | |||
| febc875834 | |||
| 2305e4b728 | |||
| b6dc66744f | |||
| a01f80bb14 | |||
| 9980d9217a | |||
| 03b025c09c | |||
| 0719d41fe1 | |||
| b12e94128c |
@@ -28,9 +28,14 @@ services:
|
|||||||
- DB_PROVIDER=PostgreSQL
|
- DB_PROVIDER=PostgreSQL
|
||||||
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
|
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
|
||||||
- EnableHttpsRedirect=false
|
- EnableHttpsRedirect=false
|
||||||
|
- TenantSettings__BaseDomain=timetracker.marc-wieland.de
|
||||||
|
volumes:
|
||||||
|
- dpkeys:/home/app/.aspnet/DataProtection-Keys
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
name: timetracker_pgdata
|
name: timetracker_pgdata
|
||||||
|
dpkeys:
|
||||||
|
name: timetracker_dpkeys
|
||||||
@@ -65,6 +65,9 @@
|
|||||||
new("Neu", "Userprofil angelegt"),
|
new("Neu", "Userprofil angelegt"),
|
||||||
new("Upgrade", "Früheste Gehenszeit wird angezeigt"),
|
new("Upgrade", "Früheste Gehenszeit wird angezeigt"),
|
||||||
new("Upgrade", "Pausen werden dynamisch berechnet"),
|
new("Upgrade", "Pausen werden dynamisch berechnet"),
|
||||||
|
new("Neu", "Excel-Export"),
|
||||||
|
new("Neu", "Multi-Tenant Integration"),
|
||||||
|
new("Upgrade", "Verbesserte Single-User Ansicht")
|
||||||
], Highlighted: true),
|
], Highlighted: true),
|
||||||
new("1.4", "08.06.2026", true,
|
new("1.4", "08.06.2026", true,
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<PageTitle>KW @_kw – Wochenübersicht – Timetracker</PageTitle>
|
<PageTitle>KW @_kw – Wochenübersicht – Timetracker</PageTitle>
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ else
|
|||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||||
|
<MudTooltip Text="Woche als Excel exportieren">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Download"
|
||||||
|
Style="color: var(--mud-palette-primary-text)" OnClick="ExportWeekExcel" />
|
||||||
|
</MudTooltip>
|
||||||
@if (!IsCurrentWeek)
|
@if (!IsCurrentWeek)
|
||||||
{
|
{
|
||||||
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
||||||
@@ -491,6 +496,10 @@ else
|
|||||||
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
|
private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); }
|
||||||
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
|
private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); }
|
||||||
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
|
private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); }
|
||||||
|
private void ExportWeekExcel()
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"api/tracker/export/week?monday={_monday:yyyy-MM-dd}", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
private static DateOnly GetMonday(DateOnly date)
|
private static DateOnly GetMonday(DateOnly date)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
@inject ITimetrackerService TrackerService
|
@inject ITimetrackerService TrackerService
|
||||||
@inject IHolidayService HolidayService
|
@inject IHolidayService HolidayService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
|
|
||||||
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year – Monatsübersicht – Timetracker</PageTitle>
|
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year – Monatsübersicht – Timetracker</PageTitle>
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ else
|
|||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||||
|
<MudTooltip Text="Monat als Excel exportieren">
|
||||||
|
<MudIconButton Icon="@Icons.Material.Filled.Download"
|
||||||
|
Style="color: var(--mud-palette-primary-text)" OnClick="ExportMonthExcel" />
|
||||||
|
</MudTooltip>
|
||||||
@if (!IsCurrentMonth)
|
@if (!IsCurrentMonth)
|
||||||
{
|
{
|
||||||
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
||||||
@@ -268,6 +273,11 @@ else
|
|||||||
await LoadMonth();
|
await LoadMonth();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ExportMonthExcel()
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"api/tracker/export/month?year={_year}&month={_month}", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatTs(TimeSpan ts, bool sign = false)
|
private static string FormatTs(TimeSpan ts, bool sign = false)
|
||||||
{
|
{
|
||||||
var neg = ts < TimeSpan.Zero;
|
var neg = ts < TimeSpan.Zero;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject ITimetrackerService TrackerService
|
@inject ITimetrackerService TrackerService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager NavigationManager
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
@using timetracker.Shared
|
@using timetracker.Shared
|
||||||
|
|
||||||
@@ -211,6 +212,75 @@ else
|
|||||||
</MudCard>
|
</MudCard>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
|
|
||||||
|
@* ── Excel Export ── *@
|
||||||
|
<MudItem xs="12">
|
||||||
|
<MudCard Elevation="3" Class="rounded-xl">
|
||||||
|
<MudCardHeader>
|
||||||
|
<CardHeaderContent>
|
||||||
|
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Excel-Datenexport</MudText>
|
||||||
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Lade deine erfassten Arbeitszeiten in verschiedenen Zeiträumen als formatierte Excel-Datei herunter.</MudText>
|
||||||
|
</CardHeaderContent>
|
||||||
|
</MudCardHeader>
|
||||||
|
<MudCardContent>
|
||||||
|
<MudGrid Spacing="3">
|
||||||
|
@* Wochenexport *@
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Wochenexport</MudText>
|
||||||
|
<MudDatePicker Label="Woche wählen" @bind-Date="_exportWeekDate" PickerVariant="PickerVariant.Dialog" Variant="Variant.Outlined" Color="Color.Primary" />
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportWeek" FullWidth="true">
|
||||||
|
Woche exportieren
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* Monatsexport *@
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Monatsexport</MudText>
|
||||||
|
<MudStack Row="true" Spacing="2">
|
||||||
|
<MudSelect T="int" Label="Monat" @bind-Value="_exportMonth" Variant="Variant.Outlined">
|
||||||
|
@for (int m = 1; m <= 12; m++)
|
||||||
|
{
|
||||||
|
var monthVal = m;
|
||||||
|
<MudSelectItem Value="@monthVal">@_deCulture.DateTimeFormat.GetMonthName(monthVal)</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudSelect T="int" Label="Jahr" @bind-Value="_exportMonthYear" Variant="Variant.Outlined">
|
||||||
|
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
|
||||||
|
{
|
||||||
|
var yearVal = y;
|
||||||
|
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
</MudStack>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportMonth" FullWidth="true">
|
||||||
|
Monat exportieren
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
|
@* Jahresexport *@
|
||||||
|
<MudItem xs="12" sm="4">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudText Typo="Typo.subtitle2" Style="font-weight:700">Jahresexport</MudText>
|
||||||
|
<MudSelect T="int" Label="Jahr" @bind-Value="_exportYear" Variant="Variant.Outlined">
|
||||||
|
@for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++)
|
||||||
|
{
|
||||||
|
var yearVal = y;
|
||||||
|
<MudSelectItem Value="@yearVal">@yearVal</MudSelectItem>
|
||||||
|
}
|
||||||
|
</MudSelect>
|
||||||
|
<MudButton StartIcon="@Icons.Material.Filled.Download" Variant="Variant.Filled" Color="Color.Primary" OnClick="ExportYear" FullWidth="true">
|
||||||
|
Jahr exportieren
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudItem>
|
||||||
|
</MudGrid>
|
||||||
|
</MudCardContent>
|
||||||
|
</MudCard>
|
||||||
|
</MudItem>
|
||||||
|
|
||||||
@* ── Diagnostics Panel ── *@
|
@* ── Diagnostics Panel ── *@
|
||||||
<MudItem xs="12">
|
<MudItem xs="12">
|
||||||
<MudExpansionPanels>
|
<MudExpansionPanels>
|
||||||
@@ -264,6 +334,12 @@ else
|
|||||||
private double _weekOvertimeHours;
|
private double _weekOvertimeHours;
|
||||||
private double _maxHoursValue = 10.0;
|
private double _maxHoursValue = 10.0;
|
||||||
|
|
||||||
|
// Excel Export variables
|
||||||
|
private DateTime? _exportWeekDate = DateTime.Today;
|
||||||
|
private int _exportMonth = DateTime.Today.Month;
|
||||||
|
private int _exportMonthYear = DateTime.Today.Year;
|
||||||
|
private int _exportYear = DateTime.Today.Year;
|
||||||
|
|
||||||
// Debug variables
|
// Debug variables
|
||||||
private int _rawDbDaysCount = 0;
|
private int _rawDbDaysCount = 0;
|
||||||
private Dictionary<DateOnly, string> _dbMatchStatus = new();
|
private Dictionary<DateOnly, string> _dbMatchStatus = new();
|
||||||
@@ -373,6 +449,25 @@ else
|
|||||||
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
|
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ExportWeek()
|
||||||
|
{
|
||||||
|
if (_exportWeekDate.HasValue)
|
||||||
|
{
|
||||||
|
var date = DateOnly.FromDateTime(_exportWeekDate.Value);
|
||||||
|
NavigationManager.NavigateTo($"api/tracker/export/week?monday={date:yyyy-MM-dd}", forceLoad: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExportMonth()
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"api/tracker/export/month?year={_exportMonthYear}&month={_exportMonth}", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExportYear()
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class DayStat
|
private sealed class DayStat
|
||||||
{
|
{
|
||||||
public DateOnly Date { get; set; }
|
public DateOnly Date { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using timetracker.Shared;
|
||||||
|
|
||||||
|
namespace timetracker.Data;
|
||||||
|
|
||||||
|
public static class ExcelExporter
|
||||||
|
{
|
||||||
|
public static async Task<byte[]> GenerateExcelExportAsync(
|
||||||
|
int userId,
|
||||||
|
DateOnly startDate,
|
||||||
|
DateOnly endDate,
|
||||||
|
string title,
|
||||||
|
ITimetrackerService trackerService,
|
||||||
|
TimetrackerDbContext db)
|
||||||
|
{
|
||||||
|
var settings = await trackerService.GetSettingsAsync(userId);
|
||||||
|
|
||||||
|
var workDays = await db.WorkDays
|
||||||
|
.Include(w => w.Breaks)
|
||||||
|
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
|
||||||
|
.ToDictionaryAsync(w => w.Date);
|
||||||
|
|
||||||
|
var holidaysList = await db.PublicHolidays
|
||||||
|
.Where(h => h.Date >= startDate && h.Date <= endDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var state = settings.GermanState;
|
||||||
|
var holidayMap = holidaysList
|
||||||
|
.Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state)))
|
||||||
|
.ToDictionary(h => h.Date, h => h.Name);
|
||||||
|
|
||||||
|
var vacationMap = await db.VacationDays
|
||||||
|
.Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate)
|
||||||
|
.ToDictionaryAsync(v => v.Date, v => v.Note ?? "Urlaub");
|
||||||
|
|
||||||
|
var user = await db.Users.FindAsync(userId);
|
||||||
|
var userName = user != null ? user.Username : "Mitarbeiter";
|
||||||
|
|
||||||
|
var rows = new List<ExportRow>();
|
||||||
|
|
||||||
|
for (var date = startDate; date <= endDate; date = date.AddDays(1))
|
||||||
|
{
|
||||||
|
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
|
||||||
|
bool isHoliday = holidayMap.ContainsKey(date);
|
||||||
|
bool isVacation = vacationMap.ContainsKey(date);
|
||||||
|
|
||||||
|
double targetHours = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0;
|
||||||
|
|
||||||
|
string status = "Wochenende";
|
||||||
|
if (isHoliday) status = "Feiertag";
|
||||||
|
else if (isVacation) status = "Urlaub";
|
||||||
|
else if (isWorkDay) status = "Arbeitstag";
|
||||||
|
|
||||||
|
TimeSpan? startTime = null;
|
||||||
|
TimeSpan? endTime = null;
|
||||||
|
TimeSpan breakTime = TimeSpan.Zero;
|
||||||
|
double actualHours = 0.0;
|
||||||
|
|
||||||
|
string note = "";
|
||||||
|
if (isHoliday) note = holidayMap[date];
|
||||||
|
else if (isVacation) note = vacationMap[date];
|
||||||
|
|
||||||
|
if (workDays.TryGetValue(date, out var wd))
|
||||||
|
{
|
||||||
|
if (wd.StartTime.HasValue) startTime = wd.StartTime.Value.ToTimeSpan();
|
||||||
|
if (wd.EndTime.HasValue) endTime = wd.EndTime.Value.ToTimeSpan();
|
||||||
|
|
||||||
|
if (startTime.HasValue && endTime.HasValue)
|
||||||
|
{
|
||||||
|
var gross = endTime.Value - startTime.Value;
|
||||||
|
if (gross > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
var breakTotal = wd.Breaks
|
||||||
|
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime)
|
||||||
|
.Aggregate(TimeSpan.Zero, (s, b) =>
|
||||||
|
s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan()));
|
||||||
|
|
||||||
|
var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
|
||||||
|
var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes));
|
||||||
|
var finalBreak = breakTotal > minBreak ? breakTotal : minBreak;
|
||||||
|
|
||||||
|
breakTime = finalBreak;
|
||||||
|
actualHours = (gross - finalBreak).TotalHours;
|
||||||
|
if (actualHours < 0) actualHours = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new ExportRow
|
||||||
|
{
|
||||||
|
Date = date,
|
||||||
|
Status = status,
|
||||||
|
StartTime = startTime,
|
||||||
|
EndTime = endTime,
|
||||||
|
BreakTime = breakTime,
|
||||||
|
ActualHours = actualHours,
|
||||||
|
TargetHours = targetHours,
|
||||||
|
DiffHours = actualHours - targetHours,
|
||||||
|
Note = note
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExportToExcel(title, userName, startDate, endDate, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] ExportToExcel(
|
||||||
|
string title,
|
||||||
|
string userName,
|
||||||
|
DateOnly startDate,
|
||||||
|
DateOnly endDate,
|
||||||
|
List<ExportRow> rows)
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
var worksheet = workbook.Worksheets.Add("Arbeitszeiten");
|
||||||
|
|
||||||
|
// Gitterlinien explizit einblenden
|
||||||
|
worksheet.ShowGridLines = true;
|
||||||
|
|
||||||
|
// Title Block
|
||||||
|
worksheet.Cell("A1").Value = title;
|
||||||
|
worksheet.Cell("A1").Style.Font.Bold = true;
|
||||||
|
worksheet.Cell("A1").Style.Font.FontSize = 16;
|
||||||
|
worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#0F172A"); // Slate 900
|
||||||
|
|
||||||
|
// Info Block
|
||||||
|
worksheet.Cell("A2").Value = $"Mitarbeiter: {userName}";
|
||||||
|
worksheet.Cell("A2").Style.Font.FontSize = 11;
|
||||||
|
worksheet.Cell("A2").Style.Font.Italic = true;
|
||||||
|
|
||||||
|
worksheet.Cell("A3").Value = $"Zeitraum: {startDate:dd.MM.yyyy} bis {endDate:dd.MM.yyyy}";
|
||||||
|
worksheet.Cell("A3").Style.Font.FontSize = 11;
|
||||||
|
worksheet.Cell("A3").Style.Font.Italic = true;
|
||||||
|
|
||||||
|
// Tabellenüberschriften (Zeile 5)
|
||||||
|
string[] headers = {
|
||||||
|
"Datum", "Wochentag", "Status", "Beginn", "Ende",
|
||||||
|
"Pause", "Netto (Ist)", "Soll-Stunden", "Differenz (Gleitzeit)", "Notiz / Feiertag / Urlaub"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (int i = 0; i < headers.Length; i++)
|
||||||
|
{
|
||||||
|
var cell = worksheet.Cell(5, i + 1);
|
||||||
|
cell.Value = headers[i];
|
||||||
|
cell.Style.Font.Bold = true;
|
||||||
|
cell.Style.Font.FontColor = XLColor.White;
|
||||||
|
cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#0EA5E9"); // Primary color (Sky 500)
|
||||||
|
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datenzeilen
|
||||||
|
int currentRow = 6;
|
||||||
|
var deCulture = new CultureInfo("de-DE");
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, 1).Value = r.Date.ToString("dd.MM.yyyy");
|
||||||
|
worksheet.Cell(currentRow, 2).Value = r.Date.ToString("dddd", deCulture);
|
||||||
|
worksheet.Cell(currentRow, 3).Value = r.Status;
|
||||||
|
|
||||||
|
if (r.StartTime.HasValue)
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, 4).Value = r.StartTime.Value.ToString(@"hh\:mm");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, 4).Value = "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.EndTime.HasValue)
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, 5).Value = r.EndTime.Value.ToString(@"hh\:mm");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, 5).Value = "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
worksheet.Cell(currentRow, 6).Value = r.BreakTime.TotalMinutes > 0 ? r.BreakTime.ToString(@"hh\:mm") : "—";
|
||||||
|
|
||||||
|
worksheet.Cell(currentRow, 7).Value = r.ActualHours;
|
||||||
|
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
|
||||||
|
|
||||||
|
worksheet.Cell(currentRow, 8).Value = r.TargetHours;
|
||||||
|
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
|
||||||
|
|
||||||
|
worksheet.Cell(currentRow, 9).Value = r.DiffHours;
|
||||||
|
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
|
||||||
|
|
||||||
|
if (r.DiffHours > 0)
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#16A34A"); // Green 600
|
||||||
|
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
|
||||||
|
}
|
||||||
|
else if (r.DiffHours < 0)
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#DC2626"); // Red 600
|
||||||
|
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
worksheet.Cell(currentRow, 10).Value = r.Note;
|
||||||
|
|
||||||
|
// Zebra-Streifen
|
||||||
|
if (currentRow % 2 == 0)
|
||||||
|
{
|
||||||
|
for (int col = 1; col <= headers.Length; col++)
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, col).Style.Fill.BackgroundColor = XLColor.FromHtml("#F8FAFC"); // Slate 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rahmenlinien
|
||||||
|
for (int col = 1; col <= headers.Length; col++)
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
||||||
|
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.FromHtml("#E2E8F0");
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summenzeile
|
||||||
|
currentRow++;
|
||||||
|
|
||||||
|
worksheet.Cell(currentRow, 6).Value = "Gesamt:";
|
||||||
|
worksheet.Cell(currentRow, 6).Style.Font.Bold = true;
|
||||||
|
worksheet.Cell(currentRow, 6).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
|
||||||
|
|
||||||
|
var actualSumRange = $"G6:G{currentRow - 2}";
|
||||||
|
worksheet.Cell(currentRow, 7).FormulaA1 = $"SUM({actualSumRange})";
|
||||||
|
worksheet.Cell(currentRow, 7).Style.Font.Bold = true;
|
||||||
|
worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00";
|
||||||
|
|
||||||
|
var targetSumRange = $"H6:H{currentRow - 2}";
|
||||||
|
worksheet.Cell(currentRow, 8).FormulaA1 = $"SUM({targetSumRange})";
|
||||||
|
worksheet.Cell(currentRow, 8).Style.Font.Bold = true;
|
||||||
|
worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00";
|
||||||
|
|
||||||
|
var diffSumRange = $"I6:I{currentRow - 2}";
|
||||||
|
worksheet.Cell(currentRow, 9).FormulaA1 = $"SUM({diffSumRange})";
|
||||||
|
worksheet.Cell(currentRow, 9).Style.Font.Bold = true;
|
||||||
|
worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00";
|
||||||
|
|
||||||
|
// Summenrahmen
|
||||||
|
for (int col = 6; col <= 9; col++)
|
||||||
|
{
|
||||||
|
worksheet.Cell(currentRow, col).Style.Border.TopBorder = XLBorderStyleValues.Thin;
|
||||||
|
worksheet.Cell(currentRow, col).Style.Border.TopBorderColor = XLColor.Black;
|
||||||
|
worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Double;
|
||||||
|
worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.Black;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spaltenbreiten automatisch anpassen
|
||||||
|
worksheet.Columns().AdjustToContents();
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
workbook.SaveAs(memoryStream);
|
||||||
|
return memoryStream.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExportRow
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public TimeSpan? StartTime { get; set; }
|
||||||
|
public TimeSpan? EndTime { get; set; }
|
||||||
|
public TimeSpan BreakTime { get; set; }
|
||||||
|
public double ActualHours { get; set; }
|
||||||
|
public double TargetHours { get; set; }
|
||||||
|
public double DiffHours { get; set; }
|
||||||
|
public string Note { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ namespace timetracker.Data.Migrations
|
|||||||
migrationBuilder.AddColumn<bool>(
|
migrationBuilder.AddColumn<bool>(
|
||||||
name: "IsTenantAdmin",
|
name: "IsTenantAdmin",
|
||||||
table: "Users",
|
table: "Users",
|
||||||
type: "INTEGER",
|
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: false);
|
defaultValue: false);
|
||||||
|
|
||||||
|
|||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using timetracker.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace timetracker.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(TimetrackerDbContext))]
|
||||||
|
[Migration("20260625184756_PendingChanges")]
|
||||||
|
partial class PendingChanges
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double>("DailyTargetHours")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("FlexTimeStartDate")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<double>("FlexTimeStartingBalanceHours")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.Property<string>("GermanState")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("MinimumBreakMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("TenantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("VacationDaysPerYear")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("WorkFriday")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("WorkMonday")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("WorkSaturday")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("WorkSunday")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("WorkThursday")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("WorkTuesday")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("WorkWednesday")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("AppSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<TimeOnly?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeOnly?>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("WorkDayId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("WorkDayId");
|
||||||
|
|
||||||
|
b.ToTable("BreakEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Counties")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PublicHolidays");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsApproved")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PrimaryColor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SecondaryColor")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Subdomain")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Subdomain")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Tenants");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTenantAdmin")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TenantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TenantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("VacationDays");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeOnly?>("EndTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<TimeOnly?>("StartTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TenantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("WorkDays");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||||
|
.WithMany("Breaks")
|
||||||
|
.HasForeignKey("WorkDayId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("WorkDay");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TenantId");
|
||||||
|
|
||||||
|
b.Navigation("Tenant");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Breaks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace timetracker.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class PendingChanges : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ namespace timetracker.Data.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||||
|
|
||||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public class TenantProvider : ITenantProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve and cache in HttpContext.Items
|
// Resolve and cache in HttpContext.Items
|
||||||
ResolveTenantAsync().GetAwaiter().GetResult();
|
ResolveTenant();
|
||||||
|
|
||||||
if (httpContext.Items.TryGetValue("TenantId", out cachedId))
|
if (httpContext.Items.TryGetValue("TenantId", out cachedId))
|
||||||
{
|
{
|
||||||
@@ -48,27 +48,27 @@ public class TenantProvider : ITenantProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Tenant?> GetCurrentTenantAsync()
|
public Task<Tenant?> GetCurrentTenantAsync()
|
||||||
{
|
{
|
||||||
var httpContext = _httpContextAccessor.HttpContext;
|
var httpContext = _httpContextAccessor.HttpContext;
|
||||||
if (httpContext == null) return null;
|
if (httpContext == null) return Task.FromResult<Tenant?>(null);
|
||||||
|
|
||||||
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
|
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
|
||||||
{
|
{
|
||||||
return (Tenant?)cachedTenant;
|
return Task.FromResult((Tenant?)cachedTenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ResolveTenantAsync();
|
ResolveTenant();
|
||||||
|
|
||||||
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
|
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
|
||||||
{
|
{
|
||||||
return (Tenant?)cachedTenant;
|
return Task.FromResult((Tenant?)cachedTenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return Task.FromResult<Tenant?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ResolveTenantAsync()
|
private void ResolveTenant()
|
||||||
{
|
{
|
||||||
var httpContext = _httpContextAccessor.HttpContext;
|
var httpContext = _httpContextAccessor.HttpContext;
|
||||||
if (httpContext == null) return;
|
if (httpContext == null) return;
|
||||||
@@ -82,9 +82,9 @@ public class TenantProvider : ITenantProvider
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(subdomain))
|
if (!string.IsNullOrEmpty(subdomain))
|
||||||
{
|
{
|
||||||
await using var db = await _dbContextFactory.CreateDbContextAsync();
|
using var db = _dbContextFactory.CreateDbContext();
|
||||||
var tenant = await db.Tenants
|
var tenant = db.Tenants
|
||||||
.FirstOrDefaultAsync(t => t.Subdomain.ToLower() == subdomain.ToLower());
|
.FirstOrDefault(t => t.Subdomain.ToLower() == subdomain.ToLower());
|
||||||
|
|
||||||
if (tenant != null)
|
if (tenant != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,14 +114,16 @@ if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
|||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
?? builder.Configuration["ConnectionStrings:DefaultConnection"];
|
?? builder.Configuration["ConnectionStrings:DefaultConnection"];
|
||||||
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
|
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
|
||||||
options.UseNpgsql(connectionString));
|
options.UseNpgsql(connectionString)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
|
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
|
||||||
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
|
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
|
||||||
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
|
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
|
||||||
options.UseSqlite($"Data Source={dbPath}"));
|
options.UseSqlite($"Data Source={dbPath}")
|
||||||
|
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
}
|
}
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -131,13 +133,149 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
|
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
|
||||||
await using var db = await factory.CreateDbContextAsync();
|
await using var db = await factory.CreateDbContextAsync();
|
||||||
|
|
||||||
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await db.Database.EnsureCreatedAsync();
|
try
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
{
|
||||||
|
await conn.OpenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if AppSettings table already exists
|
||||||
|
using (var cmdCheck = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdCheck.CommandText = "SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'AppSettings');";
|
||||||
|
var appSettingsExists = (bool)(await cmdCheck.ExecuteScalarAsync() ?? false);
|
||||||
|
|
||||||
|
if (appSettingsExists)
|
||||||
|
{
|
||||||
|
// Ensure __EFMigrationsHistory table exists
|
||||||
|
using (var cmdCreateHistory = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdCreateHistory.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS ""__EFMigrationsHistory"" (
|
||||||
|
""MigrationId"" character varying(150) NOT NULL,
|
||||||
|
""ProductVersion"" character varying(32) NOT NULL,
|
||||||
|
CONSTRAINT ""PK___EFMigrationsHistory"" PRIMARY KEY (""MigrationId"")
|
||||||
|
);";
|
||||||
|
await cmdCreateHistory.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the initial migration is already recorded
|
||||||
|
using (var cmdCheckHistory = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdCheckHistory.CommandText = @"SELECT COUNT(*) FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520133634_Initial';";
|
||||||
|
var historyCount = Convert.ToInt32(await cmdCheckHistory.ExecuteScalarAsync() ?? 0);
|
||||||
|
|
||||||
|
if (historyCount == 0)
|
||||||
|
{
|
||||||
|
// Seed the history table with the migrations that were already created by EnsureCreated in older versions
|
||||||
|
using (var cmdInsertHistory = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdInsertHistory.CommandText = @"
|
||||||
|
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"") VALUES
|
||||||
|
('20260520133634_Initial', '10.0.9'),
|
||||||
|
('20260520200000_AddPublicHolidays', '10.0.9'),
|
||||||
|
('20260522081459_AddMultiUser', '10.0.9'),
|
||||||
|
('20260607213215_AddFlexTimeAndHolidayState', '10.0.9');";
|
||||||
|
await cmdInsertHistory.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Fehler beim Vorbereiten der PostgreSQL Migrationshistorie: {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
|
||||||
|
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await db.Database.MigrateAsync();
|
try
|
||||||
|
{
|
||||||
|
var conn = db.Database.GetDbConnection();
|
||||||
|
if (conn.State != System.Data.ConnectionState.Open)
|
||||||
|
{
|
||||||
|
await conn.OpenAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if column IsTenantAdmin in Users table is of type integer
|
||||||
|
using (var cmdCheckType = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdCheckType.CommandText = @"
|
||||||
|
SELECT data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'Users' AND column_name = 'IsTenantAdmin';";
|
||||||
|
var dataType = await cmdCheckType.ExecuteScalarAsync() as string;
|
||||||
|
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Alter the column type to boolean
|
||||||
|
using (var cmdAlterType = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdAlterType.CommandText = @"
|
||||||
|
ALTER TABLE ""Users""
|
||||||
|
ALTER COLUMN ""IsTenantAdmin"" TYPE boolean
|
||||||
|
USING (""IsTenantAdmin"" <> 0);";
|
||||||
|
await cmdAlterType.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if column IsApproved in Tenants table is of type integer
|
||||||
|
using (var cmdCheckApproved = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdCheckApproved.CommandText = @"
|
||||||
|
SELECT data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'Tenants' AND column_name = 'IsApproved';";
|
||||||
|
var dataType = await cmdCheckApproved.ExecuteScalarAsync() as string;
|
||||||
|
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Alter the column type to boolean
|
||||||
|
using (var cmdAlterApproved = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdAlterApproved.CommandText = @"
|
||||||
|
ALTER TABLE ""Tenants""
|
||||||
|
ALTER COLUMN ""IsApproved"" TYPE boolean
|
||||||
|
USING (""IsApproved"" <> 0);";
|
||||||
|
await cmdAlterApproved.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if column CreatedAt in Tenants table is of type text or character varying
|
||||||
|
using (var cmdCheckCreatedAt = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdCheckCreatedAt.CommandText = @"
|
||||||
|
SELECT data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'Tenants' AND column_name = 'CreatedAt';";
|
||||||
|
var dataType = await cmdCheckCreatedAt.ExecuteScalarAsync() as string;
|
||||||
|
if (dataType != null && (dataType.Equals("text", StringComparison.OrdinalIgnoreCase) || dataType.Equals("character varying", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
// Alter the column type to timestamp without time zone
|
||||||
|
using (var cmdAlterCreatedAt = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmdAlterCreatedAt.CommandText = @"
|
||||||
|
ALTER TABLE ""Tenants""
|
||||||
|
ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
|
||||||
|
USING (""CreatedAt""::timestamp without time zone);";
|
||||||
|
await cmdAlterCreatedAt.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Fehler beim Konvertieren der PostgreSQL-Spaltentypen (IsTenantAdmin/IsApproved/CreatedAt): {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +597,83 @@ trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery]
|
|||||||
return Results.Ok(days);
|
return Results.Ok(days);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
trackerApi.MapGet("/export/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService, TimetrackerDbContext db) =>
|
||||||
|
{
|
||||||
|
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
if (DateOnly.TryParse(monday, out var date))
|
||||||
|
{
|
||||||
|
var mondayDate = date;
|
||||||
|
int diff = ((int)mondayDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
|
||||||
|
mondayDate = mondayDate.AddDays(-diff);
|
||||||
|
var sundayDate = mondayDate.AddDays(6);
|
||||||
|
|
||||||
|
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
|
||||||
|
userId,
|
||||||
|
mondayDate,
|
||||||
|
sundayDate,
|
||||||
|
$"Wochenbericht KW {System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}",
|
||||||
|
trackerService,
|
||||||
|
db);
|
||||||
|
|
||||||
|
return Results.File(
|
||||||
|
fileBytes,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
$"Wochenbericht_KW_{System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}_{mondayDate:yyyyMMdd}.xlsx");
|
||||||
|
}
|
||||||
|
return Results.BadRequest("Ungültiges Datum.");
|
||||||
|
});
|
||||||
|
|
||||||
|
trackerApi.MapGet("/export/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService, TimetrackerDbContext db) =>
|
||||||
|
{
|
||||||
|
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
if (month < 1 || month > 12) return Results.BadRequest("Ungültiger Monat.");
|
||||||
|
|
||||||
|
var startDate = new DateOnly(year, month, 1);
|
||||||
|
var endDate = startDate.AddMonths(1).AddDays(-1);
|
||||||
|
|
||||||
|
var deCulture = new System.Globalization.CultureInfo("de-DE");
|
||||||
|
var monthName = deCulture.DateTimeFormat.GetMonthName(month);
|
||||||
|
|
||||||
|
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
|
||||||
|
userId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
$"Monatsbericht {monthName} {year}",
|
||||||
|
trackerService,
|
||||||
|
db);
|
||||||
|
|
||||||
|
return Results.File(
|
||||||
|
fileBytes,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
$"Monatsbericht_{monthName}_{year}.xlsx");
|
||||||
|
});
|
||||||
|
|
||||||
|
trackerApi.MapGet("/export/year", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, ITimetrackerService trackerService, TimetrackerDbContext db) =>
|
||||||
|
{
|
||||||
|
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
|
||||||
|
|
||||||
|
var startDate = new DateOnly(year, 1, 1);
|
||||||
|
var endDate = new DateOnly(year, 12, 31);
|
||||||
|
|
||||||
|
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
|
||||||
|
userId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
$"Jahresbericht {year}",
|
||||||
|
trackerService,
|
||||||
|
db);
|
||||||
|
|
||||||
|
return Results.File(
|
||||||
|
fileBytes,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
$"Jahresbericht_{year}.xlsx");
|
||||||
|
});
|
||||||
|
|
||||||
// ── Holiday-API-Endpoints (Protected) ─────────────────────────────────────────
|
// ── Holiday-API-Endpoints (Protected) ─────────────────────────────────────────
|
||||||
var holidaysApi = app.MapGroup("/api/holidays").RequireAuthorization();
|
var holidaysApi = app.MapGroup("/api/holidays").RequireAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||||
<PackageReference Include="MudBlazor" Version="9.4.0" />
|
<PackageReference Include="MudBlazor" Version="9.4.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user