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>
234 lines
7.9 KiB
TypeScript
234 lines
7.9 KiB
TypeScript
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>
|
||
);
|
||
};
|