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:
|
||||
wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln
|
||||
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
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ flowchart TD
|
||||
K --> AQ["Akquise"]
|
||||
AQ --> AQ1["Eingang<br/>Funde der letzten 14 Tage"]
|
||||
AQ --> AQ2["Archiv<br/>älter; nach 90 Tagen gelöscht"]
|
||||
AQ2 -. "Wiederherstellen<br/>(Funddatum = heute)" .-> AQ1
|
||||
K --> O["Opportunities"]
|
||||
K --> C["Interviews"]
|
||||
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 |
|
||||
| **Berater** | für wen |
|
||||
| **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,
|
||||
Endkunde, Beschreibung und Notizen.
|
||||
@@ -191,7 +191,8 @@ Endkunde, Beschreibung und Notizen.
|
||||
### Was von selbst passiert
|
||||
|
||||
* 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,
|
||||
übernimmt es als Opportunity.
|
||||
* 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
|
||||
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
|
||||
|
||||
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`,
|
||||
`labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`,
|
||||
`primaryButtonClass` aus `components/scanner/scannerUi.tsx`.
|
||||
* **Meldungen als Toast**, Löschabfragen über `ConfirmDialog` - nie `alert()`
|
||||
oder `confirm()`.
|
||||
* **Meldungen als Toast**, Rückfragen über `ConfirmDialog` - nie `alert()`
|
||||
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.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -92,7 +92,7 @@ Alle unter `router.use(requireAuth)`.
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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/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/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/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/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen |
|
||||
| `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/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
|
||||
| `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/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
|
||||
| `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
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
@@ -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