This commit is contained in:
Bastian Wagner
2026-06-09 09:45:33 +02:00
commit 537c7cbbee
124 changed files with 27283 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
<div class="delete-dialog-icon">
<mat-icon aria-hidden="true">delete_forever</mat-icon>
</div>
<h2 mat-dialog-title>Template loeschen?</h2>
<mat-dialog-content>
<p>
<strong>{{ data.templateName }}</strong> wird dauerhaft geloescht. Bereits daraus erstellte
Listen bleiben erhalten.
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" mat-dialog-close>Abbrechen</button>
<button mat-flat-button type="button" color="warn" [mat-dialog-close]="true">
<mat-icon aria-hidden="true">delete</mat-icon>
Loeschen
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,36 @@
:host {
display: block;
padding-top: 1rem;
}
.delete-dialog-icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
margin: 0 auto 0.25rem;
border-radius: 50%;
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
color: var(--mat-sys-error);
}
.delete-dialog-icon mat-icon {
width: 32px;
height: 32px;
font-size: 32px;
}
h2[mat-dialog-title] {
padding-top: 0;
text-align: center;
}
mat-dialog-content p {
margin: 0;
color: var(--mat-sys-on-surface-variant);
text-align: center;
}
mat-dialog-actions {
padding-bottom: 1rem;
}

View File

@@ -0,0 +1,18 @@
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
export interface ConfirmDeleteDialogData {
templateName: string;
}
@Component({
selector: 'app-confirm-delete-dialog',
imports: [MatButtonModule, MatDialogModule, MatIconModule],
templateUrl: './confirm-delete-dialog.component.html',
styleUrl: './confirm-delete-dialog.component.scss',
})
export class ConfirmDeleteDialogComponent {
protected readonly data = inject<ConfirmDeleteDialogData>(MAT_DIALOG_DATA);
}

View File

@@ -0,0 +1,177 @@
<section class="template-detail-page">
<header class="detail-header">
<button mat-icon-button type="button" aria-label="Zurueck" (click)="backToTemplates()">
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
<div>
<h1>{{ template()?.name || (isCreateMode() ? 'Neues Template' : 'Template') }}</h1>
<p>{{ isCreateMode() ? 'Vorlage anlegen' : 'Vorlage bearbeiten' }}</p>
</div>
@if (canEditItems()) {
<div class="detail-actions">
<button mat-stroked-button type="button" [disabled]="copyingTemplate()" (click)="copyTemplateToList()">
@if (copyingTemplate()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">content_copy</mat-icon>
}
Als Liste
</button>
<button mat-icon-button type="button" aria-label="Template loeschen" [disabled]="deletingTemplate()" (click)="deleteTemplate()">
@if (deletingTemplate()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
</button>
</div>
}
</header>
@if (loading()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="40" />
<h2>Template wird geladen</h2>
</mat-card-content>
</mat-card>
} @else if (errorMessage()) {
<mat-card class="state-card error-state" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">error</mat-icon>
<h2>Template konnte nicht geladen werden</h2>
<p>{{ errorMessage() }}</p>
<button mat-stroked-button type="button" (click)="loadTemplate()">
<mat-icon aria-hidden="true">refresh</mat-icon>
Erneut laden
</button>
</mat-card-content>
</mat-card>
} @else {
<mat-card class="editor-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Details</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="templateForm" class="template-form" (ngSubmit)="saveTemplate()">
<mat-form-field appearance="outline">
<mat-label>Titel</mat-label>
<input matInput formControlName="name" autocomplete="off" />
@if (templateForm.controls.name.hasError('required')) {
<mat-error>Titel ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Beschreibung</mat-label>
<textarea matInput formControlName="description" rows="4"></textarea>
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="saving()">
@if (saving()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">save</mat-icon>
}
{{ isCreateMode() ? 'Template anlegen' : 'Speichern' }}
</button>
</form>
</mat-card-content>
</mat-card>
<mat-card class="editor-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Items</mat-card-title>
<mat-card-subtitle>
@if (canEditItems()) {
{{ template()?.items?.length || 0 }} Eintraege
@if (reordering()) {
- Reihenfolge wird gespeichert
}
} @else {
Nach dem Speichern verfuegbar
}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<form [formGroup]="itemForm" class="item-form" (ngSubmit)="addItem()">
<mat-form-field appearance="outline">
<mat-label>Neues Item</mat-label>
<input matInput formControlName="title" autocomplete="off" [disabled]="!canEditItems()" />
@if (itemForm.controls.title.hasError('required')) {
<mat-error>Item-Titel ist erforderlich.</mat-error>
}
</mat-form-field>
<mat-checkbox formControlName="required">Pflicht</mat-checkbox>
<button mat-flat-button type="submit" [disabled]="addingItem() || !canEditItems()">
@if (addingItem()) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">add</mat-icon>
}
Hinzufuegen
</button>
</form>
@if (!canEditItems()) {
<div class="inline-empty">
<mat-icon aria-hidden="true">save</mat-icon>
<span>Speichere das Template, bevor du Items hinzufuegst.</span>
</div>
} @else if (template()?.items?.length) {
<ul
class="detail-items"
cdkDropList
[cdkDropListData]="template()?.items || []"
(cdkDropListDropped)="reorderItems($event)"
>
@for (item of template()?.items; track item.id) {
<li cdkDrag [cdkDragDisabled]="reordering() || deletingItemId() === item.id">
<button
mat-icon-button
type="button"
class="drag-handle"
cdkDragHandle
aria-label="Item verschieben"
>
<mat-icon aria-hidden="true">drag_handle</mat-icon>
</button>
<mat-icon class="item-state-icon" aria-hidden="true">
{{ item.required ? 'radio_button_unchecked' : 'remove_circle_outline' }}
</mat-icon>
<span>{{ item.title }}</span>
<button
mat-icon-button
type="button"
[attr.aria-label]="item.title + ' entfernen'"
[disabled]="deletingItemId() === item.id"
(click)="deleteItem(item.id)"
>
@if (deletingItemId() === item.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
</button>
</li>
}
</ul>
} @else {
<div class="inline-empty">
<mat-icon aria-hidden="true">playlist_add</mat-icon>
<span>Noch keine Items.</span>
</div>
}
</mat-card-content>
</mat-card>
<a mat-button routerLink="/templates" class="secondary-back">
<mat-icon aria-hidden="true">arrow_back</mat-icon>
Zur Template-Uebersicht
</a>
}
</section>

View File

@@ -0,0 +1,176 @@
.template-detail-page {
display: grid;
gap: 1rem;
width: min(100%, 760px);
margin: 0 auto;
padding: 1rem;
}
.detail-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
}
.detail-header h1 {
overflow: hidden;
margin: 0;
font-size: 1.45rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-header p {
margin: 0.2rem 0 0;
color: var(--mat-sys-on-surface-variant);
}
.detail-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 0.35rem;
}
.detail-actions mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.editor-card,
.state-card {
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 94%, white);
}
.template-form,
.item-form {
display: grid;
gap: 0.75rem;
padding-top: 0.75rem;
}
.template-form mat-form-field,
.item-form mat-form-field {
width: 100%;
}
.template-form button[type='submit'],
.item-form button[type='submit'] {
min-height: 48px;
}
.template-form mat-progress-spinner,
.item-form mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.state-card mat-card-content {
display: grid;
justify-items: center;
gap: 0.65rem;
padding: 2rem 1rem;
text-align: center;
}
.state-card mat-icon {
width: 48px;
height: 48px;
color: var(--mat-sys-primary);
font-size: 48px;
}
.error-state mat-icon {
color: var(--mat-sys-error);
}
.detail-items {
display: grid;
gap: 0.5rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.detail-items li {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.35rem;
min-height: 52px;
padding: 0.35rem;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
background: var(--mat-sys-surface);
}
.detail-items li.cdk-drag-preview {
box-sizing: border-box;
box-shadow: var(--mat-sys-level3);
}
.detail-items li.cdk-drag-placeholder {
opacity: 0.3;
}
.detail-items.cdk-drop-list-dragging li:not(.cdk-drag-placeholder) {
transition: transform 180ms cubic-bezier(0, 0, 0.2, 1);
}
.drag-handle {
color: var(--mat-sys-on-surface-variant);
cursor: grab;
}
.drag-handle:active {
cursor: grabbing;
}
.item-state-icon {
color: var(--mat-sys-primary);
}
.detail-items span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-items button mat-progress-spinner {
display: inline-flex;
}
.inline-empty {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
color: var(--mat-sys-on-surface-variant);
}
.secondary-back {
justify-self: start;
}
@media (min-width: 701px) {
.template-detail-page {
gap: 1.25rem;
padding: 2rem;
}
.detail-header h1 {
font-size: 2rem;
}
.detail-actions {
grid-column: auto;
}
.item-form {
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
}
}

View File

@@ -0,0 +1,321 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { getAuthErrorMessage } from '../../auth/error-message';
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
import { ListTemplate } from '../templates.models';
import { TemplatesService } from '../templates.service';
@Component({
selector: 'app-template-detail',
imports: [
ReactiveFormsModule,
DragDropModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './template-detail.component.html',
styleUrl: './template-detail.component.scss',
})
export class TemplateDetailComponent implements OnInit {
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly templatesService = inject(TemplatesService);
protected readonly template = signal<ListTemplate | null>(null);
protected readonly isCreateMode = signal(false);
protected readonly loading = signal(true);
protected readonly saving = signal(false);
protected readonly addingItem = signal(false);
protected readonly deletingItemId = signal<string | null>(null);
protected readonly deletingTemplate = signal(false);
protected readonly copyingTemplate = signal(false);
protected readonly reordering = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly templateForm = this.formBuilder.group({
name: ['', [Validators.required]],
description: [''],
});
protected readonly itemForm = this.formBuilder.group({
title: ['', [Validators.required]],
required: [true],
});
protected readonly canEditItems = computed(() => Boolean(this.template()?.id));
ngOnInit(): void {
this.isCreateMode.set(this.templateId() === null);
if (this.isCreateMode()) {
this.loading.set(false);
this.templateForm.reset({ name: '', description: '' });
return;
}
this.loadTemplate();
}
protected loadTemplate(): void {
const templateId = this.templateId();
if (!templateId) {
this.errorMessage.set('Template wurde nicht gefunden.');
this.loading.set(false);
return;
}
this.loading.set(true);
this.errorMessage.set(null);
this.templatesService.getTemplate(templateId).subscribe({
next: (template) => {
this.setTemplate(template);
this.loading.set(false);
},
error: (error: unknown) => {
this.errorMessage.set(getAuthErrorMessage(error));
this.loading.set(false);
},
});
}
protected saveTemplate(): void {
const templateId = this.templateId();
if (this.templateForm.invalid) {
this.templateForm.markAllAsTouched();
return;
}
const formValue = this.templateForm.getRawValue();
const payload = {
name: formValue.name.trim(),
description: formValue.description.trim() || undefined,
};
this.saving.set(true);
const saveRequest =
this.isCreateMode() || !templateId
? this.templatesService.createTemplate(payload)
: this.templatesService.updateTemplate(templateId, payload);
saveRequest
.pipe(finalize(() => this.saving.set(false)))
.subscribe({
next: (template) => {
this.setTemplate(template);
if (this.isCreateMode()) {
this.isCreateMode.set(false);
void this.router.navigate(['/templates', template.id], {
replaceUrl: true,
});
}
this.snackBar.open('Template gespeichert.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected addItem(): void {
const templateId = this.templateId();
if (!templateId || !this.canEditItems() || this.itemForm.invalid) {
this.itemForm.markAllAsTouched();
return;
}
const formValue = this.itemForm.getRawValue();
this.addingItem.set(true);
this.templatesService
.addItem(templateId, {
title: formValue.title.trim(),
required: formValue.required,
})
.pipe(finalize(() => this.addingItem.set(false)))
.subscribe({
next: (template) => {
this.setTemplate(template);
this.itemForm.reset({ title: '', required: true });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected deleteItem(itemId: string): void {
const templateId = this.templateId();
if (!templateId || this.deletingItemId()) {
return;
}
this.deletingItemId.set(itemId);
this.templatesService
.deleteItem(templateId, itemId)
.pipe(finalize(() => this.deletingItemId.set(null)))
.subscribe({
next: (template) => {
this.setTemplate(template);
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected reorderItems(event: CdkDragDrop<NonNullable<ListTemplate['items']>>): void {
const templateId = this.templateId();
const currentTemplate = this.template();
if (
!templateId ||
!currentTemplate ||
this.reordering() ||
event.previousIndex === event.currentIndex
) {
return;
}
const previousItems = [...currentTemplate.items];
const reorderedItems = [...currentTemplate.items];
moveItemInArray(reorderedItems, event.previousIndex, event.currentIndex);
const optimisticTemplate = {
...currentTemplate,
items: reorderedItems.map((item, index) => ({ ...item, position: index })),
};
this.template.set(optimisticTemplate);
this.reordering.set(true);
this.templatesService
.reorderItems(templateId, {
itemIds: optimisticTemplate.items.map((item) => item.id),
})
.pipe(finalize(() => this.reordering.set(false)))
.subscribe({
next: (template) => {
this.setTemplate(template);
},
error: (error: unknown) => {
this.template.set({ ...currentTemplate, items: previousItems });
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected copyTemplateToList(): void {
const templateId = this.templateId();
if (!templateId || !this.canEditItems() || this.copyingTemplate()) {
return;
}
this.copyingTemplate.set(true);
this.templatesService
.createListFromTemplate(templateId)
.pipe(finalize(() => this.copyingTemplate.set(false)))
.subscribe({
next: () => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
});
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected deleteTemplate(): void {
const templateId = this.templateId();
const template = this.template();
if (
!templateId ||
!template ||
this.deletingTemplate()
) {
return;
}
this.dialog
.open<ConfirmDeleteDialogComponent, { templateName: string }, boolean>(
ConfirmDeleteDialogComponent,
{
data: { templateName: template.name },
maxWidth: '420px',
width: 'calc(100vw - 32px)',
},
)
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
return;
}
this.deletingTemplate.set(true);
this.templatesService
.deleteTemplate(templateId)
.pipe(finalize(() => this.deletingTemplate.set(false)))
.subscribe({
next: () => {
this.snackBar.open('Template geloescht.', 'OK', { duration: 3000 });
void this.router.navigateByUrl('/templates');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
});
}
protected async backToTemplates(): Promise<void> {
await this.router.navigateByUrl('/templates');
}
private setTemplate(template: ListTemplate): void {
this.template.set(template);
this.templateForm.reset({
name: template.name,
description: template.description ?? '',
});
}
private templateId(): string | null {
return this.route.snapshot.paramMap.get('templateId');
}
}

View File

@@ -0,0 +1,116 @@
<section class="workspace-page">
<header class="page-header">
<div>
<h1>Templates</h1>
<p>Vorlagen fuer wiederkehrende Listen.</p>
</div>
<a mat-flat-button routerLink="/templates/new">
<mat-icon aria-hidden="true">add</mat-icon>
Neues Template
</a>
</header>
@if (loading()) {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-progress-spinner mode="indeterminate" diameter="40" />
<h2>Templates werden geladen</h2>
</mat-card-content>
</mat-card>
} @else if (errorMessage()) {
<mat-card class="state-card error-state" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">error</mat-icon>
<h2>Templates konnten nicht geladen werden</h2>
<p>{{ errorMessage() }}</p>
<button mat-stroked-button type="button" (click)="loadTemplates()">
<mat-icon aria-hidden="true">refresh</mat-icon>
Erneut laden
</button>
</mat-card-content>
</mat-card>
} @else if (hasTemplates()) {
<div class="template-grid">
@for (template of templates(); track template.id) {
<mat-card class="template-card" appearance="outlined">
<mat-card-header>
<mat-card-title>{{ template.name }}</mat-card-title>
<mat-card-subtitle>{{ kindLabel(template.kind) }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
@if (template.description) {
<p class="template-description">{{ template.description }}</p>
}
<div class="template-meta">
<span>
<mat-icon aria-hidden="true">checklist</mat-icon>
{{ template.items.length }} Eintraege
</span>
<span>
<mat-icon aria-hidden="true">schedule</mat-icon>
{{ template.updatedAt | date: 'dd.MM.yyyy' }}
</span>
</div>
@if (template.items.length > 0) {
<ul class="template-items">
@for (item of template.items.slice(0, 4); track item.id) {
<li>
<mat-icon aria-hidden="true">
{{ item.required ? 'radio_button_unchecked' : 'remove_circle_outline' }}
</mat-icon>
<span>{{ item.title }}</span>
</li>
}
</ul>
}
</mat-card-content>
<mat-card-actions align="end">
<button
mat-button
type="button"
[disabled]="copyingTemplateId() === template.id"
(click)="copyTemplate(template)"
>
@if (copyingTemplateId() === template.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">content_copy</mat-icon>
}
Als Liste
</button>
<a mat-button [routerLink]="['/templates', template.id]">
<mat-icon aria-hidden="true">edit</mat-icon>
Bearbeiten
</a>
<button
mat-icon-button
type="button"
[attr.aria-label]="template.name + ' loeschen'"
[disabled]="deletingTemplateId() === template.id"
(click)="deleteTemplate(template)"
>
@if (deletingTemplateId() === template.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">delete</mat-icon>
}
</button>
</mat-card-actions>
</mat-card>
}
</div>
} @else {
<mat-card class="state-card" appearance="outlined">
<mat-card-content>
<mat-icon aria-hidden="true">dashboard_customize</mat-icon>
<h2>Noch keine Templates</h2>
<p>Erstelle dein erstes Template, um wiederkehrende Listen schneller anzulegen.</p>
</mat-card-content>
</mat-card>
}
</section>

View File

@@ -0,0 +1,142 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { finalize } from 'rxjs';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { getAuthErrorMessage } from '../auth/error-message';
import { ConfirmDeleteDialogComponent } from './confirm-delete-dialog/confirm-delete-dialog.component';
import { ListTemplate, ListTemplateKind } from './templates.models';
import { TemplatesService } from './templates.service';
@Component({
selector: 'app-templates',
imports: [
DatePipe,
RouterLink,
MatButtonModule,
MatCardModule,
MatDialogModule,
MatIconModule,
MatProgressSpinnerModule,
MatSnackBarModule,
],
templateUrl: './templates.component.html',
styleUrl: '../workspace-page.scss',
})
export class TemplatesComponent implements OnInit {
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
private readonly snackBar = inject(MatSnackBar);
private readonly templatesService = inject(TemplatesService);
protected readonly templates = signal<ListTemplate[]>([]);
protected readonly loading = signal(true);
protected readonly copyingTemplateId = signal<string | null>(null);
protected readonly deletingTemplateId = signal<string | null>(null);
protected readonly errorMessage = signal<string | null>(null);
protected readonly hasTemplates = computed(() => this.templates().length > 0);
ngOnInit(): void {
this.loadTemplates();
}
protected loadTemplates(): void {
this.loading.set(true);
this.errorMessage.set(null);
this.templatesService.listTemplates().subscribe({
next: (templates) => {
this.templates.set(templates);
this.loading.set(false);
},
error: (error: unknown) => {
this.errorMessage.set(getAuthErrorMessage(error));
this.loading.set(false);
},
});
}
protected kindLabel(kind: ListTemplateKind): string {
const labels: Record<ListTemplateKind, string> = {
packing: 'Packliste',
shopping: 'Einkauf',
todo: 'Todo',
custom: 'Eigene Vorlage',
};
return labels[kind];
}
protected copyTemplate(template: ListTemplate): void {
if (this.copyingTemplateId()) {
return;
}
this.copyingTemplateId.set(template.id);
this.templatesService
.createListFromTemplate(template.id)
.pipe(finalize(() => this.copyingTemplateId.set(null)))
.subscribe({
next: () => {
this.snackBar.open('Liste aus Template erstellt.', 'OK', {
duration: 3000,
});
void this.router.navigateByUrl('/lists');
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
}
protected deleteTemplate(template: ListTemplate): void {
if (this.deletingTemplateId()) {
return;
}
this.dialog
.open<ConfirmDeleteDialogComponent, { templateName: string }, boolean>(
ConfirmDeleteDialogComponent,
{
data: { templateName: template.name },
maxWidth: '420px',
width: 'calc(100vw - 32px)',
},
)
.afterClosed()
.subscribe((confirmed) => {
if (!confirmed) {
return;
}
this.deletingTemplateId.set(template.id);
this.templatesService
.deleteTemplate(template.id)
.pipe(finalize(() => this.deletingTemplateId.set(null)))
.subscribe({
next: () => {
this.templates.update((templates) =>
templates.filter(
(existingTemplate) => existingTemplate.id !== template.id,
),
);
this.snackBar.open('Template geloescht.', 'OK', { duration: 3000 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', {
duration: 5000,
});
},
});
});
}
}

View File

@@ -0,0 +1,76 @@
export type ListTemplateKind = 'packing' | 'shopping' | 'todo' | 'custom';
export interface ListTemplateItem {
id: string;
title: string;
notes?: string;
quantity?: number;
required: boolean;
position: number;
createdAt: string;
updatedAt: string;
}
export interface ListTemplate {
id: string;
ownerId: string;
name: string;
description?: string;
kind: ListTemplateKind;
items: ListTemplateItem[];
createdAt: string;
updatedAt: string;
}
export interface UserListItem {
id: string;
sourceTemplateItemId?: string;
title: string;
notes?: string;
quantity?: number;
required: boolean;
checked: boolean;
position: number;
createdAt: string;
updatedAt: string;
}
export interface UserList {
id: string;
ownerId: string;
sourceTemplateId?: string;
name: string;
description?: string;
kind: ListTemplateKind;
items: UserListItem[];
createdAt: string;
updatedAt: string;
}
export interface UpdateListTemplateRequest {
name?: string;
description?: string;
kind?: ListTemplateKind;
}
export interface CreateListTemplateRequest {
name: string;
description?: string;
kind?: ListTemplateKind;
}
export interface AddListTemplateItemRequest {
title: string;
notes?: string;
quantity?: number;
required?: boolean;
}
export interface ReorderListTemplateItemsRequest {
itemIds: string[];
}
export interface CreateListFromTemplateRequest {
name?: string;
description?: string;
}

View File

@@ -0,0 +1,71 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import {
AddListTemplateItemRequest,
CreateListFromTemplateRequest,
CreateListTemplateRequest,
ListTemplate,
ReorderListTemplateItemsRequest,
UpdateListTemplateRequest,
UserList,
} from './templates.models';
@Injectable({ providedIn: 'root' })
export class TemplatesService {
private readonly http = inject(HttpClient);
private readonly apiUrl = '/api/list-templates';
listTemplates(): Observable<ListTemplate[]> {
return this.http.get<ListTemplate[]>(this.apiUrl);
}
getTemplate(templateId: string): Observable<ListTemplate> {
return this.http.get<ListTemplate>(`${this.apiUrl}/${templateId}`);
}
createTemplate(data: CreateListTemplateRequest): Observable<ListTemplate> {
return this.http.post<ListTemplate>(this.apiUrl, data);
}
updateTemplate(
templateId: string,
data: UpdateListTemplateRequest,
): Observable<ListTemplate> {
return this.http.patch<ListTemplate>(`${this.apiUrl}/${templateId}`, data);
}
deleteTemplate(templateId: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${templateId}`);
}
createListFromTemplate(
templateId: string,
data: CreateListFromTemplateRequest = {},
): Observable<UserList> {
return this.http.post<UserList>(`${this.apiUrl}/${templateId}/lists`, data);
}
addItem(
templateId: string,
data: AddListTemplateItemRequest,
): Observable<ListTemplate> {
return this.http.post<ListTemplate>(`${this.apiUrl}/${templateId}/items`, data);
}
deleteItem(templateId: string, itemId: string): Observable<ListTemplate> {
return this.http.delete<ListTemplate>(
`${this.apiUrl}/${templateId}/items/${itemId}`,
);
}
reorderItems(
templateId: string,
data: ReorderListTemplateItemsRequest,
): Observable<ListTemplate> {
return this.http.patch<ListTemplate>(
`${this.apiUrl}/${templateId}/items/order`,
data,
);
}
}