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
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);
|
|
});
|
|
}
|
|
}
|