Implements a provider-agnostic calendar sync feature with Nextcloud as the first alternative to Google Calendar. - Add ICalendarService interface and provider-agnostic API endpoints - Implement NextcloudCalendarService using CalDAV (Ical.Net) - Add CalendarEventBuilder to share event/transport logic across providers - Add Nextcloud sync button to the UI (Index page) - Centralise TargetFramework, Nullable and ImplicitUsings in Directory.Build.props (net9.0) - Add .worktrees to .gitignore and Docker/Nextcloud config docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
221 lines
9.8 KiB
Text
221 lines
9.8 KiB
Text
@page "/"
|
|
@using ShiftScheduler.Client.Components
|
|
|
|
<div class="top-nav-buttons">
|
|
<div class="month-selection">
|
|
<button class="btn month-btn @(_isCurrentMonth ? "selected" : "")" @onclick="SelectCurrentMonth" disabled="@_isLoadingInitial">@MonthAndYear.CurrentMonthName</button>
|
|
<button class="btn month-btn @(_isCurrentMonth ? "" : "selected")" @onclick="SelectNextMonth" disabled="@_isLoadingInitial">@MonthAndYear.NextMonthName</button>
|
|
</div>
|
|
<div class="action-buttons">
|
|
<button class="btn export-btn" @onclick="ExportToIcs" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-calendar" aria-hidden="true"></span> Export to ICS</button>
|
|
<button class="btn export-btn" @onclick="SyncToGoogleCalendar" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cloud-upload" aria-hidden="true"></span> Sync to Google Calendar</button>
|
|
<button class="btn export-btn" @onclick="SyncToNextcloud" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cloud-upload" aria-hidden="true"></span> Sync to Nextcloud</button>
|
|
<button class="btn export-btn" @onclick="ExportToPdf" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-document" aria-hidden="true"></span>Export to PDF</button>
|
|
<button class="btn config-btn" @onclick="ShowConfiguration" disabled="@(_isLoadingInitial || _isSyncing)"><span class="oi oi-cog" aria-hidden="true"></span> Configuration</button>
|
|
</div>
|
|
</div>
|
|
|
|
<h3>Shift Scheduler - @SelectedDate.Title</h3>
|
|
|
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
|
{
|
|
<div class="alert alert-danger" role="alert">
|
|
@_errorMessage
|
|
<button type="button" class="btn-close" @onclick="() => _errorMessage = string.Empty"></button>
|
|
</div>
|
|
}
|
|
|
|
@if (!string.IsNullOrEmpty(_successMessage))
|
|
{
|
|
<div class="alert alert-success" role="alert">
|
|
@_successMessage
|
|
<button type="button" class="btn-close" @onclick="() => _successMessage = string.Empty"></button>
|
|
</div>
|
|
}
|
|
|
|
@if (_showCalendarSelector)
|
|
{
|
|
<div class="modal fade show" style="display: block; background-color: rgba(0,0,0,0.5);" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Select @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google") Calendar</h5>
|
|
<button type="button" class="btn-close" @onclick="CloseCalendarSelector"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
@if (_availableCalendars.Count == 0)
|
|
{
|
|
<div class="d-flex justify-content-center">
|
|
<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">Loading calendars...</span>
|
|
</div>
|
|
</div>
|
|
<p class="text-center mt-2">Loading your calendars...</p>
|
|
}
|
|
else
|
|
{
|
|
<p>Choose which calendar to sync your shifts to:</p>
|
|
<div class="list-group">
|
|
@foreach (var calendar in _availableCalendars)
|
|
{
|
|
<button type="button" class="list-group-item list-group-item-action"
|
|
@onclick="() => SelectCalendarAndSync(calendar.Id)">
|
|
@calendar.Summary
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @onclick="CloseCalendarSelector">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (_isLoadingInitial)
|
|
{
|
|
<div class="loading-overlay">
|
|
<div class="loading-content">
|
|
<div class="loading-spinner">⏳</div>
|
|
<p>Loading shifts and transport data...</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
else if (_isSyncing)
|
|
{
|
|
<div class="loading-overlay">
|
|
<div class="loading-content">
|
|
<div class="loading-spinner">⏳</div>
|
|
<p>Syncing with @(_syncProvider == "nextcloud" ? "Nextcloud" : "Google Calendar")...</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
|
|
<div class="calendar-container">
|
|
<!-- Desktop week-based layout -->
|
|
<div class="calendar-grid desktop-calendar">
|
|
@foreach (var week in SelectedDate.GetWeeksInMonth())
|
|
{
|
|
@foreach (var day in week.Value)
|
|
{
|
|
<div class="day-cell">
|
|
<div class="day-header">
|
|
@day.ToString("dd.MM.yyyy") (@day.ToString("dddd"))
|
|
</div>
|
|
<div class="shifts">
|
|
@foreach (var shift in Shifts)
|
|
{
|
|
<div class="tooltip-wrapper">
|
|
<button class="shift-button @(SelectedSchedule.ContainsKey(day) && SelectedSchedule[day] == shift.Name ? "selected" : "")"
|
|
@onclick="() => SelectShift(day, shift.Name)"
|
|
disabled="@IsLoadingTransportForDay(day)">
|
|
@if (IsLoadingTransportForDay(day) && SelectedSchedule.ContainsKey(day) && SelectedSchedule[day] == shift.Name)
|
|
{
|
|
<span class="loading-spinner">⏳</span>
|
|
}
|
|
else
|
|
{
|
|
@if (shift.IsPngIcon)
|
|
{
|
|
<img src="icons\@shift.Icon" alt="@shift.Name" style="width: 24px; height: 24px;" />
|
|
}
|
|
else
|
|
{
|
|
@shift.Icon
|
|
}
|
|
}
|
|
</button>
|
|
<span class="tooltip">@shift.Name</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
@if (SelectedSchedule.ContainsKey(day))
|
|
{
|
|
if (GetShiftTimes(day).Length > 0)
|
|
{
|
|
<div class="shift-times">
|
|
@GetShiftTimes(day)
|
|
</div>
|
|
<div class="transport-info">
|
|
@GetTransportSummary(day)
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="shift-times">
|
|
@SelectedSchedule[day]
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<!-- Mobile single-column layout -->
|
|
<div class="calendar-grid mobile-calendar">
|
|
@foreach (var day in SelectedDate.DaysInMonth())
|
|
{
|
|
<div class="day-cell">
|
|
<div class="day-header">
|
|
@day.ToString("dd.MM.yyyy") (@day.ToString("dddd"))
|
|
</div>
|
|
<div class="shifts">
|
|
@foreach (var shift in Shifts)
|
|
{
|
|
<div class="tooltip-wrapper">
|
|
<button class="shift-button @(SelectedSchedule.ContainsKey(day) && SelectedSchedule[day] == shift.Name ? "selected" : "")"
|
|
@onclick="() => SelectShift(day, shift.Name)"
|
|
disabled="@IsLoadingTransportForDay(day)">
|
|
@if (IsLoadingTransportForDay(day) && SelectedSchedule.ContainsKey(day) && SelectedSchedule[day] == shift.Name)
|
|
{
|
|
<span class="loading-spinner">⏳</span>
|
|
}
|
|
else
|
|
{
|
|
@if (shift.IsPngIcon)
|
|
{
|
|
<img src="icons\@shift.Icon" alt="@shift.Name" style="width: 24px; height: 24px;" />
|
|
}
|
|
else
|
|
{
|
|
@shift.Icon
|
|
}
|
|
}
|
|
</button>
|
|
<span class="tooltip">@shift.Name</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
@if (SelectedSchedule.ContainsKey(day))
|
|
{
|
|
if (GetShiftTimes(day).Length > 0)
|
|
{
|
|
<div class="shift-times">
|
|
@GetShiftTimes(day)
|
|
</div>
|
|
<div class="transport-info">
|
|
@GetTransportSummary(day)
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="shift-times">
|
|
@SelectedSchedule[day]
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<ConfigurationDialog IsVisible="_showConfigDialog"
|
|
OnClose="HideConfiguration"
|
|
OnConfigurationChanged="OnConfigurationChanged" />
|
|
|