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