Files
ICON-CRM/src/components/leads/ReminderWatcher.tsx
T
schakal00783andClaude Opus 5.5 6a452f1510 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>
2026-09-29 12:11:36 +02:00

234 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useState } from 'react';
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';
import { useNavigation } from '../../lib/navigation';
import { useToast } from '../Toast';
/*
* Erinnerungen an Leads und Suspects.
*
* Drei Faelle, alle als Pop-up:
*
* 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.
*/
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, setView } = useNavigation();
const [queue, setQueue] = useState<Item[]>([]);
const [staleDays, setStaleDays] = useState(30);
const check = useCallback(async () => {
// 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(() => {
void check();
const timer = setInterval(() => void check(), POLL_MS);
return () => clearInterval(timer);
}, [check]);
const current = queue[0];
if (!current) return null;
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 {
await leadsApi.acknowledge(lead.id, snoozeDays);
if (snoozeDays) toast(`Erinnerung um ${snoozeDays} Tag(e) verschoben.`, 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
next();
onChanged();
}
};
const archive = async () => {
try {
await leadsApi.update(lead.id, { archived: true });
toast(`"${lead.title}" archiviert.`, 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Archivieren fehlgeschlagen.', 'error');
} finally {
next();
onChanged();
}
};
// Vorschlag fuer das Verschieben: das am Lead gepflegte Intervall, sonst zwei Tage.
const snooze = lead.reminder_days && lead.reminder_days > 0 ? lead.reminder_days : 2;
return (
<Modal
open
size="sm"
onClose={() => void acknowledge()}
title={kind === 'due' ? 'Erinnerung' : 'Lead liegt lange still'}
subtitle={subtitle}
footer={
kind === 'due' ? (
<>
<SecondaryButton type="button" onClick={() => void acknowledge(snooze)}>
In {snooze} Tag(en) erneut
</SecondaryButton>
<PrimaryButton type="button" onClick={() => void acknowledge()}>
Erledigt
</PrimaryButton>
</>
) : (
<>
<SecondaryButton type="button" onClick={() => void acknowledge()}>
Nicht archivieren
</SecondaryButton>
<PrimaryButton type="button" onClick={archive}>
Archivieren
</PrimaryButton>
</>
)
}
>
<p className="text-sm text-ink-800">
{kind === 'due' ? (
<>
<strong>{lead.title}</strong>
{lead.company_name ? ` – ${lead.company_name}` : ''}
{lead.contact_name ? ` · ${lead.contact_name} anrufen` : ''}
</>
) : (
<>
Achtung: <strong>{lead.title}</strong> wurde seit {staleDays} Tagen nicht kontaktiert. Soll
der Lead archiviert werden?
</>
)}
</p>
<div className="mt-4 space-y-2 rounded-lg border border-ink-200 p-3">
{lead.company_name && (
<span className="flex items-center gap-2 text-sm text-ink-700">
<Avatar name={lead.company_name} src={lead.company_logo} size={24} square />
{lead.company_name}
</span>
)}
{lead.contact_name && (
<span className="flex items-center gap-2 text-sm text-ink-700">
<Avatar name={lead.contact_name} src={lead.contact_avatar} size={24} />
{lead.contact_name}
{lead.contact_phone ? ` · ${lead.contact_phone}` : ''}
</span>
)}
<p className="text-xs text-ink-400">
Zuletzt angefasst: {formatStamp(lead.last_contact_at ?? lead.created_at)}
</p>
</div>
<button
type="button"
onClick={() => {
next();
open('lead', lead.id);
}}
className="mt-3 text-sm font-medium text-brand-700 hover:underline"
>
Lead öffnen
</button>
</Modal>
);
};