Files
timetracker/timetracker.Server/Data/TimetrackerService.cs
T

343 lines
14 KiB
C#

using Microsoft.EntityFrameworkCore;
using timetracker.Shared;
namespace timetracker.Data;
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory) : ITimetrackerService
{
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
{
await using var db = await factory.CreateDbContextAsync();
return await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= monday && w.Date < monday.AddDays(7))
.OrderBy(w => w.Date)
.ToListAsync();
}
public async Task UpsertWorkDayAsync(WorkDay workDay)
{
await using var db = await factory.CreateDbContextAsync();
var existing = await db.WorkDays
.Include(w => w.Breaks)
.FirstOrDefaultAsync(w => w.UserId == workDay.UserId && w.Date == workDay.Date);
if (existing == null)
{
workDay.Id = 0;
foreach (var b in workDay.Breaks) b.Id = 0;
db.WorkDays.Add(workDay);
}
else
{
existing.StartTime = workDay.StartTime;
existing.EndTime = workDay.EndTime;
db.BreakEntries.RemoveRange(existing.Breaks);
existing.Breaks.Clear();
foreach (var b in workDay.Breaks)
existing.Breaks.Add(new BreakEntry
{
WorkDayId = existing.Id,
StartTime = b.StartTime,
EndTime = b.EndTime
});
}
await db.SaveChangesAsync();
}
public async Task<AppSettings> GetSettingsAsync(int userId)
{
await using var db = await factory.CreateDbContextAsync();
return await db.AppSettings.FirstOrDefaultAsync(s => s.UserId == userId)
?? new AppSettings { UserId = userId };
}
public async Task SaveSettingsAsync(AppSettings settings)
{
await using var db = await factory.CreateDbContextAsync();
var existing = await db.AppSettings.FirstOrDefaultAsync(s => s.UserId == settings.UserId);
if (existing == null)
db.AppSettings.Add(settings);
else
{
existing.DailyTargetHours = settings.DailyTargetHours;
existing.MinimumBreakMinutes = settings.MinimumBreakMinutes;
existing.VacationDaysPerYear = settings.VacationDaysPerYear;
existing.WorkMonday = settings.WorkMonday;
existing.WorkTuesday = settings.WorkTuesday;
existing.WorkWednesday = settings.WorkWednesday;
existing.WorkThursday = settings.WorkThursday;
existing.WorkFriday = settings.WorkFriday;
existing.WorkSaturday = settings.WorkSaturday;
existing.WorkSunday = settings.WorkSunday;
}
await db.SaveChangesAsync();
}
// ── Urlaub ────────────────────────────────────────────────────────────
public async Task<List<VacationDay>> GetVacationDaysAsync(int userId, int year)
{
await using var db = await factory.CreateDbContextAsync();
return await db.VacationDays
.Where(v => v.UserId == userId && v.Date.Year == year)
.OrderBy(v => v.Date)
.ToListAsync();
}
public async Task AddVacationDayAsync(VacationDay vacationDay)
{
await using var db = await factory.CreateDbContextAsync();
var exists = await db.VacationDays.AnyAsync(v => v.UserId == vacationDay.UserId && v.Date == vacationDay.Date);
if (!exists)
{
vacationDay.Id = 0;
db.VacationDays.Add(vacationDay);
await db.SaveChangesAsync();
}
}
public async Task RemoveVacationDayAsync(int userId, int id)
{
await using var db = await factory.CreateDbContextAsync();
var v = await db.VacationDays.FirstOrDefaultAsync(v => v.Id == id && v.UserId == userId);
if (v != null)
{
db.VacationDays.Remove(v);
await db.SaveChangesAsync();
}
}
// ── Gleitzeitkonto ───────────────────────────────────────────────────
public async Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings)
{
await using var db = await factory.CreateDbContextAsync();
// 1. Finde das Startdatum für die Berechnung
var firstDay = await db.WorkDays
.Where(w => w.UserId == userId)
.OrderBy(w => w.Date)
.Select(w => (DateOnly?)w.Date)
.FirstOrDefaultAsync();
if (firstDay == null && !settings.FlexTimeStartDate.HasValue)
return TimeSpan.FromHours(settings.FlexTimeStartingBalanceHours);
var startDate = settings.FlexTimeStartDate ?? firstDay!.Value;
var endDate = DateOnly.FromDateTime(DateTime.Today);
if (startDate > endDate)
return TimeSpan.FromHours(settings.FlexTimeStartingBalanceHours);
// 2. Lade alle erfassten Arbeitstage in diesem Zeitraum
var workDays = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
.ToDictionaryAsync(w => w.Date);
// 3. Lade alle Feiertage in diesem Zeitraum
var holidaysList = await db.PublicHolidays
.Where(h => h.Date >= startDate && h.Date <= endDate)
.ToListAsync();
var state = settings.GermanState;
var holidaySet = holidaysList
.Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state)))
.Select(h => h.Date)
.ToHashSet();
// 4. Lade alle Urlaubstage in diesem Zeitraum
var vacationSet = await db.VacationDays
.Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate)
.Select(v => v.Date)
.ToHashSetAsync();
double totalOvertimeHours = settings.FlexTimeStartingBalanceHours;
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
bool isWorkDay = settings.IsWorkDay(date.DayOfWeek);
bool isHoliday = holidaySet.Contains(date);
bool isVacation = vacationSet.Contains(date);
// Sollzeit gilt nur an regulären Arbeitstagen, die weder Feiertag noch Urlaub sind
double target = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0;
double actual = 0.0;
if (workDays.TryGetValue(date, out var wd) && wd.StartTime != null && wd.EndTime != null)
{
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
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;
actual = (gross - finalBreak).TotalHours;
if (actual < 0) actual = 0.0;
}
}
totalOvertimeHours += (actual - target);
}
return TimeSpan.FromHours(totalOvertimeHours);
}
// ── Monatsübersicht ───────────────────────────────────────────────────
public async Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month)
{
await using var db = await factory.CreateDbContextAsync();
var from = new DateOnly(year, month, 1);
var to = from.AddMonths(1);
return await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= from && w.Date < to)
.OrderBy(w => w.Date)
.ToListAsync();
}
// ── Dashboard ─────────────────────────────────────────────────────────
public async Task<DashboardStatsVm> GetDashboardStatsAsync(int userId, int year, int month)
{
await using var db = await factory.CreateDbContextAsync();
var startDate = new DateOnly(year, month, 1);
var endDate = startDate.AddMonths(1).AddDays(-1);
var workDays = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate)
.ToListAsync();
var stats = new DashboardStatsVm();
if (workDays.Any(w => w.StartTime.HasValue))
{
var earliest = workDays.Where(w => w.StartTime.HasValue).Min(w => w.StartTime!.Value);
stats.EarliestStart = earliest.ToTimeSpan();
}
if (workDays.Any(w => w.EndTime.HasValue))
{
var latest = workDays.Where(w => w.EndTime.HasValue).Max(w => w.EndTime!.Value);
stats.LatestEnd = latest.ToTimeSpan();
}
var daysWithBreaks = workDays.Where(w => w.Breaks.Any(b => b.StartTime.HasValue && b.EndTime.HasValue)).ToList();
if (daysWithBreaks.Any())
{
var totalBreakTicks = daysWithBreaks.Sum(w =>
w.Breaks.Where(b => b.StartTime.HasValue && b.EndTime.HasValue)
.Sum(b => (b.EndTime!.Value - b.StartTime!.Value).Ticks));
stats.AverageBreak = TimeSpan.FromTicks(totalBreakTicks / daysWithBreaks.Count);
}
var allUserDays = await db.WorkDays
.Where(w => w.UserId == userId && w.StartTime.HasValue && w.EndTime.HasValue)
.OrderBy(w => w.Date)
.Select(w => w.Date)
.ToListAsync();
if (allUserDays.Any())
{
int currentStreak = 1;
int maxStreak = 1;
int tempStreak = 1;
for (int i = 1; i < allUserDays.Count; i++)
{
if (allUserDays[i].DayNumber == allUserDays[i - 1].DayNumber + 1)
{
tempStreak++;
}
else if (allUserDays[i].DayNumber > allUserDays[i - 1].DayNumber + 1)
{
if (tempStreak > maxStreak) maxStreak = tempStreak;
tempStreak = 1;
}
}
if (tempStreak > maxStreak) maxStreak = tempStreak;
stats.LongestStreak = maxStreak;
var today = DateOnly.FromDateTime(DateTime.Today);
var yesterday = today.AddDays(-1);
if (allUserDays.Last() == today || allUserDays.Last() == yesterday)
{
stats.CurrentStreak = tempStreak;
}
else
{
stats.CurrentStreak = 0;
}
}
return stats;
}
public async Task<DashboardChartDataVm> GetDashboardChartDataAsync(int userId, int year)
{
await using var db = await factory.CreateDbContextAsync();
var chartData = new DashboardChartDataVm();
var settings = await GetSettingsAsync(userId);
var workDaysThisYear = await db.WorkDays
.Include(w => w.Breaks)
.Where(w => w.UserId == userId && w.Date.Year == year)
.ToListAsync();
var vacationsThisYear = await db.VacationDays
.Where(v => v.UserId == userId && v.Date.Year == year)
.ToListAsync();
var holidaysThisYear = await db.PublicHolidays
.Where(h => h.Date.Year == year && h.Counties.Contains(settings.GermanState))
.ToListAsync();
chartData.WorkDaysCount = workDaysThisYear.Count(w => w.StartTime.HasValue);
chartData.VacationDaysCount = vacationsThisYear.Count;
chartData.PublicHolidaysCount = holidaysThisYear.Count(h => settings.IsWorkDay(h.Date.DayOfWeek));
for (int month = 1; month <= 12; month++)
{
var daysInMonth = workDaysThisYear.Where(w => w.Date.Month == month).ToList();
if (!daysInMonth.Any(d => d.StartTime.HasValue)) continue;
double monthOvertime = 0;
foreach (var day in daysInMonth)
{
if (!day.StartTime.HasValue || !day.EndTime.HasValue) continue;
var gross = day.EndTime.Value - day.StartTime.Value;
var breakTicks = day.Breaks
.Where(b => b.StartTime.HasValue && b.EndTime.HasValue)
.Sum(b => (b.EndTime!.Value - b.StartTime!.Value).Ticks);
var enteredBreak = TimeSpan.FromTicks(breakTicks);
int minMinutes = gross.TotalHours >= 9.0 ? 45 : 30;
int finalMin = Math.Max(minMinutes, settings.MinimumBreakMinutes);
var minBreak = TimeSpan.FromMinutes(finalMin);
var effectiveBreak = enteredBreak > minBreak ? enteredBreak : minBreak;
var net = gross - effectiveBreak;
var target = TimeSpan.FromHours(settings.DailyTargetHours);
var isHoliday = holidaysThisYear.Any(h => h.Date == day.Date);
var isVacation = vacationsThisYear.Any(v => v.Date == day.Date);
var isWorkDay = settings.IsWorkDay(day.Date.DayOfWeek);
if (!isWorkDay || isHoliday || isVacation) target = TimeSpan.Zero;
monthOvertime += (net - target).TotalHours;
}
var monthName = new DateTime(year, month, 1).ToString("MMM", new System.Globalization.CultureInfo("de-DE"));
chartData.MonthlyOvertime.Add(new MonthlyOvertimeVm { MonthName = monthName, OvertimeHours = monthOvertime });
}
return chartData;
}
}