Initial
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
116
listify-client/src/app/templates/templates.component.html
Normal file
116
listify-client/src/app/templates/templates.component.html
Normal 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>
|
||||
142
listify-client/src/app/templates/templates.component.ts
Normal file
142
listify-client/src/app/templates/templates.component.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
76
listify-client/src/app/templates/templates.models.ts
Normal file
76
listify-client/src/app/templates/templates.models.ts
Normal 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;
|
||||
}
|
||||
71
listify-client/src/app/templates/templates.service.ts
Normal file
71
listify-client/src/app/templates/templates.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user