Neuste Version
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/.vs/
|
||||
**/.git/
|
||||
**/node_modules/
|
||||
*.md
|
||||
.dockerignore
|
||||
.gitignore
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
<link rel="stylesheet" href="_content/MudBlazor/MudBlazor.min.css" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["timetracker.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<MudThemeProvider Theme="_theme" />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="2" Style="background: linear-gradient(90deg, #3F51B5 0%, #1A237E 100%);">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" />
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="ml-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Style="color:white; font-size:1.6rem" />
|
||||
<MudText Typo="Typo.h6" Style="color:white; font-weight:700; letter-spacing:0.5px">Timetracker</MudText>
|
||||
</MudStack>
|
||||
<MudSpacer />
|
||||
</MudAppBar>
|
||||
|
||||
<MudDrawer @bind-Open="_drawerOpen" ClipMode="DrawerClipMode.Always" Elevation="2">
|
||||
<NavMenu />
|
||||
</MudDrawer>
|
||||
|
||||
<MudMainContent>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 pb-8">
|
||||
@Body
|
||||
</MudContainer>
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
|
||||
private readonly MudTheme _theme = new()
|
||||
{
|
||||
PaletteLight = new PaletteLight
|
||||
{
|
||||
Primary = "#3F51B5",
|
||||
PrimaryDarken = "#1A237E",
|
||||
PrimaryLighten = "#7986CB",
|
||||
Secondary = "#009688",
|
||||
SecondaryDarken = "#00695C",
|
||||
AppbarBackground = "#3F51B5",
|
||||
Background = "#F4F6F9",
|
||||
DrawerBackground = "#FFFFFF",
|
||||
Surface = "#FFFFFF",
|
||||
TextPrimary = "#212121",
|
||||
TextSecondary = "#757575",
|
||||
}
|
||||
};
|
||||
|
||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<MudNavMenu>
|
||||
<MudText Typo="Typo.h6" Class="px-4 mt-4 mb-2">Navigation</MudText>
|
||||
<MudDivider Class="mb-2" />
|
||||
<MudNavLink Href="" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.CalendarMonth">Wochenübersicht</MudNavLink>
|
||||
<MudNavLink Href="month" Icon="@Icons.Material.Filled.CalendarViewMonth">Monatsübersicht</MudNavLink>
|
||||
<MudNavLink Href="feiertage" Icon="@Icons.Material.Filled.Celebration">Feiertage</MudNavLink>
|
||||
<MudNavLink Href="urlaub-maximizer" Icon="@Icons.Material.Filled.AutoAwesome">Urlaubs-Maximizer</MudNavLink>
|
||||
<MudNavLink Href="settings" Icon="@Icons.Material.Filled.Settings">Einstellungen</MudNavLink>
|
||||
<MudDivider Class="mt-2 mb-2" />
|
||||
<AuthorizeView Policy="AdminOnly">
|
||||
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" IconColor="Color.Error">
|
||||
Benutzerverwaltung
|
||||
</MudNavLink>
|
||||
</AuthorizeView>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="px-4 py-1" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Color="Color.Primary" Size="Size.Small" />
|
||||
<MudText Typo="Typo.body2" Style="font-weight:600">@context.User.Identity?.Name</MudText>
|
||||
</MudStack>
|
||||
<MudNavLink Href="/auth/logout" Icon="@Icons.Material.Filled.Logout">Abmelden</MudNavLink>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</MudNavMenu>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -0,0 +1,157 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@page "/admin/users"
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Policy = "AdminOnly")]
|
||||
@inject AuthService AuthService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Benutzer…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #B71C1C 0%, #7F0000 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AdminPanelSettings" Style="color:white; font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Benutzerverwaltung</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
@_users.Count Benutzer registriert
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Tabelle ── *@
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardContent Class="pa-0">
|
||||
<MudTable Items="_users" Hover="true" Striped="true" Dense="false"
|
||||
SortLabel="Sortieren">
|
||||
<HeaderContent>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Id)">ID</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Username)" InitialDirection="SortDirection.Ascending">Benutzername</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle"
|
||||
Color="@(context.Username == "marc" ? Color.Error : Color.Default)"
|
||||
Size="Size.Small" />
|
||||
<MudText Style="@(context.Username == "marc" ? "font-weight:700" : "")">
|
||||
@context.Username
|
||||
</MudText>
|
||||
@if (context.Username == "marc")
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Error" Variant="Variant.Outlined">Admin</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (context.Username != "marc")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => DeleteUser(context))" />
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<NoRecordsContent>
|
||||
<MudText Color="Color.Secondary" Class="pa-4">Keine Benutzer gefunden.</MudText>
|
||||
</NoRecordsContent>
|
||||
</MudTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<User> _users = [];
|
||||
private bool _loading = true;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var claim = (await AuthStateProvider.GetAuthenticationStateAsync())
|
||||
.User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim == null) return;
|
||||
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task DeleteUser(User user)
|
||||
{
|
||||
await AuthService.DeleteUserAsync(user.Id);
|
||||
_users.Remove(user);
|
||||
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
@page "/feiertage"
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject HolidayService HolidayService
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>Feiertage – Timetracker</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Tertiary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Feiertage…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #00897B 0%, #004D40 100%); color: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevYear" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
Feiertage @_year
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
@_subLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
@if (_year != DateTime.Today.Year)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
OnClick="GoToCurrentYear" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color:white" Size="Size.Large" OnClick="NextYear" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@if (_holidays.Count == 0)
|
||||
{
|
||||
@* ── Keine Daten ── *@
|
||||
<MudPaper Elevation="2" Class="pa-8 rounded-xl text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Celebration"
|
||||
Style="font-size:4rem; color:#B2DFDB;" Class="mb-3" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Secondary">
|
||||
Keine Feiertage für @_year gespeichert.
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mt-1">
|
||||
Gehe zu <b>Einstellungen → Feiertage</b> und klicke „Von API laden".
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Kacheln nach Monat gruppiert ── *@
|
||||
@foreach (var group in _holidays.GroupBy(h => h.Date.Month).OrderBy(g => g.Key))
|
||||
{
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary"
|
||||
Style="font-weight:700; letter-spacing:2px; padding-left:4px">
|
||||
@_deCulture.DateTimeFormat.GetMonthName(group.Key).ToUpper()
|
||||
</MudText>
|
||||
<MudGrid Spacing="3">
|
||||
@foreach (var h in group.OrderBy(x => x.Date))
|
||||
{
|
||||
var isPast = h.Date < DateOnly.FromDateTime(DateTime.Today);
|
||||
var isToday = h.Date == DateOnly.FromDateTime(DateTime.Today);
|
||||
var daysLeft = h.Date.DayNumber - DateOnly.FromDateTime(DateTime.Today).DayNumber;
|
||||
|
||||
<MudItem xs="12" sm="6" md="4" lg="3">
|
||||
<MudCard Elevation="@(isToday ? 6 : 2)" Class="rounded-xl h-100"
|
||||
Style="@($"border-top: 4px solid {(isToday ? "#FF6F00" : isPast ? "#B2DFDB" : "#00897B")}; opacity:{(isPast && !isToday ? "0.7" : "1")};")">
|
||||
<MudCardContent Class="pa-4">
|
||||
<MudStack Spacing="2">
|
||||
|
||||
@* Icon + Datum *@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center"
|
||||
Justify="Justify.SpaceBetween">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Celebration"
|
||||
Style="@($"color:{(isToday ? "#FF6F00" : isPast ? "#80CBC4" : "#00897B")}; font-size:1.3rem")" />
|
||||
<MudText Typo="Typo.caption"
|
||||
Style="@($"color:{(isToday ? "#FF6F00" : isPast ? "#90A4AE" : "#00897B")}; font-weight:700")">
|
||||
@h.Date.ToString("dd. MMM", _deCulture)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
|
||||
@if (isToday)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
|
||||
Style="height:20px; font-size:10px; font-weight:700; background:#FF6F00; color:white;">
|
||||
HEUTE
|
||||
</MudChip>
|
||||
}
|
||||
else if (!isPast)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
|
||||
Style="@($"height:20px; font-size:10px; color:#00897B; border-color:#00897B;")">
|
||||
@(daysLeft == 1 ? "morgen" : $"in {daysLeft} Tagen")
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
|
||||
Style="height:20px; font-size:10px; color:#90A4AE; border-color:#CFD8DC;">
|
||||
vergangen
|
||||
</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@* Name *@
|
||||
<MudText Typo="Typo.h6"
|
||||
Style="@($"font-weight:700; line-height:1.3; color:{(isPast && !isToday ? "#90A4AE" : "inherit")}")">
|
||||
@h.Name
|
||||
</MudText>
|
||||
|
||||
@* Wochentag *@
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@h.Date.ToString("dddd, dd. MMMM yyyy", _deCulture)
|
||||
</MudText>
|
||||
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@* ── Zusammenfassung ── *@
|
||||
<MudPaper Elevation="2" Class="pa-4 rounded-xl"
|
||||
Style="background: linear-gradient(90deg, rgba(0,137,123,0.08) 0%, transparent 100%); border-left: 4px solid #00897B;">
|
||||
<MudStack Row="true" Spacing="4" Wrap="Wrap.Wrap" AlignItems="AlignItems.Center">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Celebration" Style="color:#00897B" />
|
||||
<MudText Typo="Typo.body2" Style="font-weight:600">@_holidays.Count Feiertage gesamt</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Style="color:#4CAF50" />
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
@_holidays.Count(h => h.Date < DateOnly.FromDateTime(DateTime.Today)) vergangen
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Schedule" Style="color:#FF9800" />
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
@_holidays.Count(h => h.Date >= DateOnly.FromDateTime(DateTime.Today)) noch ausstehend
|
||||
</MudText>
|
||||
</MudStack>
|
||||
@{
|
||||
var next = _holidays
|
||||
.Where(h => h.Date > DateOnly.FromDateTime(DateTime.Today))
|
||||
.OrderBy(h => h.Date)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
@if (next != null)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.NavigateNext" Style="color:#00897B" />
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Nächster: <b>@next.Name</b> (@next.Date.ToString("dd. MMM", _deCulture))
|
||||
</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
|
||||
|
||||
private bool _loading = true;
|
||||
private int _year = DateTime.Today.Year;
|
||||
private List<PublicHoliday> _holidays = [];
|
||||
private string _subLabel = "";
|
||||
|
||||
private int _userId;
|
||||
private AppSettings _settings = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var claim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||||
if (claim != null)
|
||||
{
|
||||
_userId = int.Parse(claim.Value);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
}
|
||||
await LoadHolidays();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadHolidays()
|
||||
{
|
||||
_holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
var count = _holidays.Count;
|
||||
var past = _holidays.Count(h => h.Date < DateOnly.FromDateTime(DateTime.Today));
|
||||
_subLabel = count == 0
|
||||
? "Keine Daten gespeichert"
|
||||
: $"{count} Feiertage · {past} vergangen · {count - past} ausstehend";
|
||||
}
|
||||
|
||||
private async Task PrevYear() { _year--; await LoadHolidays(); }
|
||||
private async Task NextYear() { _year++; await LoadHolidays(); }
|
||||
private async Task GoToCurrentYear(){ _year = DateTime.Today.Year; await LoadHolidays(); }
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>KW @_kw – Wochenübersicht – Timetracker</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Wochendaten…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Wochen-Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
@_weekLabel
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
@_weekSubLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
@if (!IsCurrentWeek)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
OnClick="GoToCurrentWeek" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Tageskarten ── *@
|
||||
@foreach (var day in _days)
|
||||
{
|
||||
var isToday = day.Date == DateOnly.FromDateTime(DateTime.Today);
|
||||
var isWorkDay = _settings.IsWorkDay(day.Date.DayOfWeek);
|
||||
var hasData = day.Start.HasValue || day.End.HasValue;
|
||||
var overtime = GetOvertime(day);
|
||||
var errors = GetDayErrors(day).ToList();
|
||||
var borderColor = GetBorderColor(day, isWorkDay, errors);
|
||||
var progressPct = GetProgressPercent(day);
|
||||
var holidayName = _holidays.GetValueOrDefault(day.Date);
|
||||
|
||||
if (!isWorkDay)
|
||||
{
|
||||
@* ── Nicht-Arbeitstag: kompakt ── *@
|
||||
<MudPaper @key="@day.Date" Elevation="1" Class="pa-3 rounded-lg"
|
||||
Style="@($"border-left: 4px solid {(!string.IsNullOrEmpty(holidayName) ? "#009688" : "#CFD8DC")}; background:{(!string.IsNullOrEmpty(holidayName) ? "rgba(0,150,136,0.05)" : "#FAFAFA")};")">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body1" Style="color:#90A4AE; font-weight:500">
|
||||
@day.Date.ToString("dddd", _deCulture)
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:#B0BEC5">
|
||||
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
@if (!string.IsNullOrEmpty(holidayName))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
|
||||
Style="background:#009688; color:white; font-weight:600">
|
||||
@holidayName
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
|
||||
Style="color:#90A4AE; border-color:#CFD8DC;">
|
||||
Kein Arbeitstag
|
||||
</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Arbeitstag: vollständige Karte ── *@
|
||||
<MudCard @key="@day.Date" Elevation="@(isToday ? 6 : 2)" Class="rounded-xl"
|
||||
Style="@($"border-left: 4px solid {borderColor};")">
|
||||
<MudCardHeader Style="@(isToday ? "background: linear-gradient(90deg, rgba(63,81,181,0.07) 0%, transparent 100%);" : "")">
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudStack Spacing="0">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">
|
||||
@day.Date.ToString("dddd", _deCulture)
|
||||
</MudText>
|
||||
@if (isToday)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Primary"
|
||||
Variant="Variant.Filled"
|
||||
Style="height:20px; font-size:10px; font-weight:700">
|
||||
HEUTE
|
||||
</MudChip>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(holidayName))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
|
||||
Style="height:20px; font-size:10px; font-weight:700; background:#009688; color:white;">
|
||||
@holidayName
|
||||
</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@day.Date.ToString("dd. MMMM yyyy", _deCulture)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
|
||||
@if (overtime.HasValue)
|
||||
{
|
||||
<MudChip T="string"
|
||||
Color="@(overtime.Value > TimeSpan.Zero ? Color.Success : overtime.Value < TimeSpan.Zero ? Color.Warning : Color.Info)"
|
||||
Variant="Variant.Filled" Size="Size.Medium">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@(overtime.Value >= TimeSpan.Zero ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
|
||||
Size="Size.Small" />
|
||||
<MudText Typo="Typo.body2"><b>@FormatTs(overtime.Value, sign: true)</b></MudText>
|
||||
</MudStack>
|
||||
</MudChip>
|
||||
}
|
||||
else if (!hasData)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Outlined" Size="Size.Small">
|
||||
Nicht erfasst
|
||||
</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
|
||||
<MudCardContent Class="pt-2">
|
||||
@* ── Zeit-Eingaben + Statistik ── *@
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="6" sm="3" md="2">
|
||||
<MudTimePicker Label="Beginn"
|
||||
Time="@day.Start"
|
||||
TimeChanged="@(async v => await OnStartChanged(day, v))"
|
||||
AmPm="false" Variant="Variant.Outlined"
|
||||
Clearable="true" PickerVariant="PickerVariant.Dialog"
|
||||
Error="@(day.End.HasValue && day.Start.HasValue && day.Start >= day.End)" />
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3" md="2">
|
||||
<MudTimePicker Label="Ende"
|
||||
Time="@day.End"
|
||||
TimeChanged="@(async v => await OnEndChanged(day, v))"
|
||||
AmPm="false" Variant="Variant.Outlined"
|
||||
Clearable="true" PickerVariant="PickerVariant.Dialog"
|
||||
Error="@(day.End.HasValue && day.Start.HasValue && day.Start >= day.End)" />
|
||||
</MudItem>
|
||||
|
||||
@if (day.GrossWork.HasValue)
|
||||
{
|
||||
<MudItem xs="12" md="8">
|
||||
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center" Wrap="Wrap.Wrap" Class="h-100 pl-1">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Brutto</MudText>
|
||||
<MudText Typo="Typo.body1">@FormatTs(day.GrossWork.Value)</MudText>
|
||||
</MudStack>
|
||||
<MudText Color="Color.Secondary" Class="mb-1">−</MudText>
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Pausen</MudText>
|
||||
<MudText Typo="Typo.body1">@FormatTs(day.TotalBreakTime)</MudText>
|
||||
</MudStack>
|
||||
<MudText Color="Color.Secondary" Class="mb-1">=</MudText>
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Netto</MudText>
|
||||
<MudText Typo="Typo.body1" Color="Color.Primary" Style="font-weight:700">
|
||||
@FormatTs(day.NetWork!.Value)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudDivider Vertical="true" FlexItem="true" Style="height:36px" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Soll</MudText>
|
||||
<MudText Typo="Typo.body1">@FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@* ── Fortschrittsbalken ── *@
|
||||
@if (progressPct >= 0)
|
||||
{
|
||||
<MudTooltip Text="@($"Netto {FormatTs(day.NetWork ?? TimeSpan.Zero)} von {FormatTs(TimeSpan.FromHours(_settings.DailyTargetHours))} Soll")">
|
||||
<MudProgressLinear Value="@Math.Min(progressPct, 100)"
|
||||
Color="@(progressPct >= 100 ? Color.Success : Color.Primary)"
|
||||
Rounded="true" Class="mt-3" Style="height:6px" />
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@* ── Pausen-Sektion ── *@
|
||||
<MudDivider Class="mt-3 mb-2" />
|
||||
<MudStack Spacing="2">
|
||||
@for (int i = 0; i < day.Breaks.Count; i++)
|
||||
{
|
||||
var brk = day.Breaks[i];
|
||||
var idx = i;
|
||||
var brkError = (day.Start.HasValue && brk.Start.HasValue && brk.Start < day.Start)
|
||||
|| (day.End.HasValue && brk.End.HasValue && brk.End > day.End);
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Wrap="Wrap.Wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.FreeBreakfast"
|
||||
Size="Size.Small" Color="Color.Secondary" Style="opacity:0.6" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width:55px">
|
||||
Pause @(idx + 1)
|
||||
</MudText>
|
||||
<MudTimePicker Time="@brk.Start"
|
||||
TimeChanged="@(async v => await OnBreakStartChanged(day, idx, v))"
|
||||
AmPm="false" Variant="Variant.Outlined"
|
||||
Style="width:300px" Clearable="true"
|
||||
PickerVariant="PickerVariant.Inline"
|
||||
Error="@brkError" />
|
||||
<MudText Color="Color.Secondary">–</MudText>
|
||||
<MudTimePicker Time="@brk.End"
|
||||
TimeChanged="@(async v => await OnBreakEndChanged(day, idx, v))"
|
||||
AmPm="false" Variant="Variant.Outlined"
|
||||
Style="width:300px" Clearable="true"
|
||||
PickerVariant="PickerVariant.Inline"
|
||||
Error="@brkError" />
|
||||
@if (brk.Duration.HasValue)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Secondary">
|
||||
@FormatTs(brk.Duration.Value)
|
||||
</MudChip>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.RemoveCircleOutline"
|
||||
Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(async () => await RemoveBreak(day, idx))" />
|
||||
</MudStack>
|
||||
}
|
||||
<MudButton StartIcon="@Icons.Material.Filled.AddCircleOutline"
|
||||
Variant="Variant.Text" Color="Color.Primary"
|
||||
Size="Size.Small" OnClick="@(() => AddBreak(day))">
|
||||
Pause hinzufügen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@* ── Validierungsfehler ── *@
|
||||
@if (errors.Count > 0)
|
||||
{
|
||||
<MudDivider Class="mt-2 mb-1" />
|
||||
@foreach (var err in errors)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-1">@err</MudAlert>
|
||||
}
|
||||
}
|
||||
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
}
|
||||
|
||||
@* ── Wochensumme ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #1A237E 0%, #283593 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
|
||||
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
|
||||
</MudStack>
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
|
||||
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
|
||||
@FormatTs(WeekOvertime, sign: true)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Gleitzeitkonto ── *@
|
||||
<MudPaper Elevation="3" Class="pa-5 rounded-xl"
|
||||
Style="@($"border-left: 6px solid {(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap" Spacing="3">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountBalance" Color="Color.Primary" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Gleitzeitkonto</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamtsaldo aller erfassten Arbeitstage</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@(_totalOvertime >= TimeSpan.Zero ? Icons.Material.Filled.TrendingUp : Icons.Material.Filled.TrendingDown)"
|
||||
Style="@($"color:{(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")}; font-size:2rem")" />
|
||||
<MudText Typo="Typo.h4"
|
||||
Style="@($"font-weight:700; color:{(_totalOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")};")">
|
||||
@FormatTs(_totalOvertime, sign: true)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
|
||||
|
||||
private bool _loading = true;
|
||||
private int _userId;
|
||||
private DateOnly _monday;
|
||||
private List<DayVm> _days = [];
|
||||
private AppSettings _settings = new();
|
||||
private string _weekLabel = "";
|
||||
private string _weekSubLabel = "";
|
||||
private int _kw => _monday == default ? 0 : _deCulture.Calendar.GetWeekOfYear(
|
||||
_monday.ToDateTime(TimeOnly.MinValue),
|
||||
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
|
||||
DayOfWeek.Monday);
|
||||
private TimeSpan _totalOvertime;
|
||||
private Dictionary<DateOnly, string> _holidays = [];
|
||||
private int _holidayYear = -1;
|
||||
|
||||
private bool IsCurrentWeek => _monday == GetMonday(DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim == null) return; // Prerender-Pass – Circuit noch nicht authentifiziert
|
||||
_userId = int.Parse(claim.Value);
|
||||
_monday = GetMonday(DateOnly.FromDateTime(DateTime.Today));
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
await LoadWeek();
|
||||
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadWeek()
|
||||
{
|
||||
if (_monday.Year != _holidayYear)
|
||||
{
|
||||
var list = await HolidayService.GetHolidaysAsync(_monday.Year, _settings.GermanState);
|
||||
_holidays = list.ToDictionary(h => h.Date, h => h.Name);
|
||||
_holidayYear = _monday.Year;
|
||||
}
|
||||
var dbDays = await TrackerService.GetWeekAsync(_userId, _monday);
|
||||
_days = Enumerable.Range(0, 7).Select(i =>
|
||||
{
|
||||
var date = _monday.AddDays(i);
|
||||
return DayVm.From(dbDays.FirstOrDefault(d => d.Date == date), date, _userId);
|
||||
}).ToList();
|
||||
BuildWeekLabels();
|
||||
}
|
||||
|
||||
private void BuildWeekLabels()
|
||||
{
|
||||
var sunday = _monday.AddDays(6);
|
||||
var kw = _deCulture.Calendar.GetWeekOfYear(
|
||||
_monday.ToDateTime(TimeOnly.MinValue),
|
||||
System.Globalization.CalendarWeekRule.FirstFourDayWeek,
|
||||
DayOfWeek.Monday);
|
||||
_weekLabel = $"KW {kw} · {_monday:dd. MMM} – {sunday:dd. MMM yyyy}";
|
||||
var recorded = _days.Count(d => d.Start.HasValue || d.End.HasValue);
|
||||
var workDays = _days.Count(d => _settings.IsWorkDay(d.Date.DayOfWeek));
|
||||
_weekSubLabel = recorded == 0
|
||||
? "Noch keine Einträge diese Woche"
|
||||
: $"{recorded} von {workDays} Arbeitstagen erfasst";
|
||||
}
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────
|
||||
private async Task PrevWeek() { _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 static DateOnly GetMonday(DateOnly date)
|
||||
{
|
||||
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
|
||||
return date.AddDays(-diff);
|
||||
}
|
||||
|
||||
// ── Change Handlers ──────────────────────────────────────────
|
||||
private async Task OnStartChanged(DayVm day, TimeSpan? v) { day.Start = v; await SaveDay(day); }
|
||||
private async Task OnEndChanged(DayVm day, TimeSpan? v) { day.End = v; await SaveDay(day); }
|
||||
|
||||
private async Task OnBreakStartChanged(DayVm day, int idx, TimeSpan? v)
|
||||
{ day.Breaks[idx].Start = v; await SaveDay(day); }
|
||||
|
||||
private async Task OnBreakEndChanged(DayVm day, int idx, TimeSpan? v)
|
||||
{ day.Breaks[idx].End = v; await SaveDay(day); }
|
||||
|
||||
private void AddBreak(DayVm day)
|
||||
{
|
||||
day.Breaks.Add(new BreakVm());
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RemoveBreak(DayVm day, int idx)
|
||||
{ day.Breaks.RemoveAt(idx); await SaveDay(day); }
|
||||
|
||||
private async Task SaveDay(DayVm day)
|
||||
{
|
||||
await TrackerService.UpsertWorkDayAsync(day.ToWorkDay());
|
||||
_totalOvertime = await TrackerService.GetTotalOvertimeAsync(_userId, _settings);
|
||||
BuildWeekLabels();
|
||||
}
|
||||
|
||||
// ── Berechnungen ─────────────────────────────────────────────
|
||||
private TimeSpan? GetOvertime(DayVm day) =>
|
||||
day.NetWork.HasValue ? day.NetWork.Value - TimeSpan.FromHours(_settings.DailyTargetHours) : null;
|
||||
|
||||
private int GetProgressPercent(DayVm day)
|
||||
{
|
||||
if (!day.NetWork.HasValue || _settings.DailyTargetHours <= 0) return -1;
|
||||
return (int)(day.NetWork.Value.TotalHours / _settings.DailyTargetHours * 100);
|
||||
}
|
||||
|
||||
private static string GetBorderColor(DayVm day, bool isWorkDay, List<string> errors)
|
||||
{
|
||||
if (!isWorkDay) return "#CFD8DC";
|
||||
if (errors.Count > 0) return "#EF5350";
|
||||
if (!day.Start.HasValue && !day.End.HasValue) return "#B0BEC5";
|
||||
if (!day.NetWork.HasValue) return "#B0BEC5";
|
||||
return "#4CAF50";
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetDayErrors(DayVm day)
|
||||
{
|
||||
if (day.Start.HasValue && day.End.HasValue && day.Start >= day.End)
|
||||
yield return "Endzeit muss nach Beginn liegen.";
|
||||
|
||||
for (int i = 0; i < day.Breaks.Count; i++)
|
||||
{
|
||||
var b = day.Breaks[i];
|
||||
if (b.Start.HasValue && b.End.HasValue && b.Start >= b.End)
|
||||
yield return $"Pause {i + 1}: Ende muss nach Start liegen.";
|
||||
if (day.Start.HasValue && b.Start.HasValue && b.Start < day.Start)
|
||||
yield return $"Pause {i + 1} beginnt vor Arbeitsbeginn.";
|
||||
if (day.End.HasValue && b.End.HasValue && b.End > day.End)
|
||||
yield return $"Pause {i + 1} endet nach Arbeitsende.";
|
||||
|
||||
for (int j = i + 1; j < day.Breaks.Count; j++)
|
||||
{
|
||||
var c = day.Breaks[j];
|
||||
if (b.Start.HasValue && b.End.HasValue && c.Start.HasValue && c.End.HasValue
|
||||
&& b.Start < c.End && c.Start < b.End)
|
||||
yield return $"Pause {i + 1} und Pause {j + 1} überschneiden sich.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan WeekGross => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.GrossWork ?? TimeSpan.Zero));
|
||||
private TimeSpan WeekBreaks => _days.Aggregate(TimeSpan.Zero, (s, d) => s + d.TotalBreakTime);
|
||||
private TimeSpan WeekNet => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (d.NetWork ?? TimeSpan.Zero));
|
||||
private TimeSpan WeekOvertime => _days.Aggregate(TimeSpan.Zero, (s, d) => s + (GetOvertime(d) ?? TimeSpan.Zero));
|
||||
|
||||
private static string FormatTs(TimeSpan ts, bool sign = false)
|
||||
{
|
||||
if (ts == TimeSpan.Zero && sign) return "±0:00";
|
||||
var prefix = sign ? (ts >= TimeSpan.Zero ? "+" : "−") : (ts < TimeSpan.Zero ? "−" : "");
|
||||
var abs = ts.Duration();
|
||||
return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}";
|
||||
}
|
||||
|
||||
// ── ViewModels ────────────────────────────────────────────────
|
||||
private sealed class DayVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public TimeSpan? Start { get; set; }
|
||||
public TimeSpan? End { get; set; }
|
||||
public List<BreakVm> Breaks { get; set; } = [];
|
||||
|
||||
public TimeSpan? GrossWork =>
|
||||
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
|
||||
|
||||
public TimeSpan TotalBreakTime => Breaks
|
||||
.Where(b => b.Duration.HasValue)
|
||||
.Aggregate(TimeSpan.Zero, (s, b) => s + b.Duration!.Value);
|
||||
|
||||
public TimeSpan? NetWork => GrossWork.HasValue ? GrossWork.Value - TotalBreakTime : null;
|
||||
|
||||
public static DayVm From(WorkDay? wd, DateOnly date, int userId) => new()
|
||||
{
|
||||
Id = wd?.Id ?? 0,
|
||||
UserId = wd?.UserId ?? userId,
|
||||
Date = date,
|
||||
Start = wd?.StartTime?.ToTimeSpan(),
|
||||
End = wd?.EndTime?.ToTimeSpan(),
|
||||
Breaks = wd?.Breaks.Select(b => new BreakVm
|
||||
{
|
||||
Id = b.Id,
|
||||
Start = b.StartTime?.ToTimeSpan(),
|
||||
End = b.EndTime?.ToTimeSpan()
|
||||
}).ToList() ?? []
|
||||
};
|
||||
|
||||
public WorkDay ToWorkDay() => new()
|
||||
{
|
||||
Id = Id,
|
||||
UserId = UserId,
|
||||
Date = Date,
|
||||
StartTime = Start.HasValue ? TimeOnly.FromTimeSpan(Start.Value) : null,
|
||||
EndTime = End.HasValue ? TimeOnly.FromTimeSpan(End.Value) : null,
|
||||
Breaks = Breaks.Select(b => new BreakEntry
|
||||
{
|
||||
Id = b.Id,
|
||||
StartTime = b.Start.HasValue ? TimeOnly.FromTimeSpan(b.Start.Value) : null,
|
||||
EndTime = b.End.HasValue ? TimeOnly.FromTimeSpan(b.End.Value) : null
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class BreakVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public TimeSpan? Start { get; set; }
|
||||
public TimeSpan? End { get; set; }
|
||||
public TimeSpan? Duration =>
|
||||
Start.HasValue && End.HasValue && End > Start ? End.Value - Start.Value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
@page "/login"
|
||||
@attribute [AllowAnonymous]
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>Anmelden – Timetracker</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
|
||||
@* ── Logo / Header ── *@
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
|
||||
Style="font-size:4rem; color:#1565C0" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#1565C0">Timetracker</MudText>
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
|
||||
@* ── Static Tab Navigation ── *@
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
|
||||
<MudButton Href="/login"
|
||||
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Anmelden
|
||||
</MudButton>
|
||||
<MudButton Href="/login?tab=register"
|
||||
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Registrieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudDivider Class="mb-6" />
|
||||
|
||||
@if (_activeTab == 0)
|
||||
{
|
||||
@* ── Login Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<form action="/auth/login" method="post">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
name="username"
|
||||
Required="true"
|
||||
AutoFocus="true" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
name="password"
|
||||
Required="true" />
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.Login"
|
||||
Class="mt-2">
|
||||
Anmelden
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</form>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<form action="/auth/register" method="post">
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
name="username"
|
||||
Required="true"
|
||||
HelperText="Mindestens 3 Zeichen" />
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
name="password"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
||||
Class="mt-2">
|
||||
Konto erstellen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</form>
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private int _activeTab = 0;
|
||||
private string? _error;
|
||||
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorParam { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "tab")]
|
||||
public string? TabParam { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_error = ErrorParam switch
|
||||
{
|
||||
"invalid" => "Benutzername oder Passwort falsch.",
|
||||
not null => Uri.UnescapeDataString(ErrorParam),
|
||||
_ => null
|
||||
};
|
||||
_activeTab = TabParam == "register" ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
@page "/month"
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year – Monatsübersicht – Timetracker</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Monatsdaten…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Monats-Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
@_subLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
@if (!IsCurrentMonth)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
OnClick="GoToCurrentMonth" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Monatstabelle ── *@
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardContent Class="pa-0">
|
||||
<MudSimpleTable Dense="true" Striped="false" Hover="true" Style="overflow-x:auto">
|
||||
<thead>
|
||||
<tr style="background: rgba(63,81,181,0.08);">
|
||||
<th style="font-weight:700; padding:10px 16px">Tag</th>
|
||||
<th style="font-weight:700; padding:10px 16px">Start</th>
|
||||
<th style="font-weight:700; padding:10px 16px">Ende</th>
|
||||
<th style="font-weight:700; padding:10px 16px">Netto</th>
|
||||
<th style="font-weight:700; padding:10px 16px">Gleitzeit</th>
|
||||
<th style="font-weight:700; padding:10px 16px">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var d in _days)
|
||||
{
|
||||
<tr style="@GetRowStyle(d)">
|
||||
<td style="padding:8px 16px; white-space:nowrap">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body2" Style="@($"font-weight:{(d.IsToday ? "700" : "500")}")">
|
||||
@d.Date.ToString("ddd, dd. MMM", _deCulture)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</td>
|
||||
<td style="padding:8px 16px">
|
||||
@(d.StartTime.HasValue ? d.StartTime.Value.ToString(@"HH\:mm") : "—")
|
||||
</td>
|
||||
<td style="padding:8px 16px">
|
||||
@(d.EndTime.HasValue ? d.EndTime.Value.ToString(@"HH\:mm") : "—")
|
||||
</td>
|
||||
<td style="padding:8px 16px">
|
||||
@(d.Net.HasValue ? FormatTs(d.Net.Value) : "—")
|
||||
</td>
|
||||
<td style="padding:8px 16px; color:@(d.Overtime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800"); font-weight:600">
|
||||
@(d.Net.HasValue ? FormatTs(d.Overtime, sign: true) : "—")
|
||||
</td>
|
||||
<td style="padding:8px 16px">
|
||||
@GetStatusChip(d)
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@* ── Monatszusammenfassung ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, rgba(63,81,181,0.08) 0%, rgba(26,35,126,0.04) 100%); border-left: 6px solid #3F51B5;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
|
||||
</MudStack>
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="6" sm="4" md="2">
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Color="Color.Primary" Style="font-weight:700">
|
||||
@FormatTs(_monthNet)
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Netto gesamt</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="4" md="2">
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5"
|
||||
Style="@($"font-weight:700; color:{(_monthOvertime >= TimeSpan.Zero ? "#4CAF50" : "#FF9800")}")">
|
||||
@FormatTs(_monthOvertime, sign: true)
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Gleitzeit</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="4" md="2">
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Color="Color.Default" Style="font-weight:700">
|
||||
@_recordedWorkDays / @_totalWorkDays
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Arbeitstage</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="4" md="2">
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">
|
||||
@_vacationCount
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Urlaubstage</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="4" md="2">
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Color="Color.Tertiary" Style="font-weight:700">
|
||||
@_holidayCount
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Feiertage</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
|
||||
|
||||
private bool _loading = true;
|
||||
private int _userId;
|
||||
private int _year = DateTime.Today.Year;
|
||||
private int _month = DateTime.Today.Month;
|
||||
private List<MonthDayVm> _days = [];
|
||||
private AppSettings _settings = new();
|
||||
|
||||
private string _subLabel = "";
|
||||
private TimeSpan _monthNet;
|
||||
private TimeSpan _monthOvertime;
|
||||
private int _recordedWorkDays;
|
||||
private int _totalWorkDays;
|
||||
private int _vacationCount;
|
||||
private int _holidayCount;
|
||||
|
||||
private bool IsCurrentMonth => _year == DateTime.Today.Year && _month == DateTime.Today.Month;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim == null) return;
|
||||
_userId = int.Parse(claim.Value);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
await LoadMonth();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadMonth()
|
||||
{
|
||||
var workDays = await TrackerService.GetMonthAsync(_userId, _year, _month);
|
||||
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year);
|
||||
|
||||
var holidayMap = holidays.ToDictionary(h => h.Date, h => h.Name);
|
||||
var vacationSet = vacations.Select(v => v.Date).ToHashSet();
|
||||
|
||||
var daysInMonth = DateTime.DaysInMonth(_year, _month);
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
_days = Enumerable.Range(1, daysInMonth).Select(day =>
|
||||
{
|
||||
var date = new DateOnly(_year, _month, day);
|
||||
var wd = workDays.FirstOrDefault(w => w.Date == date);
|
||||
var isWorkDay = _settings.IsWorkDay(date.DayOfWeek);
|
||||
|
||||
TimeSpan? net = null;
|
||||
if (wd?.StartTime != null && wd.EndTime != null)
|
||||
{
|
||||
var gross = wd.EndTime.Value.ToTimeSpan() - wd.StartTime.Value.ToTimeSpan();
|
||||
var breaks = 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()));
|
||||
net = gross - breaks;
|
||||
if (net < TimeSpan.Zero) net = null;
|
||||
}
|
||||
|
||||
var target = isWorkDay ? TimeSpan.FromHours(_settings.DailyTargetHours) : TimeSpan.Zero;
|
||||
var overtime = net.HasValue ? net.Value - target : TimeSpan.Zero;
|
||||
|
||||
return new MonthDayVm
|
||||
{
|
||||
Date = date,
|
||||
StartTime = wd?.StartTime,
|
||||
EndTime = wd?.EndTime,
|
||||
Net = net,
|
||||
Overtime = overtime,
|
||||
HolidayName = holidayMap.GetValueOrDefault(date),
|
||||
IsVacation = vacationSet.Contains(date),
|
||||
IsWorkDay = isWorkDay,
|
||||
IsToday = date == today
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
// Summaries
|
||||
_monthNet = _days.Where(d => d.Net.HasValue).Aggregate(TimeSpan.Zero, (s, d) => s + d.Net!.Value);
|
||||
_monthOvertime = _days
|
||||
.Where(d => d.IsWorkDay && d.Net.HasValue)
|
||||
.Aggregate(TimeSpan.Zero, (s, d) => s + d.Overtime);
|
||||
_recordedWorkDays = _days.Count(d => d.IsWorkDay && d.Net.HasValue);
|
||||
_totalWorkDays = _days.Count(d => d.IsWorkDay && string.IsNullOrEmpty(d.HolidayName) && !d.IsVacation);
|
||||
_vacationCount = _days.Count(d => d.IsVacation);
|
||||
_holidayCount = _days.Count(d => !string.IsNullOrEmpty(d.HolidayName));
|
||||
|
||||
var recorded = _recordedWorkDays;
|
||||
var total = _totalWorkDays;
|
||||
_subLabel = recorded == 0 ? "Noch keine Einträge diesen Monat" : $"{recorded} von {total} Arbeitstagen erfasst";
|
||||
}
|
||||
|
||||
private async Task PrevMonth()
|
||||
{
|
||||
var d = new DateOnly(_year, _month, 1).AddMonths(-1);
|
||||
_year = d.Year; _month = d.Month;
|
||||
await LoadMonth();
|
||||
}
|
||||
|
||||
private async Task NextMonth()
|
||||
{
|
||||
var d = new DateOnly(_year, _month, 1).AddMonths(1);
|
||||
_year = d.Year; _month = d.Month;
|
||||
await LoadMonth();
|
||||
}
|
||||
|
||||
private async Task GoToCurrentMonth()
|
||||
{
|
||||
_year = DateTime.Today.Year; _month = DateTime.Today.Month;
|
||||
await LoadMonth();
|
||||
}
|
||||
|
||||
private static string FormatTs(TimeSpan ts, bool sign = false)
|
||||
{
|
||||
var neg = ts < TimeSpan.Zero;
|
||||
var abs = neg ? ts.Negate() : ts;
|
||||
var h = (int)abs.TotalHours;
|
||||
var m = abs.Minutes;
|
||||
if (!sign) return $"{h}:{m:D2} h";
|
||||
return neg ? $"–{h}:{m:D2} h" : $"+{h}:{m:D2} h";
|
||||
}
|
||||
|
||||
private string GetRowStyle(MonthDayVm d)
|
||||
{
|
||||
if (d.IsToday) return "background: rgba(63,81,181,0.10);";
|
||||
if (!string.IsNullOrEmpty(d.HolidayName)) return "background: rgba(0,150,136,0.07);";
|
||||
if (d.IsVacation) return "background: rgba(255,152,0,0.08);";
|
||||
if (!d.IsWorkDay) return "background: rgba(0,0,0,0.03); color: #90A4AE;";
|
||||
if (d.Net.HasValue) return "background: rgba(76,175,80,0.06);";
|
||||
return "";
|
||||
}
|
||||
|
||||
private RenderFragment GetStatusChip(MonthDayVm d) => builder =>
|
||||
{
|
||||
void Chip(string text, string color)
|
||||
{
|
||||
builder.OpenElement(0, "span");
|
||||
builder.AddAttribute(1, "style",
|
||||
$"display:inline-block; padding:2px 10px; border-radius:12px; font-size:0.75rem; font-weight:600; background:{color}20; color:{color}; border:1px solid {color}60;");
|
||||
builder.AddContent(2, text);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
if (d.IsToday && !d.Net.HasValue && d.IsWorkDay) Chip("Heute", "#3F51B5");
|
||||
else if (!string.IsNullOrEmpty(d.HolidayName)) Chip(d.HolidayName, "#009688");
|
||||
else if (d.IsVacation) Chip("Urlaub", "#FF9800");
|
||||
else if (!d.IsWorkDay) Chip("Frei", "#90A4AE");
|
||||
else if (d.Net.HasValue) Chip("Erfasst", "#4CAF50");
|
||||
else Chip("Ausstehend", "#CFD8DC");
|
||||
};
|
||||
|
||||
private sealed class MonthDayVm
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
public TimeSpan? Net { get; set; }
|
||||
public TimeSpan Overtime { get; set; }
|
||||
public string? HolidayName { get; set; }
|
||||
public bool IsVacation { get; set; }
|
||||
public bool IsWorkDay { get; set; }
|
||||
public bool IsToday { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -0,0 +1,583 @@
|
||||
@page "/settings"
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>Einstellungen – Timetracker</PageTitle>
|
||||
|
||||
@if (_settings == null)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #3F51B5 0%, #1A237E 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudGrid Spacing="4">
|
||||
|
||||
@* ── Arbeitszeit ── *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Schedule" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Arbeitszeit</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="4">
|
||||
<MudNumericField @bind-Value="_settings.DailyTargetHours"
|
||||
Label="Sollstunden pro Tag (h)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0.5" Max="24.0" Step="0.25"
|
||||
Format="0.##"
|
||||
HelperText="Vertraglich vereinbarte Nettoarbeitszeit" />
|
||||
|
||||
<MudNumericField @bind-Value="_settings.MinimumBreakMinutes"
|
||||
Label="Gesetzliche Mindestpause (min)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0" Max="120" Step="5"
|
||||
HelperText="Pflichtpause laut Arbeitszeitgesetz" />
|
||||
|
||||
<MudPaper Elevation="0" Class="pa-3 rounded-lg"
|
||||
Style="background: var(--mud-palette-background-grey);">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary" Class="mb-2">
|
||||
Tagesberechnung
|
||||
</MudText>
|
||||
<MudStack Spacing="1">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Netto (Soll)</MudText>
|
||||
<MudText Typo="Typo.body2"><b>@FormatHours(_settings.DailyTargetHours)</b></MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">+ Mindestpause</MudText>
|
||||
<MudText Typo="Typo.body2"><b>@_settings.MinimumBreakMinutes min</b></MudText>
|
||||
</MudStack>
|
||||
<MudDivider />
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">= Brutto-Anwesenheit</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Primary">
|
||||
<b>@FormatHours(_settings.DailyTargetHours + _settings.MinimumBreakMinutes / 60.0)</b>
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Arbeitstage ── *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CalendarToday" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Arbeitstage</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-3">
|
||||
Wähle die Wochentage, an denen du arbeitest.
|
||||
</MudText>
|
||||
<MudStack Spacing="2">
|
||||
@foreach (var (label, getter, setter) in WorkDayToggles)
|
||||
{
|
||||
var isChecked = getter(_settings);
|
||||
<MudPaper Elevation="0" Class="pa-2 rounded-lg"
|
||||
Style="@($"background: {(isChecked ? "rgba(63,81,181,0.08)" : "var(--mud-palette-background-grey)")};")">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Circle"
|
||||
Style="@($"font-size:10px; color:{(isChecked ? "#3F51B5" : "#CFD8DC")}")" />
|
||||
<MudText Typo="Typo.body1" Style="@(isChecked ? "font-weight:600" : "")">
|
||||
@label
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudSwitch Value="@isChecked"
|
||||
ValueChanged="@((bool v) => setter(_settings, v))"
|
||||
Color="Color.Primary" />
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Region & Feiertage ── *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Public" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Region & Feiertage</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="4">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Wähle dein Bundesland aus, um Bundesland-spezifische Feiertage zu berücksichtigen.
|
||||
</MudText>
|
||||
<MudSelect T="string" Label="Bundesland" Variant="Variant.Outlined" @bind-Value="_settings.GermanState" Clearable="true" Placeholder="Nur bundesweite Feiertage">
|
||||
@foreach (var state in GermanStates)
|
||||
{
|
||||
<MudSelectItem Value="@state.Key">@state.Value</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* ── Gleitzeitkonto ── *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountBalance" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Gleitzeitkonto-Start</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="4">
|
||||
<MudDatePicker Label="Berechnungsstart"
|
||||
@bind-Date="_flexStartDate"
|
||||
HelperText="Gleitzeitberechnung läuft ab diesem Datum. Wenn leer, ab dem ersten Arbeitseintrag."
|
||||
Variant="Variant.Outlined"
|
||||
Clearable="true"
|
||||
DateFormat="dd.MM.yyyy"
|
||||
PickerVariant="PickerVariant.Inline" />
|
||||
|
||||
<MudNumericField @bind-Value="_settings.FlexTimeStartingBalanceHours"
|
||||
Label="Anfangsüberstunden (h)"
|
||||
Variant="Variant.Outlined"
|
||||
Step="0.5"
|
||||
Format="0.##"
|
||||
HelperText="Stufensaldo (Guthaben/Schulden) zum Berechnungsstart" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
|
||||
@* ── Speichern-Button ── *@
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
OnClick="Save" StartIcon="@Icons.Material.Filled.Save"
|
||||
Size="Size.Large" Style="max-width:300px">
|
||||
Einstellungen speichern
|
||||
</MudButton>
|
||||
|
||||
<MudDivider />
|
||||
|
||||
@* ── Urlaubsverwaltung ── *@
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardHeader Style="background: linear-gradient(90deg, rgba(0,150,136,0.1) 0%, transparent 100%);">
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.BeachAccess" Color="Color.Secondary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Urlaubsverwaltung</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Size="Size.Small" OnClick="@(() => ChangeYear(-1))" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700; min-width:50px; text-align:center">
|
||||
@_vacYear
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Size="Size.Small" OnClick="@(() => ChangeYear(1))" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudGrid Spacing="4">
|
||||
|
||||
@* ── Urlaubskontingent ── *@
|
||||
<MudItem xs="12" md="5">
|
||||
<MudStack Spacing="3">
|
||||
<MudNumericField @bind-Value="_settings.VacationDaysPerYear"
|
||||
Label="Urlaubstage pro Jahr"
|
||||
Variant="Variant.Outlined"
|
||||
Min="1" Max="365" Step="1"
|
||||
HelperText="Dein jährliches Urlaubskontingent" />
|
||||
|
||||
@* ── Statistik-Chips ── *@
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="4">
|
||||
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
|
||||
Style="background: rgba(63,81,181,0.08);">
|
||||
<MudText Typo="Typo.h5" Color="Color.Primary" Style="font-weight:700">
|
||||
@_settings.VacationDaysPerYear
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Gesamt</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
|
||||
Style="background: rgba(244,67,54,0.08);">
|
||||
<MudText Typo="Typo.h5" Color="Color.Error" Style="font-weight:700">
|
||||
@_vacationDays.Count
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Genommen</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudPaper Elevation="0" Class="pa-3 rounded-lg text-center"
|
||||
Style="@($"background: rgba({(_vacRemaining >= 0 ? "76,175,80" : "255,152,0")},0.08);")">
|
||||
<MudText Typo="Typo.h5"
|
||||
Color="@(_vacRemaining >= 0 ? Color.Success : Color.Warning)"
|
||||
Style="font-weight:700">
|
||||
@_vacRemaining
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Verbleibend</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* ── Fortschrittsbalken ── *@
|
||||
<MudTooltip Text="@($"{_vacationDays.Count} von {_settings.VacationDaysPerYear} Tagen genommen")">
|
||||
<MudProgressLinear Value="@Math.Min(VacationUsedPercent, 100)"
|
||||
Color="@(VacationUsedPercent > 100 ? Color.Error : VacationUsedPercent > 80 ? Color.Warning : Color.Success)"
|
||||
Rounded="true" Style="height:10px" />
|
||||
</MudTooltip>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Align="Align.Center">
|
||||
@VacationUsedPercent % des Jahresurlaubs @_vacYear verbraucht
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* ── Urlaub hinzufügen ── *@
|
||||
<MudItem xs="12" md="7">
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.subtitle2" Style="font-weight:600">Urlaub eintragen</MudText>
|
||||
<MudStack Row="true" AlignItems="AlignItems.End" Spacing="2" Wrap="Wrap.Wrap">
|
||||
<MudDatePicker @bind-Date="_newVacDateFrom"
|
||||
Label="Von"
|
||||
Variant="Variant.Outlined"
|
||||
DateFormat="dd.MM.yyyy"
|
||||
Style="width:300px"
|
||||
PickerVariant="PickerVariant.Inline" />
|
||||
<MudDatePicker @bind-Date="_newVacDateTo"
|
||||
Label="Bis"
|
||||
Variant="Variant.Outlined"
|
||||
DateFormat="dd.MM.yyyy"
|
||||
Style="width:300px"
|
||||
MinDate="@_newVacDateFrom"
|
||||
PickerVariant="PickerVariant.Inline" />
|
||||
<MudTextField @bind-Value="_newVacNote"
|
||||
Label="Notiz (optional)"
|
||||
Variant="Variant.Outlined"
|
||||
Style="width:300px" />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Secondary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="AddVacation" Disabled="@(_newVacDateFrom == null)">
|
||||
Hinzufügen
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@* ── Liste der Urlaubstage ── *@
|
||||
@if (_vacationDays.Count == 0)
|
||||
{
|
||||
<MudPaper Elevation="0" Class="pa-4 rounded-lg text-center"
|
||||
Style="background: var(--mud-palette-background-grey);">
|
||||
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
|
||||
Style="font-size:2.5rem; color:#CFD8DC" />
|
||||
<MudText Color="Color.Secondary" Class="mt-1">
|
||||
Noch keine Urlaubstage für @_vacYear eingetragen.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="VacationDay" Dense="true">
|
||||
@foreach (var v in _vacationDays)
|
||||
{
|
||||
<MudListItem>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center"
|
||||
Justify="Justify.SpaceBetween">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
|
||||
Size="Size.Small" Color="Color.Secondary" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body2" Style="font-weight:600">
|
||||
@v.Date.ToString("dddd, dd. MMMM yyyy", _deCulture)
|
||||
</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(v.Note))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@v.Note
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
||||
Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(async () => await RemoveVacation(v.Id))" />
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
<MudDivider />
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@* ── Feiertagsverwaltung ── *@
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardHeader Style="background: linear-gradient(90deg, rgba(0,188,212,0.1) 0%, transparent 100%);">
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Celebration" Color="Color.Tertiary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Feiertage (Deutschland)</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Size="Size.Small" OnClick="@(() => ChangeHolYear(-1))" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700; min-width:50px; text-align:center">
|
||||
@_holYear
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Size="Size.Small" OnClick="@(() => ChangeHolYear(1))" />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Tertiary"
|
||||
StartIcon="@Icons.Material.Filled.CloudDownload"
|
||||
OnClick="FetchHolidays"
|
||||
Disabled="@_fetchingHolidays"
|
||||
Size="Size.Small">
|
||||
@if (_fetchingHolidays)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Von API laden
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
@if (_holHolidays.Count == 0)
|
||||
{
|
||||
<MudPaper Elevation="0" Class="pa-4 rounded-lg text-center"
|
||||
Style="background: var(--mud-palette-background-grey);">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Celebration"
|
||||
Style="font-size:2.5rem; color:#CFD8DC" />
|
||||
<MudText Color="Color.Secondary" Class="mt-1">
|
||||
Keine Feiertage für @_holYear gespeichert. Klicke "Von API laden".
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="PublicHoliday" Dense="true">
|
||||
@foreach (var h in _holHolidays)
|
||||
{
|
||||
<MudListItem>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center"
|
||||
Justify="Justify.SpaceBetween">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Celebration"
|
||||
Size="Size.Small" Color="Color.Tertiary" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body2" Style="font-weight:600">
|
||||
@h.Date.ToString("dddd, dd. MMMM yyyy", _deCulture)
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">@h.Name</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
||||
Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(async () => await DeleteHoliday(h.Id))" />
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
<MudDivider />
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
|
||||
|
||||
private AppSettings? _settings;
|
||||
private int _userId;
|
||||
private DateTime? _flexStartDate
|
||||
{
|
||||
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
|
||||
set => _settings!.FlexTimeStartDate = value.HasValue ? DateOnly.FromDateTime(value.Value) : null;
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> GermanStates = new()
|
||||
{
|
||||
{ "DE-BW", "Baden-Württemberg" },
|
||||
{ "DE-BY", "Bayern" },
|
||||
{ "DE-BE", "Berlin" },
|
||||
{ "DE-BB", "Brandenburg" },
|
||||
{ "DE-HB", "Bremen" },
|
||||
{ "DE-HH", "Hamburg" },
|
||||
{ "DE-HE", "Hessen" },
|
||||
{ "DE-MV", "Mecklenburg-Vorpommern" },
|
||||
{ "DE-NI", "Niedersachsen" },
|
||||
{ "DE-NW", "Nordrhein-Westfalen" },
|
||||
{ "DE-RP", "Rheinland-Pfalz" },
|
||||
{ "DE-SL", "Saarland" },
|
||||
{ "DE-SN", "Sachsen" },
|
||||
{ "DE-ST", "Sachsen-Anhalt" },
|
||||
{ "DE-SH", "Schleswig-Holstein" },
|
||||
{ "DE-TH", "Thüringen" }
|
||||
};
|
||||
|
||||
private int _vacYear = DateTime.Today.Year;
|
||||
private List<VacationDay> _vacationDays = [];
|
||||
private DateTime? _newVacDateFrom;
|
||||
private DateTime? _newVacDateTo;
|
||||
private string _newVacNote = "";
|
||||
private int _holYear = DateTime.Today.Year;
|
||||
private List<PublicHoliday> _holHolidays = [];
|
||||
private bool _fetchingHolidays;
|
||||
private int _vacRemaining => (_settings?.VacationDaysPerYear ?? 0) - _vacationDays.Count;
|
||||
private int VacationUsedPercent => _settings?.VacationDaysPerYear > 0
|
||||
? (int)Math.Round(_vacationDays.Count * 100.0 / _settings.VacationDaysPerYear)
|
||||
: 0;
|
||||
|
||||
// Arbeitstage-Konfiguration als Liste von (Label, Getter, Setter)
|
||||
private static readonly (string Label, Func<AppSettings, bool> Get, Action<AppSettings, bool> Set)[] WorkDayToggles =
|
||||
[
|
||||
("Montag", s => s.WorkMonday, (s, v) => s.WorkMonday = v),
|
||||
("Dienstag", s => s.WorkTuesday, (s, v) => s.WorkTuesday = v),
|
||||
("Mittwoch", s => s.WorkWednesday, (s, v) => s.WorkWednesday = v),
|
||||
("Donnerstag", s => s.WorkThursday, (s, v) => s.WorkThursday = v),
|
||||
("Freitag", s => s.WorkFriday, (s, v) => s.WorkFriday = v),
|
||||
("Samstag", s => s.WorkSaturday, (s, v) => s.WorkSaturday = v),
|
||||
("Sonntag", s => s.WorkSunday, (s, v) => s.WorkSunday = v),
|
||||
];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim == null) return;
|
||||
_userId = int.Parse(claim.Value);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
await LoadVacations();
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
|
||||
}
|
||||
|
||||
private async Task LoadVacations()
|
||||
{
|
||||
_vacationDays = await TrackerService.GetVacationDaysAsync(_userId, _vacYear);
|
||||
}
|
||||
|
||||
private async Task ChangeYear(int delta)
|
||||
{
|
||||
_vacYear += delta;
|
||||
await LoadVacations();
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (_settings == null) return;
|
||||
await TrackerService.SaveSettingsAsync(_settings);
|
||||
// Nach dem Speichern Feiertage neu laden, falls sich das Bundesland geändert hat
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
|
||||
Snackbar.Add("Einstellungen gespeichert", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task AddVacation()
|
||||
{
|
||||
if (_newVacDateFrom == null) return;
|
||||
var from = DateOnly.FromDateTime(_newVacDateFrom.Value);
|
||||
var to = _newVacDateTo.HasValue ? DateOnly.FromDateTime(_newVacDateTo.Value) : from;
|
||||
if (to < from) to = from;
|
||||
var note = string.IsNullOrWhiteSpace(_newVacNote) ? null : _newVacNote.Trim();
|
||||
var current = from;
|
||||
var added = 0;
|
||||
while (current <= to)
|
||||
{
|
||||
if (_settings!.IsWorkDay(current.DayOfWeek))
|
||||
{
|
||||
await TrackerService.AddVacationDayAsync(new VacationDay { UserId = _userId, Date = current, Note = note });
|
||||
added++;
|
||||
}
|
||||
current = current.AddDays(1);
|
||||
}
|
||||
_newVacDateFrom = null;
|
||||
_newVacDateTo = null;
|
||||
_newVacNote = "";
|
||||
await LoadVacations();
|
||||
Snackbar.Add(added == 1 ? "Urlaubstag eingetragen" : $"{added} Urlaubstage eingetragen", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task RemoveVacation(int id)
|
||||
{
|
||||
await TrackerService.RemoveVacationDayAsync(_userId, id);
|
||||
await LoadVacations();
|
||||
Snackbar.Add("Urlaubstag entfernt", Severity.Info);
|
||||
}
|
||||
|
||||
// ── Feiertage ────────────────────────────────────────────────
|
||||
private async Task ChangeHolYear(int delta)
|
||||
{
|
||||
_holYear += delta;
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState);
|
||||
}
|
||||
|
||||
private async Task FetchHolidays()
|
||||
{
|
||||
_fetchingHolidays = true;
|
||||
var (success, message) = await HolidayService.FetchAndStoreAsync(_holYear);
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState);
|
||||
_fetchingHolidays = false;
|
||||
Snackbar.Add(message, success ? Severity.Success : Severity.Error);
|
||||
}
|
||||
|
||||
private async Task DeleteHoliday(int id)
|
||||
{
|
||||
await HolidayService.DeleteAsync(id);
|
||||
_holHolidays = await HolidayService.GetHolidaysAsync(_holYear, _settings?.GermanState);
|
||||
Snackbar.Add("Feiertag entfernt", Severity.Info);
|
||||
}
|
||||
|
||||
private static string FormatHours(double hours)
|
||||
{
|
||||
var ts = TimeSpan.FromHours(hours);
|
||||
return $"{(int)ts.TotalHours}:{ts.Minutes:D2} h";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
@page "/urlaub-maximizer"
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize]
|
||||
@inject TimetrackerService TrackerService
|
||||
@inject HolidayService HolidayService
|
||||
@inject ISnackbar Snackbar
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>Urlaubs-Maximizer – Timetracker</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Warning" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Berechne beste Urlaubskombinationen…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, #F57F17 0%, #E65100 100%); color:white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevYear" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Style="color:white" />
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
Urlaubs-Maximizer @_year
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.75)">
|
||||
@_subLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
@if (_year != DateTime.Today.Year)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
OnClick="GoToCurrentYear" Size="Size.Small">Heute</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color:white" Size="Size.Large" OnClick="NextYear" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Info-Legende ── *@
|
||||
<MudPaper Elevation="1" Class="pa-3 rounded-xl">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3" Wrap="Wrap.Wrap">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<div style="width:16px;height:16px;border-radius:4px;background:#3F51B5;"></div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Urlaubstag (einzutragen)</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<div style="width:16px;height:16px;border-radius:4px;background:#009688;"></div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Feiertag</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<div style="width:16px;height:16px;border-radius:4px;background:#FF9800;"></div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Bereits Urlaub</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<div style="width:16px;height:16px;border-radius:4px;background:#ECEFF1;border:1px solid #CFD8DC;"></div>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Wochenende / Frei</MudText>
|
||||
</MudStack>
|
||||
<MudDivider Vertical="true" FlexItem="true" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Noch <b>@_remainingDays</b> Urlaubstage verfügbar
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@if (!_holidays.Any())
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Class="rounded-xl">
|
||||
Keine Feiertage für @_year geladen. Gehe zu
|
||||
<b>Einstellungen → Feiertage</b> und klicke „Von API laden" für optimale Vorschläge.
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (_remainingDays <= 0)
|
||||
{
|
||||
<MudPaper Elevation="2" Class="pa-8 rounded-xl text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.BeachAccess"
|
||||
Style="font-size:4rem; color:#FFB74D;" Class="mb-3" />
|
||||
<MudText Typo="Typo.h6">Alle Urlaubstage sind bereits eingetragen!</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mt-1">
|
||||
Du hast dein Urlaubskontingent für @_year vollständig verplant.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (_suggestions.Count == 0)
|
||||
{
|
||||
<MudPaper Elevation="2" Class="pa-8 rounded-xl text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.SearchOff"
|
||||
Style="font-size:4rem; color:#CFD8DC;" Class="mb-3" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Secondary">Keine Vorschläge gefunden</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mt-1">
|
||||
Für das restliche Jahr @_year sind keine günstigen Brückentag-Kombinationen verfügbar.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Vorschläge gruppiert nach Urlaubstagen ── *@
|
||||
@foreach (var group in _suggestions.GroupBy(s => s.VacationDaysNeeded).OrderBy(g => g.Key))
|
||||
{
|
||||
var dayWord = group.Key == 1 ? "Urlaubstag" : "Urlaubstagen";
|
||||
var bestEff = group.Max(s => s.Efficiency);
|
||||
|
||||
<MudStack Spacing="2">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.overline" Style="font-weight:700; letter-spacing:2px; color:#E65100;">
|
||||
MIT @group.Key.ToString().ToUpper() @dayWord.ToUpper()
|
||||
</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined"
|
||||
Style="color:#E65100; border-color:#E65100; height:20px; font-size:11px">
|
||||
@group.Count() Vorschlag@(group.Count() == 1 ? "" : "schläge")
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
|
||||
<MudGrid Spacing="3">
|
||||
@foreach (var s in group.OrderByDescending(x => x.Efficiency))
|
||||
{
|
||||
var effColor = s.Efficiency >= 4.0 ? "#4CAF50"
|
||||
: s.Efficiency >= 3.0 ? "#2196F3"
|
||||
: s.Efficiency >= 2.0 ? "#FF9800"
|
||||
: "#9E9E9E";
|
||||
var effLabel = s.Efficiency >= 4.0 ? "Jackpot"
|
||||
: s.Efficiency >= 3.0 ? "Sehr gut"
|
||||
: s.Efficiency >= 2.0 ? "Gut"
|
||||
: "OK";
|
||||
|
||||
<MudItem xs="12" sm="6" lg="4">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100"
|
||||
Style="@($"border-top: 4px solid {effColor};")">
|
||||
<MudCardContent Class="pa-4">
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* Titel + Badge *@
|
||||
<MudStack Row="true" AlignItems="AlignItems.Start"
|
||||
Justify="Justify.SpaceBetween">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="font-weight:800; line-height:1.1">
|
||||
@s.TotalFreeDays Tage frei
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
für @s.VacationDaysNeeded @dayWord
|
||||
· +@s.BonusDays Bonustage
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.End">
|
||||
<MudText Typo="Typo.h6"
|
||||
Style="@($"font-weight:800; color:{effColor}")">
|
||||
@s.Efficiency.ToString("0.0")×
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption"
|
||||
Style="@($"color:{effColor}; font-weight:600")">
|
||||
@effLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@* Datum *@
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
@s.SpanStart.ToString("ddd, dd. MMM", _deCulture)
|
||||
–
|
||||
@s.SpanEnd.ToString("ddd, dd. MMM yyyy", _deCulture)
|
||||
</MudText>
|
||||
|
||||
@* Tages-Kacheln *@
|
||||
<div style="display:flex; flex-wrap:wrap; gap:4px;">
|
||||
@foreach (var d in DaysInSpan(s))
|
||||
{
|
||||
var isVac = s.VacationDaysToTake.Contains(d);
|
||||
var isHol = _holidays.ContainsKey(d);
|
||||
var isTaken = _vacationSet.Contains(d);
|
||||
var bg = isVac ? "#3F51B5"
|
||||
: isHol ? "#009688"
|
||||
: isTaken ? "#FF9800"
|
||||
: "#ECEFF1";
|
||||
var fg = (isVac || isHol || isTaken) ? "white" : "#607D8B";
|
||||
var tooltip = isVac ? "Urlaubstag eintragen"
|
||||
: isHol ? (_holidays.GetValueOrDefault(d) ?? "Feiertag")
|
||||
: isTaken ? "Bereits Urlaub"
|
||||
: "Wochenende";
|
||||
<MudTooltip Text="@tooltip">
|
||||
<div style="@($"display:flex;flex-direction:column;align-items:center;justify-content:center;width:36px;height:44px;border-radius:8px;background:{bg};color:{fg};")">
|
||||
<span style="font-size:0.55rem;font-weight:700;letter-spacing:0.5px;line-height:1.2">
|
||||
@d.ToString("ddd", _deCulture).Substring(0, 2).ToUpper()
|
||||
</span>
|
||||
<span style="font-size:0.85rem;font-weight:700;line-height:1.2">
|
||||
@d.Day
|
||||
</span>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
<MudCardActions Class="pa-3 pt-0">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
StartIcon="@Icons.Material.Filled.BeachAccess"
|
||||
Style="@($"background:{effColor}; color:white;")"
|
||||
Size="Size.Small" FullWidth="true"
|
||||
OnClick="@(async () => await TakeSuggestion(s))">
|
||||
Urlaub eintragen
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
|
||||
|
||||
private bool _loading = true;
|
||||
private int _year = DateTime.Today.Year;
|
||||
private AppSettings _settings = new();
|
||||
private Dictionary<DateOnly, string> _holidays = [];
|
||||
private HashSet<DateOnly> _vacationSet = [];
|
||||
private int _remainingDays;
|
||||
private List<Suggestion> _suggestions = [];
|
||||
private string _subLabel = "";
|
||||
private int _userId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var claim = authState.User.FindFirst(ClaimTypes.NameIdentifier);
|
||||
if (claim == null) return;
|
||||
_userId = int.Parse(claim.Value);
|
||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
||||
await LoadYear();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadYear()
|
||||
{
|
||||
var holidays = await HolidayService.GetHolidaysAsync(_year, _settings.GermanState);
|
||||
var vacations = await TrackerService.GetVacationDaysAsync(_userId, _year);
|
||||
_holidays = holidays.ToDictionary(h => h.Date, h => h.Name);
|
||||
_vacationSet = vacations.Select(v => v.Date).ToHashSet();
|
||||
_remainingDays = Math.Max(0, _settings.VacationDaysPerYear - vacations.Count);
|
||||
_suggestions = ComputeSuggestions();
|
||||
var count = _suggestions.Count;
|
||||
var bestEff = count > 0 ? _suggestions.Max(s => s.Efficiency) : 0;
|
||||
_subLabel = count == 0
|
||||
? "Keine Vorschläge verfügbar"
|
||||
: $"{count} Kombination{(count == 1 ? "" : "en")} · Beste: {bestEff:0.0}× Effizienz";
|
||||
}
|
||||
|
||||
private async Task PrevYear() { _year--; await LoadYear(); }
|
||||
private async Task NextYear() { _year++; await LoadYear(); }
|
||||
private async Task GoToCurrentYear() { _year = DateTime.Today.Year; await LoadYear(); }
|
||||
|
||||
private async Task TakeSuggestion(Suggestion s)
|
||||
{
|
||||
foreach (var d in s.VacationDaysToTake.Where(d => !_vacationSet.Contains(d)))
|
||||
await TrackerService.AddVacationDayAsync(new VacationDay { UserId = _userId, Date = d, Note = "Urlaubs-Maximizer" });
|
||||
await LoadYear();
|
||||
var word = s.VacationDaysNeeded == 1 ? "Urlaubstag" : "Urlaubstage";
|
||||
Snackbar.Add($"{s.VacationDaysNeeded} {word} eingetragen – {s.TotalFreeDays} Tage frei!", Severity.Success);
|
||||
}
|
||||
|
||||
// ── Algorithmus ──────────────────────────────────────────────────────
|
||||
private enum DayKind { Free, WorkAvailable, WorkTaken }
|
||||
|
||||
private sealed record Suggestion(
|
||||
DateOnly SpanStart,
|
||||
DateOnly SpanEnd,
|
||||
List<DateOnly> VacationDaysToTake,
|
||||
int VacationDaysNeeded,
|
||||
int TotalFreeDays,
|
||||
double Efficiency)
|
||||
{
|
||||
public int BonusDays => TotalFreeDays - VacationDaysNeeded;
|
||||
}
|
||||
|
||||
private List<Suggestion> ComputeSuggestions()
|
||||
{
|
||||
var startOfYear = new DateOnly(_year, 1, 1);
|
||||
var endOfYear = new DateOnly(_year, 12, 31);
|
||||
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||
int n = endOfYear.DayNumber - startOfYear.DayNumber + 1;
|
||||
|
||||
// Classify each day
|
||||
var kinds = new DayKind[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var d = startOfYear.AddDays(i);
|
||||
if (!_settings.IsWorkDay(d.DayOfWeek) || _holidays.ContainsKey(d))
|
||||
kinds[i] = DayKind.Free;
|
||||
else if (_vacationSet.Contains(d))
|
||||
kinds[i] = DayKind.WorkTaken;
|
||||
else
|
||||
kinds[i] = DayKind.WorkAvailable;
|
||||
}
|
||||
|
||||
// Prefix sum: count of WorkAvailable days
|
||||
var psum = new int[n + 1];
|
||||
for (int i = 0; i < n; i++)
|
||||
psum[i + 1] = psum[i] + (kinds[i] == DayKind.WorkAvailable ? 1 : 0);
|
||||
int CountAvail(int a, int b) => psum[b + 1] - psum[a];
|
||||
|
||||
int maxVac = Math.Min(_remainingDays, 5);
|
||||
if (maxVac <= 0) return [];
|
||||
|
||||
// For each window [ws, we] of contiguous days, compute best span
|
||||
var best = new Dictionary<(int, int), Suggestion>();
|
||||
|
||||
for (int ws = 0; ws < n; ws++)
|
||||
{
|
||||
if (kinds[ws] != DayKind.WorkAvailable) continue;
|
||||
|
||||
for (int we = ws; we < n; we++)
|
||||
{
|
||||
int vac = CountAvail(ws, we);
|
||||
if (vac > maxVac) break;
|
||||
if (vac == 0) continue;
|
||||
|
||||
// Extend span outward through non-WorkAvailable days
|
||||
int ss = ws, se = we;
|
||||
while (ss > 0 && kinds[ss - 1] != DayKind.WorkAvailable) ss--;
|
||||
while (se < n - 1 && kinds[se + 1] != DayKind.WorkAvailable) se++;
|
||||
|
||||
int total = se - ss + 1;
|
||||
if (total <= vac) continue; // No bonus days → skip
|
||||
if (startOfYear.AddDays(se) < today) continue; // Fully in the past → skip
|
||||
|
||||
double eff = (double)total / vac;
|
||||
var key = (ss, se);
|
||||
|
||||
var vacDays = Enumerable.Range(ws, we - ws + 1)
|
||||
.Where(i => kinds[i] == DayKind.WorkAvailable)
|
||||
.Select(i => startOfYear.AddDays(i))
|
||||
.ToList();
|
||||
|
||||
var sug = new Suggestion(
|
||||
startOfYear.AddDays(ss), startOfYear.AddDays(se),
|
||||
vacDays, vac, total, eff);
|
||||
|
||||
if (!best.TryGetValue(key, out var existing) || vac < existing.VacationDaysNeeded)
|
||||
best[key] = sug;
|
||||
}
|
||||
}
|
||||
|
||||
// Group by vac days needed, keep top 4 per group by efficiency
|
||||
return [.. best.Values
|
||||
.GroupBy(s => s.VacationDaysNeeded)
|
||||
.OrderBy(g => g.Key)
|
||||
.SelectMany(g => g
|
||||
.OrderByDescending(s => s.Efficiency)
|
||||
.ThenByDescending(s => s.TotalFreeDays)
|
||||
.Take(4))];
|
||||
}
|
||||
|
||||
private static IEnumerable<DateOnly> DaysInSpan(Suggestion s)
|
||||
{
|
||||
for (var d = s.SpanStart; d <= s.SpanEnd; d = d.AddDays(1))
|
||||
yield return d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
var returnUrl = Uri.EscapeDataString(Nav.Uri);
|
||||
Nav.NavigateTo($"/login?returnUrl={returnUrl}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
</AuthorizeRouteView>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,16 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using timetracker
|
||||
@using timetracker.Components
|
||||
@using timetracker.Components.Layout
|
||||
@using timetracker.Data
|
||||
@using MudBlazor
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class AppSettings
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public double DailyTargetHours { get; set; } = 7.5;
|
||||
public int MinimumBreakMinutes { get; set; } = 30;
|
||||
public int VacationDaysPerYear { get; set; } = 30;
|
||||
public string? GermanState { get; set; }
|
||||
public DateOnly? FlexTimeStartDate { get; set; }
|
||||
public double FlexTimeStartingBalanceHours { get; set; } = 0.0;
|
||||
|
||||
// Arbeitstage
|
||||
public bool WorkMonday { get; set; } = true;
|
||||
public bool WorkTuesday { get; set; } = true;
|
||||
public bool WorkWednesday { get; set; } = true;
|
||||
public bool WorkThursday { get; set; } = true;
|
||||
public bool WorkFriday { get; set; } = true;
|
||||
public bool WorkSaturday { get; set; } = false;
|
||||
public bool WorkSunday { get; set; } = false;
|
||||
|
||||
public bool IsWorkDay(DayOfWeek day) => day switch
|
||||
{
|
||||
DayOfWeek.Monday => WorkMonday,
|
||||
DayOfWeek.Tuesday => WorkTuesday,
|
||||
DayOfWeek.Wednesday => WorkWednesday,
|
||||
DayOfWeek.Thursday => WorkThursday,
|
||||
DayOfWeek.Friday => WorkFriday,
|
||||
DayOfWeek.Saturday => WorkSaturday,
|
||||
DayOfWeek.Sunday => WorkSunday,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory)
|
||||
{
|
||||
public async Task<User?> LoginAsync(string username, string password)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users
|
||||
.FirstOrDefaultAsync(u => u.Username == username);
|
||||
if (user == null) return null;
|
||||
return VerifyPassword(password, user.PasswordHash, user.PasswordSalt) ? user : null;
|
||||
}
|
||||
|
||||
public async Task<List<User>> GetAllUsersAsync()
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
return await db.Users
|
||||
.OrderBy(u => u.Username)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(int userId)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
if (user != null)
|
||||
{
|
||||
db.Users.Remove(user);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password) {
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
|
||||
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
||||
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
if (await db.Users.AnyAsync(u => u.Username == username))
|
||||
return (null, "Benutzername bereits vergeben.");
|
||||
|
||||
var (hash, salt) = HashPassword(password);
|
||||
var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt };
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
return (user, null);
|
||||
}
|
||||
|
||||
private static (string hash, string salt) HashPassword(string password)
|
||||
{
|
||||
var saltBytes = RandomNumberGenerator.GetBytes(32);
|
||||
var salt = Convert.ToBase64String(saltBytes);
|
||||
var hash = ComputeHash(password, salt);
|
||||
return (hash, salt);
|
||||
}
|
||||
|
||||
private static bool VerifyPassword(string password, string hash, string salt)
|
||||
=> ComputeHash(password, salt) == hash;
|
||||
|
||||
private static string ComputeHash(string password, string salt)
|
||||
{
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password),
|
||||
Convert.FromBase64String(salt),
|
||||
iterations: 200_000,
|
||||
HashAlgorithmName.SHA256,
|
||||
outputLength: 32);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class BreakEntry
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int WorkDayId { get; set; }
|
||||
public WorkDay WorkDay { get; set; } = null!;
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, HttpClient http)
|
||||
{
|
||||
private const string ApiUrl = "https://date.nager.at/api/v3/PublicHolidays/{0}/DE";
|
||||
|
||||
public async Task<List<PublicHoliday>> GetHolidaysAsync(int year, string? stateCode = null)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var holidays = await db.PublicHolidays
|
||||
.Where(h => h.Date.Year == year)
|
||||
.OrderBy(h => h.Date)
|
||||
.ToListAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(stateCode))
|
||||
{
|
||||
// Default: return only global holidays (where Counties is null or empty)
|
||||
return holidays.Where(h => string.IsNullOrEmpty(h.Counties)).ToList();
|
||||
}
|
||||
|
||||
// Return global holidays OR holidays that match the user's state code
|
||||
return holidays.Where(h => string.IsNullOrEmpty(h.Counties) || h.Counties.Split(',').Contains(stateCode)).ToList();
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> FetchAndStoreAsync(int year)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = string.Format(ApiUrl, year);
|
||||
var items = await http.GetFromJsonAsync<List<NagerHoliday>>(url);
|
||||
if (items == null) return (false, "Keine Daten erhalten.");
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var existing = await db.PublicHolidays.Where(h => h.Date.Year == year).ToListAsync();
|
||||
db.PublicHolidays.RemoveRange(existing);
|
||||
|
||||
db.PublicHolidays.AddRange(items
|
||||
.Where(h => DateOnly.TryParse(h.Date, out _))
|
||||
.Select(h => new PublicHoliday
|
||||
{
|
||||
Date = DateOnly.Parse(h.Date),
|
||||
Name = h.LocalName,
|
||||
Counties = h.Counties != null && h.Counties.Count > 0 ? string.Join(",", h.Counties) : null
|
||||
}));
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return (true, $"{items.Count} Feiertage für {year} erfolgreich gespeichert.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, $"Fehler beim Abrufen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var h = await db.PublicHolidays.FindAsync(id);
|
||||
if (h != null)
|
||||
{
|
||||
db.PublicHolidays.Remove(h);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NagerHoliday
|
||||
{
|
||||
[JsonPropertyName("date")]
|
||||
public string Date { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("localName")]
|
||||
public string LocalName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("counties")]
|
||||
public List<string>? Counties { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
// <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("20260520133634_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.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.Data.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.Data.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.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.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
DailyTargetHours = table.Column<double>(type: "REAL", nullable: false),
|
||||
MinimumBreakMinutes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
VacationDaysPerYear = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
WorkMonday = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
WorkTuesday = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
WorkWednesday = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
WorkThursday = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
WorkFriday = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
WorkSaturday = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
WorkSunday = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VacationDays",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
Note = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VacationDays", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkDays",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
StartTime = table.Column<TimeOnly>(type: "TEXT", nullable: true),
|
||||
EndTime = table.Column<TimeOnly>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkDays", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BreakEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
WorkDayId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
StartTime = table.Column<TimeOnly>(type: "TEXT", nullable: true),
|
||||
EndTime = table.Column<TimeOnly>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BreakEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BreakEntries_WorkDays_WorkDayId",
|
||||
column: x => x.WorkDayId,
|
||||
principalTable: "WorkDays",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BreakEntries_WorkDayId",
|
||||
table: "BreakEntries",
|
||||
column: "WorkDayId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BreakEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VacationDays");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkDays");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// <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("20260520200000_AddPublicHolidays")]
|
||||
partial class AddPublicHolidays
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.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.Data.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.Data.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.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.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPublicHolidays : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PublicHolidays",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Date = table.Column<DateOnly>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PublicHolidays", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "PublicHolidays");
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// <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("20260522081459_AddMultiUser")]
|
||||
partial class AddMultiUser
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.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.Data.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.Data.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.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>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiUser : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "UserId",
|
||||
table: "WorkDays",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "UserId",
|
||||
table: "VacationDays",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "UserId",
|
||||
table: "AppSettings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Users",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Username = table.Column<string>(type: "TEXT", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
|
||||
PasswordSalt = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Users", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UserId",
|
||||
table: "WorkDays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UserId",
|
||||
table: "VacationDays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UserId",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// <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("20260607213215_AddFlexTimeAndHolidayState")]
|
||||
partial class AddFlexTimeAndHolidayState
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.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>("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.Data.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.Data.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.Data.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.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>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFlexTimeAndHolidayState : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Counties",
|
||||
table: "PublicHolidays",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateOnly>(
|
||||
name: "FlexTimeStartDate",
|
||||
table: "AppSettings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "FlexTimeStartingBalanceHours",
|
||||
table: "AppSettings",
|
||||
type: "REAL",
|
||||
nullable: false,
|
||||
defaultValue: 0.0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "GermanState",
|
||||
table: "AppSettings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Counties",
|
||||
table: "PublicHolidays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FlexTimeStartDate",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FlexTimeStartingBalanceHours",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GermanState",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
partial class TimetrackerDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.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>("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.Data.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.Data.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.Data.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.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>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class PublicHoliday
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? Counties { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
|
||||
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
|
||||
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
|
||||
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
|
||||
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
|
||||
{
|
||||
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()));
|
||||
actual = (gross - breakTotal).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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class User
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public string PasswordHash { get; set; } = "";
|
||||
public string PasswordSalt { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class VacationDay
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class WorkDay
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
public List<BreakEntry> Breaks { get; set; } = [];
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# ── Build Stage ──────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Keine Unterordner mehr beim Kopieren!
|
||||
COPY timetracker.csproj ./
|
||||
RUN dotnet restore timetracker.csproj
|
||||
|
||||
COPY . ./
|
||||
RUN dotnet publish -c Release -o /app/publish --no-restore
|
||||
|
||||
# ── Runtime Stage ─────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Verzeichnis für die SQLite-Datenbank
|
||||
RUN mkdir -p /data
|
||||
|
||||
ENV ASPNETCORE_HTTP_PORTS=8080
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
|
||||
ENV TIMETRACKER_DB_PATH=/data/timetracker.db
|
||||
ENV EnableHttpsRedirect=false
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENTRYPOINT ["dotnet", "timetracker.dll"]
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MudBlazor.Services;
|
||||
using timetracker.Components;
|
||||
using timetracker.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/login";
|
||||
options.LogoutPath = "/auth/logout";
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.SlidingExpiration = true;
|
||||
});
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("AdminOnly", policy =>
|
||||
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
|
||||
});
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
builder.Services.AddMudServices();
|
||||
builder.Services.AddHttpClient<HolidayService>();
|
||||
|
||||
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
|
||||
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
|
||||
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
|
||||
options.UseSqlite($"Data Source={dbPath}"));
|
||||
builder.Services.AddScoped<TimetrackerService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||
};
|
||||
forwardedHeadersOptions.KnownProxies.Clear();
|
||||
forwardedHeadersOptions.KnownIPNetworks.Clear();
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
app.UseHsts();
|
||||
}
|
||||
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
|
||||
if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelopment()))
|
||||
{
|
||||
app.UseHttpsRedirection();
|
||||
}
|
||||
|
||||
// Statische Dateien (inkl. _framework/, _content/) vor Auth bedienen,
|
||||
// damit Blazor-JS und MudBlazor-CSS nie durch Auth-Middleware geblockt werden
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
// ── Auth-Endpoints ────────────────────────────────────────────────────────────
|
||||
app.MapPost("/auth/login", async (HttpContext ctx, AuthService authService) =>
|
||||
{
|
||||
var form = await ctx.Request.ReadFormAsync();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
var user = await authService.LoginAsync(username, password);
|
||||
if (user == null)
|
||||
return Results.Redirect("/login?error=invalid");
|
||||
|
||||
var claims = new[] {
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username)
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity),
|
||||
new AuthenticationProperties { IsPersistent = true });
|
||||
return Results.Redirect("/");
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapPost("/auth/register", async (HttpContext ctx, AuthService authService) =>
|
||||
{
|
||||
var form = await ctx.Request.ReadFormAsync();
|
||||
var username = form["username"].ToString();
|
||||
var password = form["password"].ToString();
|
||||
var (user, error) = await authService.RegisterAsync(username, password);
|
||||
if (user == null)
|
||||
return Results.Redirect($"/login?tab=register&error={Uri.EscapeDataString(error!)}");
|
||||
|
||||
var claims = new[] {
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username)
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity),
|
||||
new AuthenticationProperties { IsPersistent = true });
|
||||
return Results.Redirect("/");
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapGet("/auth/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return Results.Redirect("/login");
|
||||
});
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5065",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7184;http://localhost:5065",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Binary file not shown.
BIN
Binary file not shown.
+68
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<startup>
|
||||
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
|
||||
</startup>
|
||||
<runtime>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Build" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Build.Framework" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Build.Utilities.Core" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.Build.Tasks.Core" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-15.1.0.0" newVersion="15.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="Microsoft.IO.Redist" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.1.0.0" newVersion="6.1.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.4.0" newVersion="4.0.4.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Collections.Immutable" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.0.2.0" newVersion="4.0.2.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-6.0.1.0" newVersion="6.0.1.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||
<bindingRedirect oldVersion="0.0.0.0-4.2.1.0" newVersion="4.2.1.0" />
|
||||
</dependentAssembly>
|
||||
</assemblyBinding>
|
||||
</runtime>
|
||||
</configuration>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+171
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v8.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {
|
||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/5.0.0-2.25567.12": {
|
||||
"dependencies": {
|
||||
"Microsoft.Build.Locator": "1.10.2",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"System.Collections.Immutable": "9.0.0",
|
||||
"System.CommandLine": "2.0.0-rtm.25509.106"
|
||||
},
|
||||
"runtime": {
|
||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.dll": {}
|
||||
},
|
||||
"resources": {
|
||||
"cs/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"de/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"es/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"fr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"it/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"ja/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"ko/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"pl/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"pt-BR/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"ru/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"tr/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"zh-Hans/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"zh-Hant/Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Microsoft.Build.Locator/1.10.2": {
|
||||
"runtime": {
|
||||
"lib/net8.0/Microsoft.Build.Locator.dll": {
|
||||
"assemblyVersion": "1.0.0.0",
|
||||
"fileVersion": "1.10.2.26959"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Newtonsoft.Json/13.0.3": {
|
||||
"runtime": {
|
||||
"lib/net6.0/Newtonsoft.Json.dll": {
|
||||
"assemblyVersion": "13.0.0.0",
|
||||
"fileVersion": "13.0.3.27908"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Collections.Immutable/9.0.0": {
|
||||
"runtime": {
|
||||
"lib/net8.0/System.Collections.Immutable.dll": {
|
||||
"assemblyVersion": "9.0.0.0",
|
||||
"fileVersion": "9.0.24.52809"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.CommandLine/2.0.0-rtm.25509.106": {
|
||||
"runtime": {
|
||||
"lib/net8.0/System.CommandLine.dll": {
|
||||
"assemblyVersion": "2.0.0.0",
|
||||
"fileVersion": "2.0.25.51006"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"lib/net8.0/cs/System.CommandLine.resources.dll": {
|
||||
"locale": "cs"
|
||||
},
|
||||
"lib/net8.0/de/System.CommandLine.resources.dll": {
|
||||
"locale": "de"
|
||||
},
|
||||
"lib/net8.0/es/System.CommandLine.resources.dll": {
|
||||
"locale": "es"
|
||||
},
|
||||
"lib/net8.0/fr/System.CommandLine.resources.dll": {
|
||||
"locale": "fr"
|
||||
},
|
||||
"lib/net8.0/it/System.CommandLine.resources.dll": {
|
||||
"locale": "it"
|
||||
},
|
||||
"lib/net8.0/ja/System.CommandLine.resources.dll": {
|
||||
"locale": "ja"
|
||||
},
|
||||
"lib/net8.0/ko/System.CommandLine.resources.dll": {
|
||||
"locale": "ko"
|
||||
},
|
||||
"lib/net8.0/pl/System.CommandLine.resources.dll": {
|
||||
"locale": "pl"
|
||||
},
|
||||
"lib/net8.0/pt-BR/System.CommandLine.resources.dll": {
|
||||
"locale": "pt-BR"
|
||||
},
|
||||
"lib/net8.0/ru/System.CommandLine.resources.dll": {
|
||||
"locale": "ru"
|
||||
},
|
||||
"lib/net8.0/tr/System.CommandLine.resources.dll": {
|
||||
"locale": "tr"
|
||||
},
|
||||
"lib/net8.0/zh-Hans/System.CommandLine.resources.dll": {
|
||||
"locale": "zh-Hans"
|
||||
},
|
||||
"lib/net8.0/zh-Hant/System.CommandLine.resources.dll": {
|
||||
"locale": "zh-Hant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"Microsoft.CodeAnalysis.Workspaces.MSBuild.BuildHost/5.0.0-2.25567.12": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Microsoft.Build.Locator/1.10.2": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-F+nLS7IpgtslyxNvtD6Jalnf5WU08lu8yfJBNQl3cbEF3AMUphs4t7nPuRYaaU8QZyGrqtVi7i73LhAe/yHx7A==",
|
||||
"path": "microsoft.build.locator/1.10.2",
|
||||
"hashPath": "microsoft.build.locator.1.10.2.nupkg.sha512"
|
||||
},
|
||||
"Newtonsoft.Json/13.0.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==",
|
||||
"path": "newtonsoft.json/13.0.3",
|
||||
"hashPath": "newtonsoft.json.13.0.3.nupkg.sha512"
|
||||
},
|
||||
"System.Collections.Immutable/9.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==",
|
||||
"path": "system.collections.immutable/9.0.0",
|
||||
"hashPath": "system.collections.immutable.9.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.CommandLine/2.0.0-rtm.25509.106": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-IdCQOFNHQfK0hu3tzWOHFJLMaiEOR/4OynmOh+IfukrTIsCR4TTDm7lpuXQyMZ0eRfIyUcz06gHGJNlILAq/6A==",
|
||||
"path": "system.commandline/2.0.0-rtm.25509.106",
|
||||
"hashPath": "system.commandline.2.0.0-rtm.25509.106.nupkg.sha512"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net8.0",
|
||||
"framework": {
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "8.0.0"
|
||||
},
|
||||
"rollForward": "Major",
|
||||
"configProperties": {
|
||||
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
|
||||
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user