Suspects: Vorstufe der Leads als Tabelle

Neuer Menüpunkt über Leads mit Unterpunkt Archiv.

- Tabelle Name, Funktion, Unternehmen, LinkedIn, Website, Notiz,
  Letzter Kontakt, Nächster Impuls; leere Zeile unten, speichert beim
  Verlassen. Links als Symbol mit URL im Tooltip.
- Nächster Impuls: Tage eingeben, Erinnerung über den ReminderWatcher
- Archivieren, Zurückholen, endgültig löschen (nur aus dem Archiv)
- Lead erstellen öffnet die Lead-Karte vorbelegt (LeadModal.initial),
  danach wandert der Suspect mit lead_id ins Archiv

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 12:11:36 +02:00
co-authored by Claude Opus 5.5
parent e0f6ef06cd
commit 6a452f1510
14 changed files with 1139 additions and 22 deletions
+91 -18
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { formatStamp, type Lead } from '../../types';
import { leadsApi } from '../../lib/api';
import { formatDate, formatStamp, type Lead } from '../../types';
import { leadsApi, suspectsApi, type Suspect } from '../../lib/api';
import { Modal } from '../ui/Modal';
import { Avatar } from '../ui/Avatar';
import { PrimaryButton, SecondaryButton } from '../ui/form';
@@ -8,15 +8,17 @@ import { useNavigation } from '../../lib/navigation';
import { useToast } from '../Toast';
/*
* Erinnerungen an Leads.
* Erinnerungen an Leads und Suspects.
*
* Zwei Faelle, beide als Pop-up:
* Drei Faelle, alle als Pop-up:
*
* faellig eine gesetzte Erinnerung ist erreicht ("in 2 Tagen anrufen")
* faellig eine gesetzte Lead-Erinnerung ist erreicht ("in 2 Tagen anrufen")
* alt ein Lead wurde einen Monat lang nicht angefasst; dann fragt das
* CRM, ob er archiviert werden soll. Wird er das nicht, laeuft die
* Frist von vorn - das erledigt der Server, indem das Quittieren
* `last_contact_at` auf jetzt setzt.
* suspect der "naechste Impuls" eines Suspects ist erreicht: die Person
* erneut ansprechen. "Erledigt" setzt Letzter Kontakt auf heute.
*
* Abgefragt wird beim Anmelden und danach stuendlich. Oefter waere sinnlos:
* die Fristen sind Tage, nicht Minuten.
@@ -24,23 +26,29 @@ import { useToast } from '../Toast';
const POLL_MS = 60 * 60 * 1000;
type Item = { kind: 'due' | 'stale'; lead: Lead } | { kind: 'suspect'; suspect: Suspect };
export const ReminderWatcher: React.FC<{ onChanged: () => void }> = ({ onChanged }) => {
const { toast } = useToast();
const { open } = useNavigation();
const [queue, setQueue] = useState<Array<{ lead: Lead; kind: 'due' | 'stale' }>>([]);
const { open, setView } = useNavigation();
const [queue, setQueue] = useState<Item[]>([]);
const [staleDays, setStaleDays] = useState(30);
const check = useCallback(async () => {
try {
const result = await leadsApi.reminders();
setStaleDays(result.stale_days);
setQueue([
...result.due.map((lead) => ({ lead, kind: 'due' as const })),
...result.stale.map((lead) => ({ lead, kind: 'stale' as const })),
]);
} catch {
/* Kein Netz oder abgelaufene Sitzung - beim naechsten Durchlauf erneut. */
// Beide Abfragen unabhaengig: faellt eine aus, kommt die andere trotzdem.
const [leads, suspects] = await Promise.allSettled([leadsApi.reminders(), suspectsApi.reminders()]);
const items: Item[] = [];
if (leads.status === 'fulfilled') {
setStaleDays(leads.value.stale_days);
items.push(
...leads.value.due.map((lead) => ({ lead, kind: 'due' as const })),
...leads.value.stale.map((lead) => ({ lead, kind: 'stale' as const }))
);
}
if (suspects.status === 'fulfilled') {
items.push(...suspects.value.due.map((suspect) => ({ suspect, kind: 'suspect' as const })));
}
setQueue(items);
}, []);
useEffect(() => {
@@ -52,8 +60,73 @@ export const ReminderWatcher: React.FC<{ onChanged: () => void }> = ({ onChanged
const current = queue[0];
if (!current) return null;
const { lead, kind } = current;
const next = () => setQueue((list) => list.slice(1));
const subtitle = queue.length > 1 ? `${queue.length} offene Erinnerungen` : undefined;
if (current.kind === 'suspect') {
const { suspect } = current;
const acknowledgeSuspect = async (snoozeDays?: number) => {
try {
await suspectsApi.acknowledge(suspect.id, snoozeDays);
toast(snoozeDays ? `Erinnerung um ${snoozeDays} Tag(e) verschoben.` : 'Als kontaktiert vermerkt.', 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
next();
onChanged();
}
};
return (
<Modal
open
size="sm"
onClose={next}
title="Nächster Impuls"
subtitle={subtitle}
footer={
<>
<SecondaryButton type="button" onClick={() => void acknowledgeSuspect(7)}>
In 7 Tagen erneut
</SecondaryButton>
<PrimaryButton type="button" onClick={() => void acknowledgeSuspect()}>
Kontakt aufgenommen
</PrimaryButton>
</>
}
>
<p className="text-sm text-ink-800">
Zeit, <strong>{suspect.name}</strong> erneut anzusprechen.
</p>
<div className="mt-4 space-y-1 rounded-lg border border-ink-200 p-3 text-sm text-ink-700">
{(suspect.role || suspect.company) && (
<p>{[suspect.role, suspect.company].filter(Boolean).join(' · ')}</p>
)}
{suspect.linkedin && (
<a href={suspect.linkedin} target="_blank" rel="noopener noreferrer" className="block text-brand-700 hover:underline">
LinkedIn-Profil öffnen ↗
</a>
)}
{suspect.notes && <p className="text-xs text-ink-500">{suspect.notes}</p>}
<p className="text-xs text-ink-400">
Letzter Kontakt: {suspect.last_contact_on ? formatDate(suspect.last_contact_on) : 'noch keiner'}
</p>
</div>
<button
type="button"
onClick={() => {
next();
setView('Suspects');
}}
className="mt-3 text-sm font-medium text-brand-700 hover:underline"
>
Zu den Suspects
</button>
</Modal>
);
}
const { lead, kind } = current;
const acknowledge = async (snoozeDays?: number) => {
try {
@@ -88,7 +161,7 @@ export const ReminderWatcher: React.FC<{ onChanged: () => void }> = ({ onChanged
size="sm"
onClose={() => void acknowledge()}
title={kind === 'due' ? 'Erinnerung' : 'Lead liegt lange still'}
subtitle={queue.length > 1 ? `${queue.length} offene Erinnerungen` : undefined}
subtitle={subtitle}
footer={
kind === 'due' ? (
<>