feat(opportunities): Papierkorb statt endgueltigem Loeschen

Das Loeschsymbol gab es in der Kachelansicht schon, es war aber bis zum
Ueberfahren durchsichtig - ein Knopf, den man nur findet, wenn man ihn kennt.
Jetzt ist er sichtbar, in der Liste ebenso, und die Pipeline-Karte hat ihn
ueberhaupt zum ersten Mal.

Geloescht wird nicht mehr endgueltig: DELETE setzt deleted_at, die Zeile bleibt.
An einer Opportunity haengen Ansprechpartner, Verlauf und Mailzuordnungen, und
ein Fehlklick soll das nicht kosten. Der Papierkorb ist ein Unterpunkt unter
Opportunities und zeigt, wann und von wem geloescht wurde und wie lange der
Eintrag noch liegt. Zurueckholen darf, wer auch loeschen darf; das Leeren jeder
Angemeldete - was dort liegt, hat schon jemand mit den noetigen Rechten
weggeworfen. Beides fragt vorher nach.

Nach 90 Tagen raeumt das Backend selbst auf, beim Abruf der Liste und hoechstens
einmal pro Stunde je Prozess - dasselbe Muster wie in der Akquise. Ein eigener
Zeitplan waere eine weitere Stelle, die ausfallen kann, und die Abfragen filtern
Faelliges ohnehin heraus.

Im Papierkorb liegende Opportunities sind aus allen Listen, Summen und
Zuordnungsdialogen heraus und lassen sich nicht bearbeiten (409) - erst
zurueckholen, dann aendern.

Nebenbei: der Header bekommt seinen Titel jetzt ueber viewTitle(). Der
Ansichtsname musste eindeutig sein ("Opportunity-Papierkorb", weil die Mails
schon einen Papierkorb haben), angezeigt wird beides als "Papierkorb".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-18 14:16:10 +02:00
co-authored by Claude Opus 5
parent 2717462c3a
commit 1ac0e2d2c7
16 changed files with 503 additions and 30 deletions
+7 -1
View File
@@ -6,6 +6,7 @@ import { Leads } from './components/leads/Leads';
import { MailPoolLeads } from './components/leads/MailPoolLeads';
import { Acquisitions } from './components/acquisition/Acquisitions';
import { Opportunities } from './components/opportunities/Opportunities';
import { OpportunityTrash } from './components/opportunities/OpportunityTrash';
import { Calendar } from './components/calendar/Calendar';
import { parseSqlDateTime } from './components/calendar/calendarUtils';
import { Settings } from './components/settings/Settings';
@@ -22,6 +23,7 @@ import {
OPEN_STAGES,
formatEuro,
opportunityVolume,
viewTitle,
type AppUser,
type CalendarEvent,
type Lead,
@@ -140,6 +142,7 @@ const App: React.FC = () => {
// mit, deshalb steht hier nur, worum es auf der Seite geht.
const STATIC_SUBTITLES: Partial<Record<ViewState, string>> = {
Akquise: 'Gefundene Projekte - eintragen, prüfen, zur Opportunity machen',
'Opportunity-Papierkorb': 'Gelöschte Opportunities - nach 90 Tagen endgültig entfernt',
Archiv: 'Akquise-Einträge älter als 14 Tage - nach 90 Tagen werden sie gelöscht',
Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen',
Papierkorb: 'Im CRM gelöschte Mails - im Postfach crm@perinco.de bleiben sie erhalten',
@@ -182,7 +185,7 @@ const App: React.FC = () => {
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Header
title={currentView}
title={viewTitle(currentView)}
subtitle={subtitle}
onToggleSidebar={() => setIsSidebarOpen((open) => !open)}
/>
@@ -192,6 +195,9 @@ const App: React.FC = () => {
{currentView === 'Archiv' && (
<Acquisitions view="archiv" reload={() => loadData(true)} />
)}
{currentView === 'Opportunity-Papierkorb' && (
<OpportunityTrash reload={() => loadData(true)} />
)}
{currentView === 'Opportunities' && (
<Opportunities
opportunities={opportunities}
+2 -2
View File
@@ -1,9 +1,9 @@
import React from 'react';
import type { ViewState } from '../types';
import { useTheme } from '../lib/theme';
interface HeaderProps {
title: ViewState;
/** Beschriftung der Ansicht - meist ihr Name, siehe viewTitle(). */
title: string;
subtitle?: string;
onToggleSidebar: () => void;
}
+26 -5
View File
@@ -1,5 +1,12 @@
import React, { useEffect, useState } from 'react';
import { ACQUISITION_VIEWS, CONTACT_VIEWS, MAIL_VIEWS, SCANNER_VIEWS, type ViewState } from '../types';
import {
ACQUISITION_VIEWS,
CONTACT_VIEWS,
MAIL_VIEWS,
OPPORTUNITY_VIEWS,
SCANNER_VIEWS,
type ViewState,
} from '../types';
import { UserAvatar } from './ui/UserAvatar';
import { useAuth } from '../lib/auth';
@@ -73,6 +80,23 @@ const ACQUISITION_GROUP: MenuGroup = {
const TRASH_ICON =
'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16';
const OPPORTUNITY_ICON =
'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z';
/**
* Opportunities mit ihrem Papierkorb. Geloeschte Opportunities liegen dort 90
* Tage, damit ein Fehlklick nicht Verlauf, Ansprechpartner und Mailbezuege
* kostet.
*/
const OPPORTUNITIES_GROUP: MenuGroup = {
item: { name: 'Opportunities', icon: OPPORTUNITY_ICON },
views: OPPORTUNITY_VIEWS,
children: [
{ name: 'Opportunities', label: 'Alle', icon: OPPORTUNITY_ICON },
{ name: 'Opportunity-Papierkorb', label: 'Papierkorb', icon: TRASH_ICON },
],
};
/**
* Mails. Darunter klappt der Papierkorb auf: Mails, die im CRM geloescht wurden.
* Im Postfach bleiben sie stehen - der Abgleich holt sie aber nicht wieder.
@@ -119,10 +143,7 @@ const SCANNER_GROUP: MenuGroup = {
// Einstellungen bleibt separat am unteren Rand.
const CATEGORY_ENTRIES: (MenuItem | MenuGroup)[] = [
ACQUISITION_GROUP,
{
name: 'Opportunities',
icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z',
},
OPPORTUNITIES_GROUP,
{
// Der Terminkalender heisst im CRM "Interviews": genutzt wird er fuer
// Gespraechstermine, nicht als allgemeiner Kalender.
@@ -202,7 +202,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
if (!toDelete) return;
try {
await opportunitiesApi.remove(toDelete.id);
addToast(`"${toDelete.title}" wurde gelöscht.`, 'success');
addToast(`„${toDelete.title}" liegt im Papierkorb.`, 'success');
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
@@ -401,6 +401,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
opportunities={filtered}
onEdit={openEdit}
onStageChange={changeStage}
onDelete={setToDelete}
/>
)}
@@ -417,7 +418,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
{toDelete && (
<ConfirmDialog
title="Opportunity löschen?"
message={`"${toDelete.title}" wird unwiderruflich entfernt - inklusive der Zuordnung der Ansprechpartner. Die Kontakte selbst bleiben erhalten.`}
message={`„${toDelete.title}" wandert in den Papierkorb und verschwindet aus dieser Liste. Von dort lässt sie sich zurückholen; nach 90 Tagen wird sie endgültig gelöscht.`}
onConfirm={confirmDelete}
onCancel={() => setToDelete(null)}
/>
@@ -67,12 +67,15 @@ export const OpportunityGrid: React.FC<OpportunityGridProps> = ({
</p>
<p className="truncate text-xs text-ink-400">{opportunity.company || 'Ohne Firma'}</p>
</div>
{/* Sichtbar, nicht erst beim Ueberfahren: ein Knopf, den man nur findet,
wenn man ihn schon kennt, ist keiner. */}
<button
onClick={(event) => {
event.stopPropagation();
onDelete(opportunity);
}}
className="shrink-0 rounded-lg p-1.5 text-ink-300 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
className="shrink-0 rounded-lg p-1.5 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
title="In den Papierkorb legen"
aria-label={`${opportunity.title} löschen`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -245,7 +245,8 @@ export const OpportunityList: React.FC<OpportunityListProps> = ({
event.stopPropagation();
onDelete(opportunity);
}}
className="rounded-lg p-2 text-ink-300 transition hover:bg-red-50 hover:text-red-600"
className="rounded-lg p-2 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
title="In den Papierkorb legen"
aria-label={`${opportunity.title} löschen`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -15,6 +15,8 @@ interface OpportunityPipelineProps {
onEdit: (opportunity: Opportunity) => void;
/** Verschiebt eine Karte in eine andere Phase. */
onStageChange: (opportunity: Opportunity, stage: OpportunityStage) => void;
/** Legt die Opportunity in den Papierkorb. */
onDelete: (opportunity: Opportunity) => void;
}
/**
@@ -25,6 +27,7 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
opportunities,
onEdit,
onStageChange,
onDelete,
}) => {
const [draggedId, setDraggedId] = useState<number | null>(null);
const [hoverStage, setHoverStage] = useState<OpportunityStage | null>(null);
@@ -110,12 +113,34 @@ export const OpportunityPipeline: React.FC<OpportunityPipelineProps> = ({
draggedId === opportunity.id ? 'opacity-40' : ''
}`}
>
<p className="truncate text-xs font-extrabold text-ink-900">
{opportunity.title}
</p>
<p className="truncate text-[11px] text-ink-400">
{opportunity.company || 'Ohne Firma'}
</p>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-extrabold text-ink-900">
{opportunity.title}
</p>
<p className="truncate text-[11px] text-ink-400">
{opportunity.company || 'Ohne Firma'}
</p>
</div>
<button
onClick={(event) => {
event.stopPropagation();
onDelete(opportunity);
}}
className="shrink-0 rounded-lg p-1 text-ink-400 transition hover:bg-red-50 hover:text-red-600"
title="In den Papierkorb legen"
aria-label={`${opportunity.title} löschen`}
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-xs font-bold text-ink-800">
@@ -0,0 +1,234 @@
import React, { useCallback, useEffect, useState } from 'react';
import { StageBadge } from './opportunityUi';
import { ConfirmDialog } from '../ui/Modal';
import { UserAvatar } from '../ui/UserAvatar';
import { useToast } from '../Toast';
import { opportunitiesApi } from '../../lib/api';
import { formatEuro, opportunityVolume, type Opportunity } from '../../types';
/*
* Papierkorb der Opportunities.
*
* Bewusst eine eigene, schlanke Ansicht und nicht die Opportunity-Liste mit einem
* Schalter: hier zaehlt nur, was weg ist, wer es weggeworfen hat und wann - Filter,
* Sortierung und drei Darstellungen waeren dafuer Beiwerk.
*
* Nach 90 Tagen raeumt das Backend selbst auf; das Leeren von Hand ist der
* schnellere Weg und fragt vorher nach.
*/
interface OpportunityTrashProps {
/** Daten der App neu laden - nach einer Wiederherstellung. */
reload: () => void;
}
/** Wie lange etwas im Papierkorb liegt - der Server loescht nach 90 Tagen. */
const DELETE_AFTER_DAYS = 90;
/** MariaDB-DATETIME steht in UTC; ohne das angehaengte Z liest der Browser Ortszeit. */
function parseUtc(value: string | null): Date | null {
if (!value) return null;
const date = new Date(`${value.replace(' ', 'T')}Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDeleted(value: string | null): { label: string; remaining: string } {
const date = parseUtc(value);
if (!date) return { label: '—', remaining: '' };
const days = Math.floor((Date.now() - date.getTime()) / 86_400_000);
const left = Math.max(0, DELETE_AFTER_DAYS - days);
return {
label: date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }),
remaining: left === 0 ? 'wird beim nächsten Aufräumen gelöscht' : `noch ${left} Tage`,
};
}
export const OpportunityTrash: React.FC<OpportunityTrashProps> = ({ reload }) => {
const { addToast } = useToast();
const [entries, setEntries] = useState<Opportunity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null);
const [isEmptyOpen, setIsEmptyOpen] = useState(false);
const load = useCallback(
async (silent = false) => {
if (!silent) setIsLoading(true);
try {
setEntries(await opportunitiesApi.list({ trash: true }));
} catch (err) {
addToast(err instanceof Error ? err.message : 'Papierkorb nicht abrufbar.', 'error');
} finally {
setIsLoading(false);
}
},
[addToast]
);
useEffect(() => {
load();
}, [load]);
const restore = async (opportunity: Opportunity) => {
setBusyId(opportunity.id);
try {
await opportunitiesApi.restore(opportunity.id);
addToast(`„${opportunity.title}" wiederhergestellt.`, 'success');
await load(true);
// Die Opportunity-Liste der App kennt den Eintrag noch nicht wieder.
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Wiederherstellen fehlgeschlagen.', 'error');
await load(true);
} finally {
setBusyId(null);
}
};
const emptyTrash = async () => {
setIsEmptyOpen(false);
try {
const result = await opportunitiesApi.emptyTrash();
addToast(
result.purged === 1
? '1 Opportunity endgültig gelöscht.'
: `${result.purged} Opportunities endgültig gelöscht.`,
'success'
);
await load(true);
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Leeren fehlgeschlagen.', 'error');
}
};
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-2xl border-l-4 border-l-ink-300 border border-ink-200 bg-ink-50 p-4 sm:flex-row sm:items-center">
<div className="min-w-0">
<p className="text-sm font-bold text-ink-700">
{entries.length === 0
? 'Der Papierkorb ist leer.'
: `${entries.length} ${
entries.length === 1 ? 'gelöschte Opportunity' : 'gelöschte Opportunities'
}`}
</p>
<p className="mt-1 text-xs text-ink-500">
Gelöschte Opportunities liegen hier {DELETE_AFTER_DAYS} Tage und lassen sich
zurückholen - mit Ansprechpartnern, Verlauf und Mailzuordnungen. Danach werden sie
automatisch entfernt.
</p>
</div>
<button
onClick={() => setIsEmptyOpen(true)}
disabled={entries.length === 0}
className="shrink-0 whitespace-nowrap rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:bg-red-50 hover:text-red-700 disabled:opacity-40 sm:ml-auto"
>
Papierkorb leeren
</button>
</div>
{isLoading && entries.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : entries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-ink-200 bg-surface px-6 py-16 text-center text-sm font-bold text-ink-400">
Hier liegt nichts. Gelöschte Opportunities erscheinen an dieser Stelle.
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
<div className="scrollbar-slim overflow-x-auto">
<table className="w-full min-w-[860px] text-left">
<thead className="border-b border-ink-200 bg-ink-50">
<tr>
{['Opportunity', 'Phase', 'Volumen', 'Gelöscht', 'Von', ''].map((label, index) => (
<th
key={label || `spalte-${index}`}
className={`px-4 py-3 text-[10px] font-extrabold uppercase tracking-wider text-ink-500 ${
label === 'Volumen' ? 'text-right' : ''
}`}
>
{label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-ink-100">
{entries.map((opportunity) => {
const deleted = formatDeleted(opportunity.deleted_at);
return (
<tr key={opportunity.id} className="transition hover:bg-ink-50">
<td className="max-w-sm px-4 py-3">
<p className="truncate text-sm font-bold text-ink-900">{opportunity.title}</p>
<p className="truncate text-xs text-ink-400">
{opportunity.company || 'Ohne Firma'}
{opportunity.contacts.length > 0 &&
` · ${opportunity.contacts.length} Ansprechpartner`}
</p>
</td>
<td className="px-4 py-3">
<StageBadge stage={opportunity.stage} />
</td>
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-bold text-ink-800">
{formatEuro(opportunityVolume(opportunity))}
</td>
<td className="whitespace-nowrap px-4 py-3">
<p className="text-sm tabular-nums text-ink-700">{deleted.label}</p>
<p className="text-[11px] text-ink-400">{deleted.remaining}</p>
</td>
<td className="whitespace-nowrap px-4 py-3">
{opportunity.deleted_by === null ? (
<span className="text-sm text-ink-400">—</span>
) : (
<div className="flex items-center gap-2">
<UserAvatar
user={{
id: opportunity.deleted_by,
name: opportunity.deleted_by_name ?? '?',
color: null,
avatar: null,
}}
size={24}
/>
<span className="text-sm text-ink-700">
{opportunity.deleted_by_name ?? 'unbekannt'}
</span>
</div>
)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-right">
<button
onClick={() => restore(opportunity)}
disabled={busyId === opportunity.id}
className="rounded-xl bg-brand-500 px-4 py-2 text-xs font-bold text-white shadow-card transition hover:bg-brand-600 disabled:opacity-50"
>
{busyId === opportunity.id ? 'Hole zurück...' : 'Wiederherstellen'}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{isEmptyOpen && (
<ConfirmDialog
title="Papierkorb endgültig leeren?"
message={`${entries.length} ${
entries.length === 1 ? 'Opportunity wird' : 'Opportunities werden'
} unwiderruflich gelöscht - mit der Zuordnung ihrer Ansprechpartner und ihrer Mails. Die Kontakte selbst und die Mails bleiben erhalten.`}
confirmLabel="Endgültig löschen"
onConfirm={emptyTrash}
onCancel={() => setIsEmptyOpen(false)}
/>
)}
</div>
);
};
+8 -1
View File
@@ -194,12 +194,19 @@ export type OpportunityInput = Partial<
};
export const opportunitiesApi = {
list: () => request<Opportunity[]>('/opportunities'),
/** `trash` zeigt genau den Papierkorb, sonst ist er ausgeblendet. */
list: (options: { trash?: boolean } = {}) =>
request<Opportunity[]>(`/opportunities${options.trash ? '?trash=true' : ''}`),
create: (input: OpportunityInput) =>
request<Opportunity>('/opportunities', { method: 'POST', body: JSON.stringify(input) }),
update: (id: number, input: OpportunityInput) =>
request<Opportunity>(`/opportunities/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
/** Legt die Opportunity in den Papierkorb - endgueltig entfernt sie erst das Leeren. */
remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }),
restore: (id: number) =>
request<Opportunity>(`/opportunities/${id}/restore`, { method: 'POST' }),
emptyTrash: () =>
request<{ ok: true; purged: number }>('/opportunities/trash/empty', { method: 'POST' }),
};
// ---------- Akquise ----------
+23
View File
@@ -2,6 +2,7 @@ export type ViewState =
| 'Akquise'
| 'Archiv'
| 'Opportunities'
| 'Opportunity-Papierkorb'
| 'Interviews'
| 'Kontakte'
| 'Aus Mails'
@@ -53,6 +54,24 @@ export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb'];
*/
export const ACQUISITION_VIEWS: ViewState[] = ['Akquise', 'Archiv'];
/**
* Opportunities mit ihrem Papierkorb. Der Wert heisst "Opportunity-Papierkorb",
* weil die Mails schon einen "Papierkorb" haben und der Ansichtsname eindeutig
* sein muss; angezeigt wird beides als "Papierkorb" (siehe VIEW_TITLES).
*/
export const OPPORTUNITY_VIEWS: ViewState[] = ['Opportunities', 'Opportunity-Papierkorb'];
/**
* Abweichende Beschriftung einer Ansicht. Ohne Eintrag ist der Name der Ansicht
* gleichzeitig ihr Titel - das gilt fuer alle bis auf die, deren Name nur
* intern eindeutig sein muss.
*/
export const VIEW_TITLES: Partial<Record<ViewState, string>> = {
'Opportunity-Papierkorb': 'Papierkorb',
};
export const viewTitle = (view: ViewState): string => VIEW_TITLES[view] ?? view;
export type Role = 'admin' | 'user';
export interface AppUser {
@@ -236,6 +255,9 @@ export interface Opportunity {
notes: string | null;
owner_id: number | null;
created_by: number | null;
/** Gesetzt = liegt im Papierkorb; nach 90 Tagen wird sie dort geloescht. */
deleted_at: string | null;
deleted_by: number | null;
created_at?: string;
updated_at?: string;
/** Angereichert vom Backend */
@@ -243,6 +265,7 @@ export interface Opportunity {
owner_name?: string | null;
owner_color?: string | null;
owner_avatar?: string | null;
deleted_by_name?: string | null;
contacts: OpportunityContact[];
}