feat: a trash for tickets

Deleting a ticket now sends it to the trash, for whoever opened it or an
admin: it disappears from boards, searches and statistics, cannot change,
and comes back from the ticket page or the trash page for 30 days. After
that it is deleted for good with the usual cascade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:47:30 +02:00
parent 71b807b94b
commit bec39da46d
49 changed files with 1671 additions and 9 deletions

View File

@@ -223,6 +223,9 @@ const NOBODY = '__none__';
description="Los tickets nuevos aparecen aquí, al final, hasta que alguien los coge."
/>
}
<p class="ts-backlog__trash">
<a class="ts-plain-link" routerLink="../trash">Papelera</a>
</p>
`,
styles: `
.ts-backlog {
@@ -275,6 +278,11 @@ const NOBODY = '__none__';
align-items: center;
font-size: 0.875rem;
}
.ts-backlog__trash {
margin: 1rem 0 0;
font-size: 0.875rem;
text-align: right;
}
.ts-backlog__pos {
color: var(--ts-muted);
font-size: 0.75rem;

View File

@@ -520,3 +520,14 @@ a.ts-key {
.ts-dev__when {
font-size: 0.8125rem;
}
.ts-trashed {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent);
}

View File

@@ -94,6 +94,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<a class="ts-back" routerLink="../../board">← Tablero</a>
@if (facade.issue(); as issue) {
@if (issue.trashedAt) {
<div class="ts-trashed" role="status">
En la papelera desde {{ ago(issue.trashedAt) }}: no aparece en el
tablero ni en las búsquedas, y se borrará del todo a los 30 días.
@if (canTrash(issue)) {
<pi-action-button
size="small"
color="primary"
text="Recuperar"
(execute)="facade.setTrashed(false)"
/>
}
</div>
}
<div class="ts-issue">
<section class="ts-issue__main">
<header class="ts-issue__header">
@@ -698,6 +712,15 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-label>Área</pi-label>
</pi-select-field>
@if (!issue.trashedAt && canTrash(issue)) {
<pi-action-button
size="small"
color="grey"
appearance="ghost"
text="Mover a la papelera"
(execute)="trash()"
/>
}
<dl class="ts-facts">
<dt>Reportado por</dt>
<dd>{{ issue.reporter.name }}</dd>
@@ -1122,6 +1145,23 @@ export class IssuePage {
}
/** A screenshot pasted anywhere on the ticket is attached to it. */
/** Whoever opened it, or whoever manages the project. */
protected canTrash(issue: { reporter: { id: string } }): boolean {
return (
this.project.canContribute() &&
(issue.reporter.id === this.facade.me() || this.project.canManage())
);
}
protected trash(): void {
if (
confirm(
'¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.',
)
)
this.facade.setTrashed(true);
}
/** `e`: edit the ticket (not while typing, nor with a drawer open). */
@HostListener('document:keydown', ['$event'])
protected onKey(event: KeyboardEvent): void {

View File

@@ -0,0 +1,130 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { PiActionButtonComponent, PiEmptyStateComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { IssueSummary, IssuesGateway } from '@task-stream/web/tracker/domain';
import { timeAgo } from '../ui/copy';
/** /p/:key/trash — tickets deleted in the last 30 days, to bring back. */
@Component({
selector: 'ts-trash-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiActionButtonComponent, PiEmptyStateComponent],
template: `
<div class="ts-section-header">
<p class="ts-muted">
Tickets borrados en los últimos 30 días. Después se borran del todo, con
sus comentarios.
</p>
</div>
@if (issues().length) {
<ul class="ts-trash ts-panel">
@for (issue of issues(); track issue.id) {
<li class="ts-trash__row">
<a class="ts-key" [routerLink]="['..', 'issues', issue.key]">{{
issue.key
}}</a>
<span class="ts-trash__title">{{ issue.title }}</span>
<span class="ts-muted">{{
issue.trashedAt ? ago(issue.trashedAt) : ''
}}</span>
@if (project.canContribute()) {
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Recuperar"
(execute)="restore(issue)"
/>
}
</li>
}
</ul>
} @else if (loaded()) {
<pi-empty-state
icon="trash"
title="La papelera está vacía"
description="Lo que se borre aparecerá aquí durante 30 días."
/>
}
`,
styles: `
.ts-trash {
margin: 0;
padding: 0.25rem 0;
list-style: none;
}
.ts-trash__row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 0.75rem;
align-items: center;
padding: 0.5rem 0.875rem;
border-top: 1px solid var(--ts-line);
&:first-child {
border-top: 0;
}
}
.ts-trash__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
})
export class TrashPage {
protected readonly project = inject(ProjectFacade);
private readonly gateway = inject(IssuesGateway);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly issues = signal<IssueSummary[]>([]);
protected readonly loaded = signal(false);
protected readonly ago = timeAgo;
constructor() {
effect(() => {
const projectId = this.project.project()?.id;
if (projectId) untracked(() => this.reload(projectId));
});
}
protected restore(issue: IssueSummary): void {
this.gateway
.restore(issue.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.toast.success(`${issue.key} recuperado`);
this.issues.update((list) => list.filter((i) => i.id !== issue.id));
},
error: (error) =>
this.toast.error(
(error as { message?: string })?.message?.split('\n')[0] ||
'No se pudo recuperar',
),
});
}
private reload(projectId: string): void {
this.gateway
.trashed(projectId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (issues) => {
this.issues.set(issues);
this.loaded.set(true);
},
error: () => this.loaded.set(true),
});
}
}

View File

@@ -62,6 +62,12 @@ export const trackerRoutes: Routes = [
loadComponent: () =>
import('./pages/epics.page').then((m) => m.EpicsPage),
},
{
path: 'trash',
title: 'Papelera · TaskStream',
loadComponent: () =>
import('./pages/trash.page').then((m) => m.TrashPage),
},
{
path: 'releases',
title: 'Releases · TaskStream',