feat(akquise): Archiv bearbeiten und wiederherstellen

Im Archiv setzte der Stift nur `editing` - das Formular stand dort nicht,
also passierte sichtbar nichts. Es wird jetzt auch im Archiv gezeigt,
aber nur beim Bearbeiten: eingetragen wird weiterhin im Eingang.

Dazu ein Weg zurueck. `POST /api/acquisitions/:id/restore` setzt
`found_at` auf heute und leert `converted_at` samt `opportunity_id` -
ohne Letzteres haelt die Uebernahme einen Rueckfaeller im Archiv fest,
gleich welches Datum er traegt. Eine Spalte "wiederhergestellt" gibt es
bewusst nicht; die Tabelle fuehrt keinen Zustand.

Der Pfeil erscheint nur im Archiv. Ein nie uebernommener Eintrag wird
ohne Rueckfrage zurueckgeholt, ein Rueckfaeller mit - dabei faellt der
Verweis auf die geloeschte Opportunity weg. Fuer diese Rueckfrage kennt
`ConfirmDialog` jetzt `tone="neutral"`: ein roter Knopf an einer
harmlosen Stelle nimmt der Farbe ihre Bedeutung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-03 09:35:15 +02:00
co-authored by Claude Opus 5
parent ea886e4d1b
commit f319b8207b
10 changed files with 205 additions and 16 deletions
@@ -25,6 +25,8 @@ interface AcquisitionTableProps {
onEdit: (entry: Acquisition) => void;
onDelete: (entry: Acquisition) => void;
onConvert: (entry: Acquisition) => void;
/** Nur im Archiv gesetzt - im Eingang gibt es nichts wiederherzustellen. */
onRestore?: (entry: Acquisition) => void;
}
const COLUMNS: { key: AcquisitionSortKey | null; label: string; className?: string }[] = [
@@ -116,6 +118,7 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
onEdit,
onDelete,
onConvert,
onRestore,
}) => (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
<div className="scrollbar-slim overflow-x-auto">
@@ -227,6 +230,24 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
<td className="whitespace-nowrap px-4 py-3 text-right">
<div className="inline-flex gap-1">
{onRestore && (
<button
onClick={() => onRestore(entry)}
disabled={isBusy}
title="Zurück in die Akquise: der Eintrag bekommt das heutige Datum und steht wieder im Eingang."
aria-label={`${entry.title} wiederherstellen`}
className="rounded-lg p-2 text-ink-400 transition hover:bg-brand-50 hover:text-brand-700 disabled:opacity-50"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h10a5 5 0 010 10h-3M3 10l4-4M3 10l4 4"
/>
</svg>
</button>
)}
<button
onClick={() => onEdit(entry)}
title="Bearbeiten"
+70 -5
View File
@@ -15,11 +15,13 @@ import { EMPTY_ACQUISITION_OPTIONS, type Acquisition, type AcquisitionList } fro
* Akquise: gefundene Projekte in einer Liste, mit der Eingabe darueber.
*
* Derselbe Aufbau traegt das Archiv (`view = 'archiv'`): dieselbe Liste, nur
* ohne Eingabe - im Archiv wird nichts Neues eingetragen, es wird abgearbeitet
* oder es verfaellt.
* ohne Eingabe fuer Neues - im Archiv wird nichts Neues eingetragen, es wird
* abgearbeitet oder es verfaellt. Zum Bearbeiten klappt das Formular auch dort
* auf; ohne das blieb der Stift im Archiv ohne Wirkung.
*
* Eingang und Archiv trennt allein das Funddatum; das entscheidet das Backend.
* Nichts hier verschiebt Eintraege, es gibt keinen Zustand, der driften kann.
* Verschoben wird nichts - auch das Wiederherstellen setzt nur das Funddatum
* auf heute, es gibt keinen Zustand, der driften kann.
*/
interface AcquisitionsProps {
@@ -51,6 +53,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
const [search, setSearch] = useState('');
const [editing, setEditing] = useState<Acquisition | null>(null);
const [toDelete, setToDelete] = useState<Acquisition | null>(null);
const [toRestore, setToRestore] = useState<Acquisition | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [sortConfig, setSortConfig] = useState<AcquisitionSortConfig>({
key: 'found_at',
@@ -79,6 +82,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
useEffect(() => {
setEditing(null);
setSearch('');
setToRestore(null);
}, [view]);
const entries = useMemo(() => {
@@ -141,6 +145,34 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
}
};
const restore = async (entry: Acquisition) => {
setBusyId(entry.id);
try {
await acquisitionsApi.restore(entry.id);
addToast(`„${entry.title}“ steht wieder in der Akquise.`, 'success');
if (editing?.id === entry.id) setEditing(null);
await load(true);
// Die Zahl am Menuepunkt "Archiv" haengt an den Daten der App.
reload();
} catch (err) {
addToast(err instanceof Error ? err.message : 'Wiederherstellen fehlgeschlagen.', 'error');
await load(true);
} finally {
setBusyId(null);
setToRestore(null);
}
};
/*
* Ein nie uebernommener Eintrag wird ohne Rueckfrage wiederhergestellt - er
* bekommt nur ein neues Funddatum. Bei einem Rueckfaeller geht dabei der
* Verweis auf die (geloeschte) Opportunity verloren, das wird gefragt.
*/
const handleRestore = (entry: Acquisition) => {
if (entry.converted_at === null) void restore(entry);
else setToRestore(entry);
};
const confirmDelete = async () => {
if (!toDelete) return;
try {
@@ -160,6 +192,21 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
return (
<div className="space-y-4">
{/*
Im Archiv wird nichts Neues eingetragen - das Formular erscheint dort
nur zum Bearbeiten. Ohne diesen Zweig setzte der Stift im Archiv den
Zustand, ohne dass etwas aufging.
*/}
{isArchive && editing && (
<AcquisitionForm
editing={editing}
options={data?.options ?? EMPTY_ACQUISITION_OPTIONS}
providerSuggestions={data?.provider_suggestions ?? []}
onSaved={() => load(true)}
onCancelEdit={() => setEditing(null)}
/>
)}
{isArchive ? (
<div className="rounded-2xl border-l-4 border-l-ink-300 border border-ink-200 bg-ink-50 p-4">
<p className="text-sm font-bold text-ink-700">
@@ -167,8 +214,9 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
</p>
<p className="mt-1 text-xs text-ink-500">
Nach {deleteAfter} Tagen wird ein Eintrag automatisch gelöscht. Bis dahin lässt er sich
weiter bearbeiten und jederzeit in eine Opportunity übernehmen. Neue Projekte werden
unter <em>Akquise</em> eingetragen.
weiter bearbeiten, jederzeit in eine Opportunity übernehmen oder mit dem Pfeil
zurück in die Akquise holen - er bekommt dann das heutige Datum. Neue Projekte
werden unter <em>Akquise</em> eingetragen.
</p>
<p className="mt-1 text-xs text-ink-500">
Hier stehen auch Bewerbungen, deren <strong className="font-bold">Opportunity gelöscht</strong>{' '}
@@ -249,6 +297,23 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
onEdit={setEditing}
onDelete={setToDelete}
onConvert={convert}
onRestore={isArchive ? handleRestore : undefined}
/>
)}
{toRestore && (
<ConfirmDialog
title="Zurück in die Akquise?"
message={
`„${toRestore.title}“ bekommt das heutige Datum und steht wieder im Eingang. ` +
(toRestore.opportunity_deleted_at
? 'Der Verweis auf die Opportunity im Papierkorb geht dabei verloren - wird sie zurückgeholt, steht sie neben diesem Eintrag.'
: 'Der Verweis auf die gelöschte Opportunity geht dabei verloren.')
}
confirmLabel="Wiederherstellen"
tone="neutral"
onConfirm={() => restore(toRestore)}
onCancel={() => setToRestore(null)}
/>
)}
+21 -3
View File
@@ -117,6 +117,13 @@ interface ConfirmDialogProps {
title: string;
message: string;
confirmLabel?: string;
/*
* 'danger' warnt vor einem Verlust (Standard, es wird meist geloescht).
* 'neutral' fragt nur nach, ohne rote Warnung - fuer Schritte, die nichts
* zerstoeren, etwa das Wiederherstellen aus dem Archiv. Ein roter Knopf an
* einer harmlosen Stelle nimmt der Farbe ihre Bedeutung.
*/
tone?: 'danger' | 'neutral';
onConfirm: () => void;
onCancel: () => void;
}
@@ -125,6 +132,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
title,
message,
confirmLabel = 'Löschen',
tone = 'danger',
onConfirm,
onCancel,
}) => {
@@ -136,13 +144,21 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
className="fixed inset-0 z-[130] flex items-center justify-center bg-ink-950/50 p-4 backdrop-blur-sm"
>
<div className="w-full max-w-sm rounded-2xl border border-ink-200 bg-surface p-7 text-center shadow-lift">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-red-600">
<div
className={`mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-full ${
tone === 'danger' ? 'bg-red-50 text-red-600' : 'bg-brand-50 text-brand-700'
}`}
>
<svg className="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
d={
tone === 'danger'
? 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z'
: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
}
/>
</svg>
</div>
@@ -157,7 +173,9 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
</button>
<button
onClick={onConfirm}
className="rounded-xl bg-red-600 py-3 text-sm font-bold text-white transition hover:bg-red-700"
className={`rounded-xl py-3 text-sm font-bold text-white transition ${
tone === 'danger' ? 'bg-red-600 hover:bg-red-700' : 'bg-brand-500 hover:bg-brand-600'
}`}
>
{confirmLabel}
</button>
+8
View File
@@ -294,6 +294,14 @@ export const acquisitionsApi = {
request<Acquisition>(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/acquisitions/${id}`, { method: 'DELETE' }),
/**
* Zurueck in den Eingang: Funddatum auf heute, und bei einem Rueckfaeller
* wird die Uebernahme geloest (der Verweis auf die geloeschte Opportunity
* faellt weg).
*/
restore: (id: number) =>
request<Acquisition>(`/acquisitions/${id}/restore`, { method: 'POST' }),
/** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */
toOpportunity: (id: number) =>
request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, {