This commit is contained in:
Bastian Wagner
2026-06-26 10:37:35 +02:00
parent 671fd62ad8
commit f5345e6ff1
4 changed files with 490 additions and 19 deletions

View File

@@ -0,0 +1,146 @@
<div class="cleanup-dialog-heading">
<mat-icon aria-hidden="true">auto_fix_high</mat-icon>
<div>
<h2 mat-dialog-title>AI Cleanup angewendet</h2>
<p>{{ data.response.list.name }}</p>
</div>
</div>
<mat-dialog-content>
<section class="summary-grid" aria-label="Cleanup Zusammenfassung">
@for (item of summaryItems; track item.label) {
<div class="summary-tile">
<mat-icon aria-hidden="true">{{ item.icon }}</mat-icon>
<strong>{{ item.value }}</strong>
<span>{{ item.label }}</span>
</div>
}
</section>
@if (!hasChanges()) {
<section class="empty-state">
<mat-icon aria-hidden="true">task_alt</mat-icon>
<p>Die KI hat keine sinnvollen Aenderungen gefunden.</p>
</section>
}
@if (data.response.cleanup.listUpdate) {
<section class="change-section">
<h3>Listendetails</h3>
<div class="change-card">
@if (data.response.cleanup.listUpdate.name) {
<div class="field-change">
<span>Titel</span>
<p class="before">{{ data.before?.name || '-' }}</p>
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
<p class="after">{{ data.response.list.name }}</p>
</div>
}
@if (data.response.cleanup.listUpdate.description) {
<div class="field-change">
<span>Beschreibung</span>
<p class="before">{{ data.before?.description || '-' }}</p>
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
<p class="after">{{ data.response.list.description || '-' }}</p>
</div>
}
@if (data.response.cleanup.listUpdate.reason) {
<p class="reason">{{ data.response.cleanup.listUpdate.reason }}</p>
}
</div>
</section>
}
@if (data.response.cleanup.itemUpdates.length > 0) {
<section class="change-section">
<h3>Verbesserte Items</h3>
<div class="change-list">
@for (update of data.response.cleanup.itemUpdates; track update.itemId) {
<article class="change-card">
<header>
<mat-icon aria-hidden="true">rule</mat-icon>
<strong>{{ itemTitle(update.itemId) }}</strong>
</header>
@for (change of itemChanges(update); track change.label) {
<div class="field-change">
<span>{{ change.label }}</span>
<p class="before">{{ change.before }}</p>
<mat-icon aria-hidden="true">arrow_forward</mat-icon>
<p class="after">{{ change.after }}</p>
</div>
}
@if (update.reason) {
<p class="reason">{{ update.reason }}</p>
}
</article>
}
</div>
</section>
}
@if (data.response.cleanup.duplicateDeletions.length > 0) {
<section class="change-section">
<h3>Entfernte Duplikate</h3>
<div class="change-list">
@for (deletion of data.response.cleanup.duplicateDeletions; track deletion.itemId) {
<article class="change-card duplicate-card">
<header>
<mat-icon aria-hidden="true">content_copy_off</mat-icon>
<strong>{{ itemTitle(deletion.itemId) }}</strong>
</header>
<p>
Entfernt, weil <strong>{{ itemTitle(deletion.keptItemId) }}</strong>
erhalten bleibt.
</p>
@if (deletion.reason) {
<p class="reason">{{ deletion.reason }}</p>
}
</article>
}
</div>
</section>
}
@if (data.response.cleanup.itemAdditions.length > 0) {
<section class="change-section">
<h3>Neue Items</h3>
<div class="change-list">
@for (addition of data.response.cleanup.itemAdditions; track addition.title) {
<article class="change-card addition-card">
<header>
<mat-icon aria-hidden="true">playlist_add</mat-icon>
<strong>{{ addition.title }}</strong>
</header>
@if (addition.notes || addition.quantity || !addition.required) {
<p>
@if (addition.quantity) {
Menge: {{ addition.quantity }}
}
@if (addition.notes) {
{{ addition.quantity ? '- ' : '' }}{{ addition.notes }}
}
@if (!addition.required) {
{{ addition.quantity || addition.notes ? '- ' : '' }}Optional
}
</p>
}
@if (addition.reason) {
<p class="reason">{{ addition.reason }}</p>
}
</article>
}
</div>
</section>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-flat-button type="button" mat-dialog-close>
<mat-icon aria-hidden="true">check</mat-icon>
Schliessen
</button>
</mat-dialog-actions>

View File

@@ -0,0 +1,192 @@
.cleanup-dialog-heading {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem 0;
}
.cleanup-dialog-heading > mat-icon {
width: 42px;
height: 42px;
color: var(--mat-sys-primary);
font-size: 42px;
}
.cleanup-dialog-heading h2,
.cleanup-dialog-heading p {
margin: 0;
}
.cleanup-dialog-heading p {
overflow: hidden;
color: var(--mat-sys-on-surface-variant);
text-overflow: ellipsis;
white-space: nowrap;
}
mat-dialog-content {
display: grid;
gap: 1rem;
min-width: min(760px, calc(100vw - 64px));
max-height: min(72vh, 760px);
padding-top: 1rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.summary-tile {
display: grid;
justify-items: center;
gap: 0.25rem;
min-width: 0;
padding: 0.75rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-primary) 7%, var(--mat-sys-surface));
text-align: center;
}
.summary-tile mat-icon {
color: var(--mat-sys-primary);
}
.summary-tile strong {
font-size: 1.35rem;
}
.summary-tile span {
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
}
.change-section {
display: grid;
gap: 0.6rem;
}
.change-section h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.change-list {
display: grid;
gap: 0.65rem;
}
.change-card {
display: grid;
gap: 0.55rem;
min-width: 0;
padding: 0.8rem;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 74%, transparent);
border-radius: 8px;
background: var(--mat-sys-surface);
}
.change-card header {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.change-card header strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.change-card header mat-icon {
color: var(--mat-sys-primary);
}
.change-card p {
margin: 0;
overflow-wrap: anywhere;
}
.field-change {
display: grid;
grid-template-columns: 7rem minmax(0, 1fr) auto minmax(0, 1fr);
align-items: start;
gap: 0.5rem;
}
.field-change span {
color: var(--mat-sys-on-surface-variant);
font-size: 0.83rem;
}
.field-change mat-icon {
width: 18px;
height: 18px;
color: var(--mat-sys-outline);
font-size: 18px;
}
.before,
.after {
min-width: 0;
padding: 0.45rem 0.55rem;
border-radius: 6px;
overflow-wrap: anywhere;
}
.before {
background: color-mix(in srgb, var(--mat-sys-error) 7%, var(--mat-sys-surface));
}
.after {
background: color-mix(in srgb, var(--mat-sys-primary) 8%, var(--mat-sys-surface));
}
.reason {
color: var(--mat-sys-on-surface-variant);
font-size: 0.88rem;
}
.duplicate-card {
border-color: color-mix(in srgb, var(--mat-sys-error) 32%, var(--mat-sys-outline-variant));
}
.addition-card {
border-color: color-mix(in srgb, var(--mat-sys-primary) 32%, var(--mat-sys-outline-variant));
}
.empty-state {
display: grid;
justify-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
color: var(--mat-sys-on-surface-variant);
text-align: center;
}
.empty-state mat-icon {
color: var(--mat-sys-primary);
}
@media (max-width: 700px) {
mat-dialog-content {
min-width: 0;
}
.summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field-change {
grid-template-columns: 1fr;
}
.field-change mat-icon {
transform: rotate(90deg);
}
}

View File

@@ -0,0 +1,135 @@
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';
import {
ApplyListCleanupResponse,
ListCleanupItemUpdate,
UserList,
UserListItem,
} from '../lists.models';
export interface ListCleanupResultDialogData {
before: UserList | null;
response: ApplyListCleanupResponse;
}
@Component({
selector: 'app-list-cleanup-result-dialog',
imports: [MatButtonModule, MatDialogModule, MatIconModule],
templateUrl: './list-cleanup-result-dialog.component.html',
styleUrl: './list-cleanup-result-dialog.component.scss',
})
export class ListCleanupResultDialogComponent {
protected readonly data = inject<ListCleanupResultDialogData>(MAT_DIALOG_DATA);
protected readonly summaryItems = [
{
icon: 'edit_note',
label: 'Details',
value: this.data.response.summary.listUpdated ? 1 : 0,
},
{
icon: 'rule',
label: 'Verbessert',
value: this.data.response.summary.itemsUpdated,
},
{
icon: 'content_copy_off',
label: 'Duplikate',
value: this.data.response.summary.duplicatesDeleted,
},
{
icon: 'playlist_add',
label: 'Neu',
value: this.data.response.summary.itemsAdded,
},
];
protected hasChanges(): boolean {
const summary = this.data.response.summary;
return (
summary.listUpdated ||
summary.itemsUpdated > 0 ||
summary.duplicatesDeleted > 0 ||
summary.itemsAdded > 0
);
}
protected beforeItem(itemId: string): UserListItem | undefined {
return this.data.before?.items.find((item) => item.id === itemId);
}
protected afterItem(itemId: string): UserListItem | undefined {
return this.data.response.list.items.find((item) => item.id === itemId);
}
protected itemTitle(itemId: string): string {
return (
this.beforeItem(itemId)?.title ??
this.afterItem(itemId)?.title ??
'Unbekanntes Item'
);
}
protected itemChanges(update: ListCleanupItemUpdate): Array<{
label: string;
before: string;
after: string;
}> {
const before = this.beforeItem(update.itemId);
const after = this.afterItem(update.itemId);
return [
update.title !== undefined
? {
label: 'Titel',
before: before?.title ?? '-',
after: after?.title ?? update.title,
}
: null,
update.notes !== undefined
? {
label: 'Notizen',
before: before?.notes ?? '-',
after: after?.notes ?? update.notes,
}
: null,
update.quantity !== undefined
? {
label: 'Menge',
before: this.displayQuantity(before?.quantity),
after: this.displayQuantity(after?.quantity ?? update.quantity),
}
: null,
update.required !== undefined
? {
label: 'Pflicht',
before: this.displayBoolean(before?.required),
after: this.displayBoolean(after?.required ?? update.required),
}
: null,
].filter(
(
change,
): change is {
label: string;
before: string;
after: string;
} => change !== null,
);
}
protected displayQuantity(value: number | undefined): string {
return typeof value === 'number' ? String(value) : '-';
}
protected displayBoolean(value: boolean | undefined): string {
if (value === undefined) {
return '-';
}
return value ? 'Ja' : 'Nein';
}
}

View File

@@ -346,6 +346,7 @@ export class ListDetailComponent implements OnInit {
protected applyCleanup(): void { protected applyCleanup(): void {
const listId = this.currentListId(); const listId = this.currentListId();
const listBeforeCleanup = this.list();
if (!listId || this.cleaningList()) { if (!listId || this.cleaningList()) {
return; return;
@@ -360,9 +361,7 @@ export class ListDetailComponent implements OnInit {
this.setList(response.list, !this.showEditor()); this.setList(response.list, !this.showEditor());
this.itemSuggestions.set([]); this.itemSuggestions.set([]);
this.suggestionsLoaded.set(false); this.suggestionsLoaded.set(false);
this.snackBar.open(this.cleanupSummary(response), 'OK', { void this.openCleanupResultDialog(listBeforeCleanup, response);
duration: 4000,
});
}, },
error: (error: unknown) => { error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 }); this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
@@ -644,22 +643,21 @@ export class ListDetailComponent implements OnInit {
return value.trim().replace(/\s+/g, ' ').toLowerCase(); return value.trim().replace(/\s+/g, ' ').toLowerCase();
} }
private cleanupSummary(response: ApplyListCleanupResponse): string { private async openCleanupResultDialog(
const parts = [ before: UserList | null,
response.summary.listUpdated ? 'Details aktualisiert' : null, response: ApplyListCleanupResponse,
response.summary.itemsUpdated > 0 ): Promise<void> {
? `${response.summary.itemsUpdated} Items verbessert` const { ListCleanupResultDialogComponent } = await import(
: null, '../list-cleanup-result-dialog/list-cleanup-result-dialog.component'
response.summary.duplicatesDeleted > 0 );
? `${response.summary.duplicatesDeleted} Duplikate entfernt`
: null,
response.summary.itemsAdded > 0
? `${response.summary.itemsAdded} Items hinzugefuegt`
: null,
].filter((part): part is string => part !== null);
return parts.length > 0 this.dialog.open(ListCleanupResultDialogComponent, {
? `Cleanup angewendet: ${parts.join(', ')}.` data: {
: 'Cleanup geprueft: keine sinnvollen Aenderungen gefunden.'; before,
response,
},
maxWidth: '900px',
width: 'calc(100vw - 32px)',
});
} }
} }