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
+6
View File
@@ -168,6 +168,12 @@ Acht Eigenheiten, die man kennen muss:
Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert: Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert:
wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln
dafür stehen in `server/src/acquisitionRules.js`. dafür stehen in `server/src/acquisitionRules.js`.
Aus derselben Logik folgt, was **Wiederherstellen** (`POST /:id/restore`) tut:
Es setzt `found_at` auf heute und leert `converted_at` samt `opportunity_id` -
ohne das Zurücksetzen von `converted_at` bliebe ein Rückfaller im Archiv
stehen, gleich welches Datum er trägt. Ein Kennzeichen
„wiederhergestellt" gibt es nicht; es wäre der Zustand, den diese Tabelle
gerade nicht führt.
### Postfach crm@perinco.de ### Postfach crm@perinco.de
+1
View File
@@ -13,6 +13,7 @@ flowchart TD
K --> AQ["Akquise"] K --> AQ["Akquise"]
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"] AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"] AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
AQ2 -. "Wiederherstellen<br/>(Funddatum = heute)" .-> AQ1
K --> O["Opportunities"] K --> O["Opportunities"]
K --> C["Interviews"] K --> C["Interviews"]
K --> L["Kontakte"] K --> L["Kontakte"]
+24 -2
View File
@@ -183,7 +183,7 @@ wird geleert - für das nächste Projekt aus derselben Quelle.
| **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage | | **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage |
| **Berater** | für wen | | **Berater** | für wen |
| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Erstgespräch Berater* | | **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Erstgespräch Berater* |
| Stift / Papierkorb | bearbeiten (füllt das Formular oben) und löschen | | Pfeil / Stift / Papierkorb | zurück in die Akquise (nur im Archiv), bearbeiten (füllt das Formular oben) und löschen |
Die Spaltenköpfe sortieren, die Suche geht über Titel, Anbieter, Berater, Die Spaltenköpfe sortieren, die Suche geht über Titel, Anbieter, Berater,
Endkunde, Beschreibung und Notizen. Endkunde, Beschreibung und Notizen.
@@ -191,7 +191,8 @@ Endkunde, Beschreibung und Notizen.
### Was von selbst passiert ### Was von selbst passiert
* Nach **14 Tagen** rutscht ein Eintrag ins **Archiv**. Dort lässt er sich weiter * Nach **14 Tagen** rutscht ein Eintrag ins **Archiv**. Dort lässt er sich weiter
bearbeiten und jederzeit übernehmen. bearbeiten und jederzeit übernehmen - der Stift klappt das Formular auch im
Archiv auf.
* Nach **90 Tagen** wird er **gelöscht**. Wer ein Projekt behalten will, * Nach **90 Tagen** wird er **gelöscht**. Wer ein Projekt behalten will,
übernimmt es als Opportunity. übernimmt es als Opportunity.
* Wird ein Eintrag **übernommen**, verschwindet er aus Eingang und Archiv - er * Wird ein Eintrag **übernommen**, verschwindet er aus Eingang und Archiv - er
@@ -207,6 +208,27 @@ Endkunde, Beschreibung und Notizen.
Verschoben wird dabei nichts: Eingang und Archiv sind dieselbe Liste, nur nach Verschoben wird dabei nichts: Eingang und Archiv sind dieselbe Liste, nur nach
dem Funddatum geteilt. Es gibt also keinen Zustand, der klemmen kann. dem Funddatum geteilt. Es gibt also keinen Zustand, der klemmen kann.
### Zurück in die Akquise
Der **Pfeil** in der Archivliste holt einen Eintrag zurück in den Eingang - für
ein Projekt, das doch noch aktuell ist, oder für eine Bewerbung, die nach dem
Löschen ihrer Opportunity von vorn beginnt. Der Eintrag bekommt dabei das
**heutige Datum**; damit zählen die 14 Tage bis zum Archiv und die 90 Tage bis
zum Löschen wieder von vorn.
> **Zur Statistik:** Das neue Funddatum verschiebt die Bewerbung auch im
> Dashboard in den heutigen Zeitraum - sie ist dort keine Bewerbung von damals
> mehr.
Bei einem **Rückfaller** (die Bewerbung war schon eine Opportunity) fragt das CRM
nach: mit dem Wiederherstellen fällt der Verweis auf die gelöschte Opportunity
weg. Der Eintrag ist danach wieder ein gewöhnlicher Fund - liegt die Opportunity
noch im Papierkorb und wird sie zurückgeholt, steht sie ab dann *neben* dem
Eintrag statt an seiner Stelle.
Ein Eintrag, dessen Opportunity noch lebt, lässt sich nicht wiederherstellen -
er steht ohnehin in keiner der beiden Listen.
### Aus dem Eintrag wird eine Opportunity ### Aus dem Eintrag wird eine Opportunity
Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 % Wahrscheinlichkeit, Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 % Wahrscheinlichkeit,
+3 -2
View File
@@ -219,8 +219,9 @@ Browser-Download.
* **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`, * **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`,
`labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`, `labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`,
`primaryButtonClass` aus `components/scanner/scannerUi.tsx`. `primaryButtonClass` aus `components/scanner/scannerUi.tsx`.
* **Meldungen als Toast**, Löschabfragen über `ConfirmDialog` - nie `alert()` * **Meldungen als Toast**, Rückfragen über `ConfirmDialog` - nie `alert()`
oder `confirm()`. oder `confirm()`. Rot (`tone="danger"`, Standard) nur, wenn etwas verloren
geht; sonst `tone="neutral"`, damit die Farbe ihre Bedeutung behält.
* **Rechte immer doppelt**: Oberfläche blendet aus, Backend antwortet mit 403. * **Rechte immer doppelt**: Oberfläche blendet aus, Backend antwortet mit 403.
## Deployment ## Deployment
+4 -4
View File
@@ -92,7 +92,7 @@ Alle unter `router.use(requireAuth)`.
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete | | `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin | | `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
| `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete | | `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete |
| `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern/löschen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete | | `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern/löschen/wiederherstellen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete. `/restore` setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest; bei lebender Opportunity antwortet es 409 |
| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv, beide Papierkörbe, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt | | `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv, beide Papierkörbe, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt |
| `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen | | `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen |
| `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin | | `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin |
@@ -272,9 +272,9 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/opportunities/opportunityUi.tsx` | `STAGE_LABELS` (Anzeige, mit Umlaut), `STAGE_HINTS` (Erläuterung als Titelhinweis), `STAGE_STYLES`, `STAGE_COLORS`, `StageBadge` | | `components/opportunities/opportunityUi.tsx` | `STAGE_LABELS` (Anzeige, mit Umlaut), `STAGE_HINTS` (Erläuterung als Titelhinweis), `STAGE_STYLES`, `STAGE_COLORS`, `StageBadge` |
| `components/dashboard/Dashboard.tsx` | Startansicht: Zeitraumwahl, vier Kennzahlen, Verlauf, Berater, Termine, „Beim Kunden". `buildAxis()` erzeugt auch leere Abschnitte | | `components/dashboard/Dashboard.tsx` | Startansicht: Zeitraumwahl, vier Kennzahlen, Verlauf, Berater, Termine, „Beim Kunden". `buildAxis()` erzeugt auch leere Abschnitte |
| `components/dashboard/dashboardUi.tsx` | `useCountUp()` (Zahl läuft hoch, respektiert `prefers-reduced-motion`), `StatCard`, `SeriesChart`, `BarList`, `Panel` - von Hand statt Diagrammbibliothek | | `components/dashboard/dashboardUi.tsx` | `useCountUp()` (Zahl läuft hoch, respektiert `prefers-reduced-motion`), `StatCard`, `SeriesChart`, `BarList`, `Panel` - von Hand statt Diagrammbibliothek |
| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen | | `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen, Wiederherstellen. Das Formular steht im Archiv nur beim Bearbeiten - ohne diesen Zweig blieb der Stift dort ohne Wirkung |
| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag | | `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag |
| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden | | `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden. Der Pfeil zum Wiederherstellen erscheint nur, wenn `onRestore` gesetzt ist - im Eingang gibt es nichts wiederherzustellen |
| `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>` | | `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit `<datalist>` |
| `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen | | `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart | | `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart |
@@ -285,7 +285,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` | | `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` | | `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt | | `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
| `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden | | `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden. `ConfirmDialog` kennt zwei Tonlagen: `tone="danger"` (Standard, rot - es geht etwas verloren) und `tone="neutral"` (grün, bloße Rückfrage, z. B. beim Wiederherstellen) |
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` | | `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat | | `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
| `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG | | `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG |
+47
View File
@@ -606,6 +606,53 @@ router.patch('/:id', async (req, res, next) => {
} }
}); });
/*
* POST /api/acquisitions/:id/restore
*
* Zurueck in den Eingang. Eingang und Archiv trennt allein das Funddatum -
* deshalb setzt das Wiederherstellen es auf heute. Eine eigene Spalte
* "wiederhergestellt" waere ein zweiter Zustand, der zum Datum driften kann.
*
* Bei einem Rueckfaeller (uebernommen, Opportunity weg) wird zusaetzlich die
* Uebernahme geloest: sonst haelt `converted_at` den Eintrag im Archiv fest,
* egal welches Datum er traegt. Der Verweis auf die Opportunity geht dabei
* verloren - sie ist geloescht oder im Papierkorb, und der Eintrag ist wieder
* das Original.
*/
router.post('/:id/restore', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' });
try {
const [rows] = await pool.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]);
const existing = rows[0];
if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
if (!mayModify(req.user, existing)) {
return res.status(403).json({
error:
'Diesen Eintrag darf nur wiederherstellen, wer ihn angelegt hat, oder ein Administrator.',
});
}
// Lebt die Opportunity noch, gehoert der Eintrag zu ihr und steht ohnehin
// in keiner der beiden Listen - dann gibt es nichts wiederherzustellen.
if (existing.converted_at !== null && !(await opportunityGone(existing.opportunity_id))) {
return res
.status(409)
.json({ error: 'Der Eintrag gehört zu einer bestehenden Opportunity.' });
}
await pool.query(
`UPDATE crm_acquisitions
SET found_at = CURDATE(), converted_at = NULL, opportunity_id = NULL
WHERE id = ?`,
[id]
);
res.json(await fetchAcquisition(id));
} catch (err) {
next(err);
}
});
// DELETE /api/acquisitions/:id // DELETE /api/acquisitions/:id
router.delete('/:id', async (req, res, next) => { router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id); const id = Number(req.params.id);
@@ -25,6 +25,8 @@ interface AcquisitionTableProps {
onEdit: (entry: Acquisition) => void; onEdit: (entry: Acquisition) => void;
onDelete: (entry: Acquisition) => void; onDelete: (entry: Acquisition) => void;
onConvert: (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 }[] = [ const COLUMNS: { key: AcquisitionSortKey | null; label: string; className?: string }[] = [
@@ -116,6 +118,7 @@ export const AcquisitionTable: React.FC<AcquisitionTableProps> = ({
onEdit, onEdit,
onDelete, onDelete,
onConvert, onConvert,
onRestore,
}) => ( }) => (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card"> <div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
<div className="scrollbar-slim overflow-x-auto"> <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"> <td className="whitespace-nowrap px-4 py-3 text-right">
<div className="inline-flex gap-1"> <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 <button
onClick={() => onEdit(entry)} onClick={() => onEdit(entry)}
title="Bearbeiten" 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. * Akquise: gefundene Projekte in einer Liste, mit der Eingabe darueber.
* *
* Derselbe Aufbau traegt das Archiv (`view = 'archiv'`): dieselbe Liste, nur * Derselbe Aufbau traegt das Archiv (`view = 'archiv'`): dieselbe Liste, nur
* ohne Eingabe - im Archiv wird nichts Neues eingetragen, es wird abgearbeitet * ohne Eingabe fuer Neues - im Archiv wird nichts Neues eingetragen, es wird
* oder es verfaellt. * 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. * 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 { interface AcquisitionsProps {
@@ -51,6 +53,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [editing, setEditing] = useState<Acquisition | null>(null); const [editing, setEditing] = useState<Acquisition | null>(null);
const [toDelete, setToDelete] = 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 [busyId, setBusyId] = useState<number | null>(null);
const [sortConfig, setSortConfig] = useState<AcquisitionSortConfig>({ const [sortConfig, setSortConfig] = useState<AcquisitionSortConfig>({
key: 'found_at', key: 'found_at',
@@ -79,6 +82,7 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
useEffect(() => { useEffect(() => {
setEditing(null); setEditing(null);
setSearch(''); setSearch('');
setToRestore(null);
}, [view]); }, [view]);
const entries = useMemo(() => { 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 () => { const confirmDelete = async () => {
if (!toDelete) return; if (!toDelete) return;
try { try {
@@ -160,6 +192,21 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
return ( return (
<div className="space-y-4"> <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 ? ( {isArchive ? (
<div className="rounded-2xl border-l-4 border-l-ink-300 border border-ink-200 bg-ink-50 p-4"> <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"> <p className="text-sm font-bold text-ink-700">
@@ -167,8 +214,9 @@ export const Acquisitions: React.FC<AcquisitionsProps> = ({ view = 'eingang', re
</p> </p>
<p className="mt-1 text-xs text-ink-500"> <p className="mt-1 text-xs text-ink-500">
Nach {deleteAfter} Tagen wird ein Eintrag automatisch gelöscht. Bis dahin lässt er sich 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 weiter bearbeiten, jederzeit in eine Opportunity übernehmen oder mit dem Pfeil
unter <em>Akquise</em> eingetragen. zurück in die Akquise holen - er bekommt dann das heutige Datum. Neue Projekte
werden unter <em>Akquise</em> eingetragen.
</p> </p>
<p className="mt-1 text-xs text-ink-500"> <p className="mt-1 text-xs text-ink-500">
Hier stehen auch Bewerbungen, deren <strong className="font-bold">Opportunity gelöscht</strong>{' '} 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} onEdit={setEditing}
onDelete={setToDelete} onDelete={setToDelete}
onConvert={convert} 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; title: string;
message: string; message: string;
confirmLabel?: 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; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -125,6 +132,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
title, title,
message, message,
confirmLabel = 'Löschen', confirmLabel = 'Löschen',
tone = 'danger',
onConfirm, onConfirm,
onCancel, 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" 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="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"> <svg className="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} 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> </svg>
</div> </div>
@@ -157,7 +173,9 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
</button> </button>
<button <button
onClick={onConfirm} 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} {confirmLabel}
</button> </button>
+8
View File
@@ -294,6 +294,14 @@ export const acquisitionsApi = {
request<Acquisition>(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), request<Acquisition>(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/acquisitions/${id}`, { method: 'DELETE' }), 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. */ /** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */
toOpportunity: (id: number) => toOpportunity: (id: number) =>
request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, { request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, {