Quellcode des ICON CRM (5)

This commit is contained in:
2026-09-14 16:55:38 +02:00
parent e383400d7c
commit 4fcb34f324
25 changed files with 7616 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
import React, { useCallback, useEffect, useState } from 'react';
import { formatStamp, type Lead } from '../../types';
import { leadsApi } 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.
*
* Zwei Faelle, beide als Pop-up:
*
* faellig eine gesetzte 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.
*
* Abgefragt wird beim Anmelden und danach stuendlich. Oefter waere sinnlos:
* die Fristen sind Tage, nicht Minuten.
*/
const POLL_MS = 60 * 60 * 1000;
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 [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. */
}
}, []);
useEffect(() => {
void check();
const timer = setInterval(() => void check(), POLL_MS);
return () => clearInterval(timer);
}, [check]);
const current = queue[0];
if (!current) return null;
const { lead, kind } = current;
const next = () => setQueue((list) => list.slice(1));
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={queue.length > 1 ? `${queue.length} offene Erinnerungen` : undefined}
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>
);
};