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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user