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:
co-authored by
Claude Opus 5.5
parent
e0f6ef06cd
commit
6a452f1510
+12
@@ -10,6 +10,7 @@ import { Dashboard } from './components/dashboard/Dashboard';
|
||||
import { Contacts } from './components/contacts/Contacts';
|
||||
import { Companies } from './components/companies/Companies';
|
||||
import { Leads } from './components/leads/Leads';
|
||||
import { Suspects } from './components/suspects/Suspects';
|
||||
import { ReminderWatcher } from './components/leads/ReminderWatcher';
|
||||
import { Tenders } from './components/tenders/Tenders';
|
||||
import { LiveBoard } from './components/tenders/LiveBoard';
|
||||
@@ -136,6 +137,17 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{(view === 'Suspects' || view === 'Suspects · Archiv') && (
|
||||
<Suspects
|
||||
key={view}
|
||||
archived={view === 'Suspects · Archiv'}
|
||||
companies={companies}
|
||||
contacts={contacts}
|
||||
users={users}
|
||||
onChanged={loadShared}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(view === 'Leads' || view === 'Leads · Archiviert') && (
|
||||
<Leads
|
||||
key={view}
|
||||
|
||||
@@ -36,6 +36,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ view, onSelect, counts, collap
|
||||
label: 'Dashboard',
|
||||
icon: icon('M3 3h8v8H3V3Zm10 0h8v5h-8V3ZM3 13h8v8H3v-8Zm10-3h8v11h-8V10Z'),
|
||||
},
|
||||
{
|
||||
view: 'Suspects',
|
||||
label: 'Suspects',
|
||||
badge: 'suspects_open',
|
||||
icon: icon('M9 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 8v-1c0-3 3-5 7-5s7 2 7 5v1H2Zm16-12h2v2h2v2h-2v2h-2v-2h-2v-2h2V8Z'),
|
||||
children: [{ view: 'Suspects · Archiv', label: 'Archiv', badge: 'suspects_archived' }],
|
||||
},
|
||||
{
|
||||
view: 'Leads',
|
||||
label: 'Leads',
|
||||
|
||||
@@ -32,6 +32,10 @@ interface LeadModalProps {
|
||||
companies: Company[];
|
||||
contacts: Contact[];
|
||||
users: DirectoryUser[];
|
||||
/** Vorbelegung beim Anlegen - z. B. aus einem Suspect. */
|
||||
initial?: Partial<typeof EMPTY>;
|
||||
/** Nach dem Anlegen, mit dem neuen Lead. */
|
||||
onCreated?: (lead: Lead) => void;
|
||||
}
|
||||
|
||||
type Tab = 'stamm' | 'todos' | 'historie';
|
||||
@@ -61,6 +65,8 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
companies,
|
||||
contacts,
|
||||
users,
|
||||
initial,
|
||||
onCreated,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const { open: openEntity } = useNavigation();
|
||||
@@ -103,7 +109,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
* Beim Anlegen ist der angemeldete Benutzer voreingestellt - in aller Regel
|
||||
* kümmert sich der, der den Datensatz erfasst. Ändern lässt es sich weiterhin.
|
||||
*/
|
||||
setForm({ ...EMPTY, owner_id: user?.id ?? '' });
|
||||
setForm({ ...EMPTY, owner_id: user?.id ?? '', ...initial });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -132,8 +138,10 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
||||
};
|
||||
if (remindIn) body.remind_in_days = Number(remindIn);
|
||||
|
||||
if (isNew) await leadsApi.create(body);
|
||||
else await leadsApi.update(leadId, body);
|
||||
if (isNew) {
|
||||
const { lead: created } = await leadsApi.create(body);
|
||||
onCreated?.(created);
|
||||
} else await leadsApi.update(leadId, body);
|
||||
toast('Gespeichert.', 'success');
|
||||
onSaved();
|
||||
onClose();
|
||||
|
||||
@@ -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' ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { formatDate, type Company, type Contact, type DirectoryUser, type Lead } from '../../types';
|
||||
import { suspectsApi, type Suspect, type SuspectInput } from '../../lib/api';
|
||||
import { LeadModal } from '../leads/LeadModal';
|
||||
import { ConfirmDialog, Modal } from '../ui/Modal';
|
||||
import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
/*
|
||||
* Suspects - die Stufe vor dem Lead.
|
||||
*
|
||||
* Eine Tabelle, die sich wie eine Liste füllt: unten steht immer eine leere
|
||||
* Zeile, jede Zelle ist direkt bearbeitbar und speichert beim Verlassen.
|
||||
*
|
||||
* Links (LinkedIn, Website) werden nach dem Speichern zu einem Symbol - die
|
||||
* Adresse steht im Tooltip. Das spart die Breite, die eine URL sonst frisst.
|
||||
*
|
||||
* "Nächster Impuls" setzt eine Erinnerung in X Tagen; fällig meldet sie sich
|
||||
* über denselben Wächter wie die Lead-Erinnerungen.
|
||||
*
|
||||
* Aus einer Zeile wird per Knopf ein Lead: die Lead-Karte öffnet sich
|
||||
* vorbelegt (Titel fehlt noch), nach dem Speichern wandert der Suspect ins
|
||||
* Archiv und trägt den Verweis auf den Lead.
|
||||
*/
|
||||
|
||||
interface SuspectsProps {
|
||||
archived: boolean;
|
||||
companies: Company[];
|
||||
contacts: Contact[];
|
||||
users: DirectoryUser[];
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
type TextField = 'name' | 'role' | 'company' | 'linkedin' | 'website' | 'notes';
|
||||
|
||||
const EMPTY_DRAFT: Record<TextField, string> & { last_contact_on: string } = {
|
||||
name: '',
|
||||
role: '',
|
||||
company: '',
|
||||
linkedin: '',
|
||||
website: '',
|
||||
notes: '',
|
||||
last_contact_on: '',
|
||||
};
|
||||
|
||||
const cellInput =
|
||||
'w-full min-w-0 rounded border border-transparent bg-transparent px-2 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 hover:border-ink-200 focus:border-brand-400 focus:bg-surface focus:outline-none';
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contacts, users, onChanged }) => {
|
||||
const { toast } = useToast();
|
||||
const [rows, setRows] = useState<Suspect[] | null>(null);
|
||||
const [draft, setDraft] = useState({ ...EMPTY_DRAFT });
|
||||
const [impulse, setImpulse] = useState<Suspect | null>(null);
|
||||
const [toLead, setToLead] = useState<Suspect | null>(null);
|
||||
const [removing, setRemoving] = useState<Suspect | null>(null);
|
||||
const draftName = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const { suspects } = await suspectsApi.list(archived);
|
||||
setRows(suspects);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setRows(null);
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [archived]);
|
||||
|
||||
const replace = (suspect: Suspect) =>
|
||||
setRows((list) => list?.map((row) => (row.id === suspect.id ? suspect : row)) ?? null);
|
||||
|
||||
const update = async (row: Suspect, changes: SuspectInput) => {
|
||||
try {
|
||||
const { suspect } = await suspectsApi.update(row.id, changes);
|
||||
replace(suspect);
|
||||
return suspect;
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Neue Zeile anlegen - sobald die Zeile verlassen oder Enter gedrückt wird. */
|
||||
const createDraft = async () => {
|
||||
const hasContent = Object.values(draft).some((value) => value.trim());
|
||||
if (!hasContent) return;
|
||||
if (!draft.name.trim()) {
|
||||
toast('Bitte einen Namen eintragen – dann wird die Zeile gespeichert.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { suspect } = await suspectsApi.create({
|
||||
...draft,
|
||||
last_contact_on: draft.last_contact_on || null,
|
||||
});
|
||||
setRows((list) => [...(list ?? []), suspect]);
|
||||
setDraft({ ...EMPTY_DRAFT });
|
||||
onChanged();
|
||||
requestAnimationFrame(() => draftName.current?.focus());
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const archive = async (row: Suspect) => {
|
||||
if (await update(row, { archived: true })) {
|
||||
setRows((list) => list?.filter((item) => item.id !== row.id) ?? null);
|
||||
toast(`„${row.name}" archiviert.`, 'success');
|
||||
onChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (row: Suspect) => {
|
||||
if (await update(row, { archived: false })) {
|
||||
setRows((list) => list?.filter((item) => item.id !== row.id) ?? null);
|
||||
toast(`„${row.name}" ist zurück in Suspects.`, 'success');
|
||||
onChanged();
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!removing) return;
|
||||
try {
|
||||
await suspectsApi.remove(removing.id);
|
||||
setRows((list) => list?.filter((item) => item.id !== removing.id) ?? null);
|
||||
toast('Endgültig gelöscht.', 'success');
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const leadCreated = async (lead: Lead) => {
|
||||
if (!toLead) return;
|
||||
const row = toLead;
|
||||
try {
|
||||
await suspectsApi.update(row.id, { archived: true, lead_id: lead.id });
|
||||
setRows((list) => list?.filter((item) => item.id !== row.id) ?? null);
|
||||
toast(`Lead angelegt – „${row.name}" liegt jetzt im Archiv.`, 'success');
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Suspect konnte nicht archiviert werden.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (rows === null) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="max-w-3xl text-sm text-ink-500">
|
||||
{archived
|
||||
? 'Archivierte Suspects und solche, aus denen ein Lead wurde. Zurückholen oder endgültig löschen.'
|
||||
: 'Personen, die interessant sein könnten. Einfach in die leere Zeile unten schreiben – gespeichert wird beim Verlassen der Zeile oder mit Enter.'}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
|
||||
<table className="w-full min-w-[1100px] table-fixed text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[15%]" />
|
||||
<col className="w-[14%]" />
|
||||
<col className="w-[14%]" />
|
||||
<col className="w-[72px]" />
|
||||
<col className="w-[72px]" />
|
||||
<col />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[96px]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-[11px] font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-3 py-3">Name</th>
|
||||
<th className="px-3 py-3">Funktion</th>
|
||||
<th className="px-3 py-3">Unternehmen</th>
|
||||
<th className="px-3 py-3 text-center">LinkedIn</th>
|
||||
<th className="px-3 py-3 text-center">Website</th>
|
||||
<th className="px-3 py-3">Notiz</th>
|
||||
<th className="px-3 py-3">Letzter Kontakt</th>
|
||||
<th className="px-3 py-3 text-center">Nächster Impuls</th>
|
||||
<th className="px-3 py-3" aria-label="Aktionen" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) =>
|
||||
archived ? (
|
||||
<ArchivedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
onRestore={() => void restore(row)}
|
||||
onDelete={() => setRemoving(row)}
|
||||
/>
|
||||
) : (
|
||||
<tr key={row.id} className="border-b border-ink-100 align-middle hover:bg-ink-50/60">
|
||||
{(['name', 'role', 'company'] as const).map((field) => (
|
||||
<td key={field} className="px-1 py-1">
|
||||
<TextCell
|
||||
value={row[field]}
|
||||
required={field === 'name'}
|
||||
onCommit={(value) => void update(row, { [field]: value })}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-1 py-1 text-center">
|
||||
<LinkCell value={row.linkedin} kind="linkedin" onCommit={(value) => void update(row, { linkedin: value })} />
|
||||
</td>
|
||||
<td className="px-1 py-1 text-center">
|
||||
<LinkCell value={row.website} kind="website" onCommit={(value) => void update(row, { website: value })} />
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<TextCell value={row.notes} onCommit={(value) => void update(row, { notes: value })} />
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="date"
|
||||
value={row.last_contact_on ?? ''}
|
||||
max={today()}
|
||||
onChange={(event) => void update(row, { last_contact_on: event.target.value || null })}
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-1 text-center">
|
||||
<ImpulseButton remindAt={row.remind_at} onClick={() => setImpulse(row)} />
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton title="Archivieren" onClick={() => void archive(row)} path={ARCHIVE_ICON} />
|
||||
<IconButton title="Lead erstellen" onClick={() => setToLead(row)} path={LEAD_ICON} tone="brand" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
|
||||
{!archived && (
|
||||
<tr
|
||||
className="bg-ink-50/40"
|
||||
onBlur={(event) => {
|
||||
// Erst speichern, wenn der Fokus die Zeile ganz verlässt.
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) void createDraft();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void createDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(['name', 'role', 'company', 'linkedin', 'website', 'notes'] as const).map((field) => (
|
||||
<td key={field} className="px-1 py-1">
|
||||
<input
|
||||
ref={field === 'name' ? draftName : undefined}
|
||||
value={draft[field]}
|
||||
onChange={(event) => setDraft({ ...draft, [field]: event.target.value })}
|
||||
placeholder={DRAFT_PLACEHOLDERS[field]}
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-1 py-1">
|
||||
<input
|
||||
type="date"
|
||||
value={draft.last_contact_on}
|
||||
max={today()}
|
||||
onChange={(event) => setDraft({ ...draft, last_contact_on: event.target.value })}
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td />
|
||||
<td />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{archived && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-12 text-center text-sm text-ink-400">
|
||||
Das Archiv ist leer.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ImpulseDialog
|
||||
suspect={impulse}
|
||||
onClose={() => setImpulse(null)}
|
||||
onSave={async (days) => {
|
||||
if (!impulse) return;
|
||||
const saved = await update(impulse, { remind_in_days: days });
|
||||
if (saved) {
|
||||
toast(days ? `Erinnerung in ${days} Tag(en).` : 'Erinnerung entfernt.', 'success');
|
||||
setImpulse(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{toLead && (
|
||||
<LeadModal
|
||||
leadId={null}
|
||||
companies={companies}
|
||||
contacts={contacts}
|
||||
users={users}
|
||||
initial={leadFromSuspect(toLead, companies, contacts)}
|
||||
onCreated={(lead) => void leadCreated(lead)}
|
||||
onSaved={onChanged}
|
||||
onClose={() => setToLead(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removing)}
|
||||
title="Endgültig löschen?"
|
||||
message={`„${removing?.name}" wird gelöscht. Das lässt sich nicht rückgängig machen.`}
|
||||
onConfirm={() => void remove()}
|
||||
onCancel={() => setRemoving(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DRAFT_PLACEHOLDERS: Record<TextField, string> = {
|
||||
name: '+ Name',
|
||||
role: 'Funktion',
|
||||
company: 'Unternehmen',
|
||||
linkedin: 'LinkedIn-URL',
|
||||
website: 'Website',
|
||||
notes: 'Notiz',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------ Bausteine */
|
||||
|
||||
/** Textzelle: speichert beim Verlassen, Esc verwirft. */
|
||||
const TextCell: React.FC<{ value: string | null; required?: boolean; onCommit: (value: string | null) => void }> = ({
|
||||
value,
|
||||
required,
|
||||
onCommit,
|
||||
}) => {
|
||||
const [text, setText] = useState(value ?? '');
|
||||
useEffect(() => setText(value ?? ''), [value]);
|
||||
|
||||
const commit = () => {
|
||||
const next = text.trim();
|
||||
if (next === (value ?? '')) return;
|
||||
if (required && !next) {
|
||||
setText(value ?? '');
|
||||
return;
|
||||
}
|
||||
onCommit(next || null);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
value={text}
|
||||
title={text.length > 30 ? text : undefined}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
if (event.key === 'Escape') {
|
||||
setText(value ?? '');
|
||||
requestAnimationFrame(() => (event.target as HTMLInputElement).blur());
|
||||
}
|
||||
}}
|
||||
className={`${cellInput} truncate ${required ? 'font-medium' : ''}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** Link als Symbol; die Adresse zeigt der Tooltip. Bearbeiten über den Stift. */
|
||||
const LinkCell: React.FC<{ value: string | null; kind: 'linkedin' | 'website'; onCommit: (value: string | null) => void }> = ({
|
||||
value,
|
||||
kind,
|
||||
onCommit,
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState(value ?? '');
|
||||
useEffect(() => setText(value ?? ''), [value]);
|
||||
|
||||
if (editing || !value) {
|
||||
return (
|
||||
<input
|
||||
autoFocus={editing}
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onBlur={() => {
|
||||
setEditing(false);
|
||||
if (text.trim() !== (value ?? '')) onCommit(text.trim() || null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
if (event.key === 'Escape') {
|
||||
setText(value ?? '');
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
placeholder="URL"
|
||||
className={`${cellInput} text-xs`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="group inline-flex items-center gap-0.5">
|
||||
<a
|
||||
href={value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={value}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-ink-100 ${
|
||||
kind === 'linkedin' ? 'text-[#0a66c2]' : 'text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d={kind === 'linkedin' ? LINKEDIN_ICON : LINK_ICON} />
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
title="Link bearbeiten"
|
||||
className="invisible rounded p-0.5 text-ink-400 hover:text-ink-700 group-hover:visible"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="currentColor" aria-hidden="true">
|
||||
<path d="m14.1 5.1 4.8 4.8L8.8 20H4v-4.8L14.1 5.1Zm1.4-1.4 1.8-1.8a1 1 0 0 1 1.4 0l3.4 3.4a1 1 0 0 1 0 1.4l-1.8 1.8-4.8-4.8Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Wecker - mit Datum daneben, wenn ein Impuls gesetzt ist; rot, wenn fällig. */
|
||||
const ImpulseButton: React.FC<{ remindAt: string | null; onClick: () => void }> = ({ remindAt, onClick }) => {
|
||||
const date = remindAt ? new Date(remindAt.replace(' ', 'T') + (remindAt.includes('Z') ? '' : 'Z')) : null;
|
||||
const due = date ? date.getTime() <= Date.now() : false;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={date ? `Erinnerung am ${date.toLocaleDateString('de-DE')}` : 'Erinnerung setzen'}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium hover:bg-ink-100 ${
|
||||
due ? 'text-red-600' : date ? 'text-brand-700' : 'text-ink-400'
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d={ALARM_ICON} />
|
||||
</svg>
|
||||
{date && date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ImpulseDialog: React.FC<{
|
||||
suspect: Suspect | null;
|
||||
onClose: () => void;
|
||||
onSave: (days: number | null) => Promise<void>;
|
||||
}> = ({ suspect, onClose, onSave }) => {
|
||||
const [days, setDays] = useState('');
|
||||
useEffect(() => setDays(''), [suspect]);
|
||||
const valid = Number(days) >= 1 && Number.isInteger(Number(days));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(suspect)}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
title="Nächster Impuls"
|
||||
subtitle={suspect ? [suspect.name, suspect.company].filter(Boolean).join(' · ') : undefined}
|
||||
footer={
|
||||
<>
|
||||
{suspect?.remind_at && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onSave(null)}
|
||||
className="mr-auto rounded-lg px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Erinnerung entfernen
|
||||
</button>
|
||||
)}
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
Abbrechen
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" disabled={!valid} onClick={() => void onSave(Number(days))}>
|
||||
Speichern
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className="block text-sm text-ink-700">
|
||||
In wie vielen Tagen soll das CRM an einen erneuten Kontakt erinnern?
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
autoFocus
|
||||
value={days}
|
||||
onChange={(event) => setDays(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && valid) void onSave(Number(days));
|
||||
}}
|
||||
placeholder="z. B. 14"
|
||||
className={`${inputClass} mt-2 w-32`}
|
||||
/>
|
||||
</label>
|
||||
{suspect?.remind_at && (
|
||||
<p className="mt-3 text-xs text-ink-400">
|
||||
Aktuell gesetzt: {formatDate(suspect.remind_at)}. Speichern ersetzt diesen Termin.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const ArchivedRow: React.FC<{ row: Suspect; onRestore: () => void; onDelete: () => void }> = ({
|
||||
row,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}) => (
|
||||
<tr className="border-b border-ink-100 last:border-0">
|
||||
<td className="truncate px-3 py-2.5 font-medium text-ink-900">
|
||||
{row.name}
|
||||
{row.lead_id && (
|
||||
<span
|
||||
title={row.lead_title ? `Lead: ${row.lead_title}` : 'Lead angelegt'}
|
||||
className="ml-2 rounded border border-emerald-300 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-bold uppercase text-emerald-700"
|
||||
>
|
||||
Lead
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="truncate px-3 py-2.5 text-ink-600">{row.role ?? '–'}</td>
|
||||
<td className="truncate px-3 py-2.5 text-ink-600">{row.company ?? '–'}</td>
|
||||
<td className="px-3 py-2.5 text-center">{row.linkedin && <StaticLink href={row.linkedin} kind="linkedin" />}</td>
|
||||
<td className="px-3 py-2.5 text-center">{row.website && <StaticLink href={row.website} kind="website" />}</td>
|
||||
<td className="truncate px-3 py-2.5 text-ink-600" title={row.notes ?? undefined}>
|
||||
{row.notes ?? ''}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-ink-600">{row.last_contact_on ? formatDate(row.last_contact_on) : '–'}</td>
|
||||
<td className="px-3 py-2.5 text-center text-ink-400">{row.remind_at ? formatDate(row.remind_at) : '–'}</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton title="Zurückholen" onClick={onRestore} path={RESTORE_ICON} tone="brand" />
|
||||
<IconButton title="Endgültig löschen" onClick={onDelete} path={DELETE_ICON} tone="red" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const StaticLink: React.FC<{ href: string; kind: 'linkedin' | 'website' }> = ({ href, kind }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={href}
|
||||
className={`inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-ink-100 ${
|
||||
kind === 'linkedin' ? 'text-[#0a66c2]' : 'text-brand-600'
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d={kind === 'linkedin' ? LINKEDIN_ICON : LINK_ICON} />
|
||||
</svg>
|
||||
</a>
|
||||
);
|
||||
|
||||
const IconButton: React.FC<{ title: string; onClick: () => void; path: string; tone?: 'ink' | 'brand' | 'red' }> = ({
|
||||
title,
|
||||
onClick,
|
||||
path,
|
||||
tone = 'ink',
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-ink-100 ${
|
||||
tone === 'brand' ? 'text-brand-600' : tone === 'red' ? 'text-red-600 hover:bg-red-50' : 'text-ink-500'
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d={path} />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------ Suspect -> Lead */
|
||||
|
||||
const plain = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/\b(gmbh|ag|se|kg|co|mbh|ug)\b\.?/g, '')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.trim();
|
||||
|
||||
/**
|
||||
* Vorbelegung der Lead-Karte. Unternehmen und Ansprechpartner werden nur
|
||||
* gesetzt, wenn es sie im CRM mit gleichem Namen gibt; alles andere landet in
|
||||
* der Notiz, damit nichts verloren geht.
|
||||
*/
|
||||
function leadFromSuspect(row: Suspect, companies: Company[], contacts: Contact[]) {
|
||||
const company = row.company ? companies.find((item) => plain(item.name) === plain(row.company!)) : undefined;
|
||||
const contact = contacts.find((item) => plain(item.name) === plain(row.name));
|
||||
|
||||
const notes = [
|
||||
`Aus Suspects: ${row.name}${row.role ? `, ${row.role}` : ''}${row.company ? ` (${row.company})` : ''}`,
|
||||
row.linkedin ? `LinkedIn: ${row.linkedin}` : null,
|
||||
row.website ? `Website: ${row.website}` : null,
|
||||
row.last_contact_on ? `Letzter Kontakt: ${formatDate(row.last_contact_on)}` : null,
|
||||
row.notes ? `\n${row.notes}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
title: '',
|
||||
stage: 'Neuer Lead' as const,
|
||||
company_id: (company?.id ?? '') as number | '',
|
||||
contact_id: (contact?.id ?? '') as number | '',
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- Icons */
|
||||
|
||||
const ARCHIVE_ICON = 'M3 3h18v5H3V3Zm1 6h16v12H4V9Zm5 3v2h6v-2H9Z';
|
||||
const LEAD_ICON = 'M3 5h18v4H3V5Zm0 6h8v8H3v-8Zm10 0h8v3h-8v-3Zm0 5h8v3h-8v-3Z';
|
||||
const RESTORE_ICON = 'M12 5V2L7 6l5 4V7a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8Z';
|
||||
const DELETE_ICON = 'M9 3h6l1 1h4v2H4V4h4l1-1Zm-3 5h12l-1 13H7L6 8Zm4 3v8h2v-8h-2Zm4 0v8h2v-8h-2Z';
|
||||
const ALARM_ICON =
|
||||
'M12 5a8 8 0 1 1 0 16 8 8 0 0 1 0-16Zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-1 2h2v4l3 2-1 1.6-4-2.6V9ZM6.3 2.3l1.4 1.4-3.9 3.9-1.4-1.4 3.9-3.9Zm11.4 0 3.9 3.9-1.4 1.4-3.9-3.9 1.4-1.4Z';
|
||||
const LINK_ICON =
|
||||
'M10.6 13.4a1 1 0 0 1 0-1.4l3.5-3.5a1 1 0 1 1 1.4 1.4L12 13.4a1 1 0 0 1-1.4 0ZM8.5 11.3 6.4 13.4a3 3 0 0 0 4.2 4.2l2.1-2.1 1.4 1.4-2.1 2.1a5 5 0 0 1-7-7l2.1-2.1 1.4 1.4Zm7-5.7a3 3 0 0 0-4.2 0L9.2 7.7 7.8 6.3l2.1-2.1a5 5 0 0 1 7 7l-2.1 2.1-1.4-1.4 2.1-2.1a3 3 0 0 0 0-4.2Z';
|
||||
const LINKEDIN_ICON =
|
||||
'M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm3.3 7H5v8h2.3v-8ZM6.2 5.8a1.3 1.3 0 1 0 0 2.7 1.3 1.3 0 0 0 0-2.7ZM19 13.4c0-2.2-1.2-3.5-3.1-3.5-1.1 0-1.9.6-2.2 1.1V10h-2.2v8h2.3v-4.2c0-1.1.5-1.8 1.4-1.8.8 0 1.3.6 1.3 1.8V18H19v-4.6Z';
|
||||
@@ -433,6 +433,40 @@ export const dashboardApi = {
|
||||
load: () => get<DashboardData>('/dashboard'),
|
||||
};
|
||||
|
||||
/* --------------------------------------------------------------- Suspects */
|
||||
|
||||
export interface Suspect {
|
||||
id: number;
|
||||
name: string;
|
||||
role: string | null;
|
||||
company: string | null;
|
||||
linkedin: string | null;
|
||||
website: string | null;
|
||||
notes: string | null;
|
||||
/** YYYY-MM-DD */
|
||||
last_contact_on: string | null;
|
||||
remind_at: string | null;
|
||||
archived: boolean;
|
||||
lead_id: number | null;
|
||||
lead_title: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type SuspectInput = Partial<
|
||||
Pick<Suspect, 'name' | 'role' | 'company' | 'linkedin' | 'website' | 'notes' | 'last_contact_on' | 'archived' | 'lead_id'>
|
||||
> & { remind_in_days?: number | null };
|
||||
|
||||
export const suspectsApi = {
|
||||
list: (archived = false) => get<{ suspects: Suspect[] }>(`/suspects${query({ archived: archived ? 1 : 0 })}`),
|
||||
create: (body: SuspectInput) => post<{ suspect: Suspect }>('/suspects', body),
|
||||
update: (id: number, body: SuspectInput) => patch<{ suspect: Suspect }>(`/suspects/${id}`, body),
|
||||
remove: (id: number) => del<{ ok: true }>(`/suspects/${id}`),
|
||||
reminders: () => get<{ due: Suspect[] }>('/suspects/reminders'),
|
||||
acknowledge: (id: number, snooze_days?: number) =>
|
||||
post<{ ok: true }>(`/suspects/${id}/acknowledge`, { snooze_days }),
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- Scanner */
|
||||
|
||||
export type ScannerSourceId = 'ba' | 'stepstone' | 'karriere' | 'adzuna' | 'freelancermap';
|
||||
|
||||
@@ -376,6 +376,8 @@ export interface LeadTodo {
|
||||
|
||||
export type View =
|
||||
| 'Dashboard'
|
||||
| 'Suspects'
|
||||
| 'Suspects · Archiv'
|
||||
| 'Leads'
|
||||
| 'Leads · Archiviert'
|
||||
| 'Kontakte'
|
||||
|
||||
Reference in New Issue
Block a user