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:
co-authored by
Claude Opus 5
parent
ea886e4d1b
commit
f319b8207b
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
Reference in New Issue
Block a user