You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

331 lines
16 KiB

import { Component, OnInit, signal } from "@angular/core";
import { DatePipe, DecimalPipe } from "@angular/common";
import { RouterLink } from "@angular/router";
import { FormsModule } from "@angular/forms";
import { MATERIAL } from "../shared/material";
import { ApiService } from "../core/api.service";
import { Dashboard, Entry } from "../core/models";
@Component({
standalone: true,
imports: [RouterLink, FormsModule, DatePipe, DecimalPipe, ...MATERIAL],
template: `<section class="page entries-page">
<div class="page-head">
<div>
<h1>Einträge</h1>
<p class="muted">Stunden, Kosten und Fahrten verwalten</p>
</div>
<div class="actions">
<button mat-stroked-button (click)="download()">
<mat-icon>download</mat-icon> CSV</button
><a mat-flat-button routerLink="/entries/new"
><mat-icon>add</mat-icon> Neuer Eintrag</a
>
</div>
</div>
<mat-card class="timer-card">
<mat-card-content>
@if (running(); as timer) {
<div><mat-icon>timer</mat-icon><span>Timer läuft</span><strong>{{ elapsed(timer) }}</strong><small>{{ timer.praktikums_typ }} · {{ timer.entry_art }}</small></div>
<mat-checkbox [(ngModel)]="lunchBreak">30 Minuten Pause abziehen</mat-checkbox>
<button class="timer-stop" mat-flat-button color="warn" (click)="stopTimer(timer)"><mat-icon>stop</mat-icon> Timer stoppen</button>
} @else {
<div><mat-icon>timer</mat-icon><span>Zeiterfassung</span><strong>Timer starten</strong></div>
<mat-form-field appearance="outline"><mat-label>Ausbildung</mat-label><mat-select [(ngModel)]="timerType">@for(t of types;track t){<mat-option [value]="t">{{t}}</mat-option>}</mat-select></mat-form-field>
<mat-form-field appearance="outline"><mat-label>Art</mat-label><mat-select [(ngModel)]="timerArt">@for(a of timerArts();track a){<mat-option [value]="a">{{a}}</mat-option>}</mat-select></mat-form-field>
<button class="timer-start" mat-flat-button (click)="startTimer()"><mat-icon>play_arrow</mat-icon> Timer starten</button>
}
</mat-card-content>
</mat-card>
<mat-card class="filters"
><mat-card-content
><mat-form-field appearance="outline"
><mat-label>Suche</mat-label
><input
matInput
[(ngModel)]="search"
(keyup.enter)="load()"
/><mat-icon matSuffix>search</mat-icon></mat-form-field
><mat-form-field appearance="outline"
><mat-label>Ausbildung</mat-label
><mat-select [(ngModel)]="typ" (selectionChange)="load()"
><mat-option value="">Alle</mat-option>
@for (t of types; track t) {
<mat-option [value]="t">{{ t }}</mat-option>
}
</mat-select></mat-form-field
><mat-form-field appearance="outline"><mat-label>Von Jahr</mat-label><mat-select [(ngModel)]="minYear"><mat-option value="">Alle</mat-option>@for(y of years();track y){<mat-option [value]="y">{{y}}</mat-option>}</mat-select></mat-form-field
><mat-form-field appearance="outline"><mat-label>Bis Jahr</mat-label><mat-select [(ngModel)]="maxYear"><mat-option value="">Alle</mat-option>@for(y of years();track y){<mat-option [value]="y">{{y}}</mat-option>}</mat-select></mat-form-field
><button mat-button (click)="reset()">
Zurücksetzen
</button></mat-card-content
></mat-card
>
@if (loading()) {
<mat-progress-bar mode="indeterminate" />
}
<mat-card class="table-card"
><div class="table-wrap">
<table mat-table [dataSource]="visibleEntries()">
<ng-container matColumnDef="date"
><th mat-header-cell *matHeaderCellDef>Datum</th>
<td mat-cell *matCellDef="let e">
{{ e.date | date: "dd.MM.yyyy" }}
</td></ng-container
><ng-container matColumnDef="type"
><th mat-header-cell *matHeaderCellDef>Ausbildung</th>
<td mat-cell *matCellDef="let e">
<strong>{{ e.praktikums_typ }}</strong>
</td></ng-container
><ng-container matColumnDef="art"><th mat-header-cell *matHeaderCellDef>Art</th><td mat-cell *matCellDef="let e">{{e.entry_art}}</td></ng-container
><ng-container matColumnDef="time"
><th mat-header-cell *matHeaderCellDef>Zeit</th>
<td mat-cell *matCellDef="let e">
{{ e.hours }} h {{ e.minutes }} min
</td></ng-container
><ng-container matColumnDef="details"
><th mat-header-cell *matHeaderCellDef>Details</th>
<td mat-cell *matCellDef="let e">
<span>{{ e.beschreibung || "–" }}</span>
</td></ng-container
><ng-container matColumnDef="distance"><th mat-header-cell *matHeaderCellDef>Kilometer</th><td mat-cell *matCellDef="let e">{{e.distance_km || 0 | number:"1.0-2"}} km</td></ng-container
><ng-container matColumnDef="allowance"><th mat-header-cell *matHeaderCellDef>Pauschale</th><td mat-cell *matCellDef="let e">{{e.kilometer_pauschale || 0 | number:"1.2-2"}} €</td></ng-container
><ng-container matColumnDef="cost"><th mat-header-cell *matHeaderCellDef>Kosten</th><td mat-cell *matCellDef="let e">{{e.kosten || 0 | number:"1.2-2"}} €</td></ng-container
><ng-container matColumnDef="training"><th mat-header-cell *matHeaderCellDef>Fortbildung</th><td mat-cell *matCellDef="let e"><mat-icon class="training-state" [class.is-active]="e.zaehlt_als_fortbildung">{{e.zaehlt_als_fortbildung ? "check_circle" : "remove"}}</mat-icon></td></ng-container
><ng-container matColumnDef="actions"
><th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let e">
<div class="entry-actions">
<a class="edit-action" mat-icon-button matTooltip="Bearbeiten" aria-label="Eintrag bearbeiten" [routerLink]="['/entries', e.id, 'edit']"
><mat-icon>edit</mat-icon></a
><button class="delete-action" mat-icon-button matTooltip="Löschen" aria-label="Eintrag löschen" (click)="remove(e)">
<mat-icon>delete</mat-icon>
</button>
</div>
</td></ng-container
>
<tr mat-header-row *matHeaderRowDef="columns"></tr>
<tr
mat-row
*matRowDef="let row; columns: columns"
[class]="entryRowClass(row)"
></tr>
</table>
@if (!loading() && !entries().length) {
<div class="empty">
<mat-icon>event_busy</mat-icon>
<p>Noch keine passenden Einträge vorhanden.</p>
</div>
}
</div><div class="entry-count">Angezeigt: {{visibleEntries().length}} · Gesamt: {{entries().length}}</div></mat-card
>
</section>`,
styles: [
`
.filters {
margin-bottom: 20px;
}
.entries-page {
max-width: 1720px;
}
.timer-card { margin-bottom: 20px; }
.timer-card mat-card-content { display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap; }
.timer-card mat-card-content > div { display:grid;grid-template-columns:auto 1fr;gap:3px 10px;margin-right:auto; }
.timer-card mat-card-content > div > mat-icon {
grid-row:1/4;
align-self:center;
display:grid;
place-items:center;
width:40px;
height:40px;
border-radius:12px;
color:var(--app-primary);
background:color-mix(in srgb,var(--app-primary) 13%,transparent);
}
.timer-card button mat-icon { color:inherit; }
.timer-card mat-card-content > button,
.filters mat-card-content > button {
align-self:flex-start;
min-height:48px;
margin-top:4px;
}
.timer-start mat-icon, .timer-stop mat-icon { opacity:1; }
.timer-card strong { font-size:1.25rem; }
.filters mat-card-content {
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.filters mat-form-field {
min-width: 220px;
}
.table-card {
padding: 0;
overflow: hidden;
}
.entry-count { padding:12px 18px;color:#687287;border-top:1px solid rgba(120,130,145,.2);font-size:.85rem; }
.training-state { color:var(--app-muted);vertical-align:middle; }
.training-state.is-active { color:#198754; }
.mat-column-actions { width:108px;min-width:108px;white-space:nowrap; }
.mat-column-time { width:128px;min-width:128px;white-space:nowrap; }
.entry-actions { display:flex;align-items:center;justify-content:flex-end;gap:8px;white-space:nowrap; }
.entry-actions .mat-mdc-icon-button { flex:0 0 40px;width:40px;height:40px;padding:8px;border-radius:10px; }
.edit-action { color:#0d6efd;background:rgba(13,110,253,.11); }
.delete-action { color:#c62828;background:rgba(198,40,40,.11); }
.edit-action:hover { background:rgba(13,110,253,.19); }
.delete-action:hover { background:rgba(198,40,40,.19); }
td strong,
td small {
display: block;
}
td small {
color: #687287;
margin-top: 4px;
}
th {
font-weight: 700;
}
tr.mat-mdc-row > td:first-child {
border-left: 4px solid transparent;
}
tr.mat-mdc-row.type-propaedeutikum:nth-of-type(odd) > td {
background-color: rgba(13, 110, 253, 0.055);
}
tr.mat-mdc-row.type-propaedeutikum:nth-of-type(even) > td {
background-color: rgba(13, 110, 253, 0.115);
}
tr.mat-mdc-row.type-fachspezifikum:nth-of-type(odd) > td {
background-color: rgba(25, 135, 84, 0.055);
}
tr.mat-mdc-row.type-fachspezifikum:nth-of-type(even) > td {
background-color: rgba(25, 135, 84, 0.115);
}
tr.mat-mdc-row.type-mediation:nth-of-type(odd) > td {
background-color: rgba(253, 126, 20, 0.065);
}
tr.mat-mdc-row.type-mediation:nth-of-type(even) > td {
background-color: rgba(253, 126, 20, 0.135);
}
tr.mat-mdc-row.type-propaedeutikum:hover > td {
background-color: rgba(13, 110, 253, 0.17);
}
tr.mat-mdc-row.type-fachspezifikum:hover > td {
background-color: rgba(25, 135, 84, 0.17);
}
tr.mat-mdc-row.type-mediation:hover > td {
background-color: rgba(253, 126, 20, 0.19);
}
tr.mat-mdc-row.type-propaedeutikum > td:first-child { border-left-color: #0d6efd; }
tr.mat-mdc-row.type-fachspezifikum > td:first-child { border-left-color: #198754; }
tr.mat-mdc-row.type-mediation > td:first-child { border-left-color: #fd7e14; }
tr.mat-mdc-row.entry-today {
font-weight: 700;
}
tr.mat-mdc-row.entry-today > td {
box-shadow: inset 0 3px 0 #7c3aed, inset 0 -3px 0 #7c3aed;
}
tr.mat-mdc-row.entry-today > td:first-child {
border-left:6px solid #7c3aed;
}
tr.mat-mdc-row.entry-today > td:last-child {
border-right:3px solid #7c3aed;
}
:host-context(body.dark) tr.mat-mdc-row.type-propaedeutikum:nth-of-type(odd) > td { background-color: rgba(62, 139, 255, 0.12); }
:host-context(body.dark) tr.mat-mdc-row.type-propaedeutikum:nth-of-type(even) > td { background-color: rgba(62, 139, 255, 0.18); }
:host-context(body.dark) tr.mat-mdc-row.type-fachspezifikum:nth-of-type(odd) > td { background-color: rgba(53, 184, 116, 0.12); }
:host-context(body.dark) tr.mat-mdc-row.type-fachspezifikum:nth-of-type(even) > td { background-color: rgba(53, 184, 116, 0.18); }
:host-context(body.dark) tr.mat-mdc-row.type-mediation:nth-of-type(odd) > td { background-color: rgba(255, 143, 51, 0.13); }
:host-context(body.dark) tr.mat-mdc-row.type-mediation:nth-of-type(even) > td { background-color: rgba(255, 143, 51, 0.2); }
:host-context(body.dark) tr.mat-mdc-row.type-propaedeutikum:hover > td { background-color: rgba(62, 139, 255, 0.25); }
:host-context(body.dark) tr.mat-mdc-row.type-fachspezifikum:hover > td { background-color: rgba(53, 184, 116, 0.25); }
:host-context(body.dark) tr.mat-mdc-row.type-mediation:hover > td { background-color: rgba(255, 143, 51, 0.27); }
:host-context(body.dark) tr.mat-mdc-row.entry-today > td {
box-shadow: inset 0 3px 0 #b794f4, inset 0 -3px 0 #b794f4;
}
:host-context(body.dark) tr.mat-mdc-row.entry-today > td:first-child { border-left-color:#b794f4; }
:host-context(body.dark) tr.mat-mdc-row.entry-today > td:last-child { border-right-color:#b794f4; }
:host-context(body.dark) .edit-action { color:#8bb7ff;background:rgba(62,139,255,.2); }
:host-context(body.dark) .delete-action { color:#ff9b9b;background:rgba(255,90,90,.18); }
@media (max-width: 700px) {
.filters mat-form-field {
width: 100%;
}
.mat-column-details {
display: none;
}
}
`,
],
})
export class EntriesComponent implements OnInit {
readonly entries = signal<Entry[]>([]);
readonly loading = signal(false);
readonly columns = ["date", "time", "type", "art", "details", "distance", "allowance", "cost", "training", "actions"];
readonly types = ["propädeutikum", "fachspezifikum", "mediation"];
readonly arts: Record<string,string[]> = {propädeutikum:["Praktikum","Selbsterfahrung","Supervision","Fortbildung","Semesterkosten"],fachspezifikum:["Praktikum","Selbsterfahrung","Supervision","Fortbildung","Semesterkosten","Gruppenselbsterfahrung","Theorie/Methodikseminare","Peergruppensupervision","Einzel-/Kleingruppensupervision","Eigenständige Tätigkeit"],mediation:["Präsenzmodul","Selbsterfahrung","Supervision","Peergruppenarbeit","Fallarbeit","Praxisseminare","Literatur- und Selbststudium","Fortbildung","Semesterkosten"]};
readonly running=signal<Entry|null>(null);
readonly clock=signal(Date.now());
timerType="propädeutikum"; timerArt="Praktikum"; lunchBreak=false;
search = "";
typ = "";
minYear: number | "" = "";
maxYear: number | "" = "";
constructor(private api: ApiService) {}
ngOnInit() {
this.load();
setInterval(()=>this.clock.set(Date.now()),30_000);
}
load() {
this.loading.set(true);
this.api.entries({ search: this.search, typ: this.typ }).subscribe({
next: (v) => {
this.entries.set(v);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
this.api.dashboard().subscribe((d:Dashboard)=>this.running.set(d.running_entry));
}
timerArts(){const available=this.arts[this.timerType]||[];if(!available.includes(this.timerArt))this.timerArt=available[0];return available;}
startTimer(){this.api.startTimer(this.timerType,this.timerArt).subscribe(e=>{this.running.set(e);this.load();});}
stopTimer(entry:Entry){this.api.stopTimer(entry.id,this.lunchBreak).subscribe(()=>{this.running.set(null);this.lunchBreak=false;this.load();});}
elapsed(entry:Entry){this.clock();if(!entry.start_time)return "0 h 0 min";const minutes=Math.max(0,Math.floor((Date.now()-new Date(entry.start_time).getTime())/60000));return `${Math.floor(minutes/60)} h ${minutes%60} min`;}
reset() {
this.search = "";
this.typ = "";
this.minYear = "";
this.maxYear = "";
this.load();
}
years(){return [...new Set(this.entries().map(e=>Number(e.date.slice(0,4))))].sort((a,b)=>b-a);}
visibleEntries(){return this.entries().filter(e=>{const year=Number(e.date.slice(0,4));return(!this.minYear||year>=this.minYear)&&(!this.maxYear||year<=this.maxYear);});}
entryRowClass(entry: Entry): string {
const normalized = entry.praktikums_typ
?.toLocaleLowerCase("de-AT")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const type = normalized === "propadeutikum"
? "propaedeutikum"
: normalized?.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const classes = [`type-${type || "unknown"}`];
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
if (entry.date === today) {
classes.push("entry-today");
}
return classes.join(" ");
}
remove(e: Entry) {
if (confirm(`Eintrag vom ${e.date} wirklich löschen?`))
this.api.deleteEntry(e.id).subscribe(() => this.load());
}
download() {
this.api.exportCsv().subscribe((blob) => {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `eintraege-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(a.href);
});
}
}