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
+20
View File
@@ -28,6 +28,26 @@ Oben in der Kopfleiste, oder mit `Strg+K`. Rechts im Feld zwei Symbole:
Zahlen zum Bestand und die letzten Tätigkeiten. Von hier führt jeder Eintrag in
die zugehörige Karte.
## Suspects
Personen, die interessant sein könnten – bevor daraus ein Lead wird. Technik
siehe [13 Suspects](13-suspects.md).
1. **Erfassen:** in die leere Zeile unten schreiben (Name ist Pflicht). Die Zeile
speichert, sobald du sie verlässt oder Enter drückst. Vorhandene Zellen
einfach anklicken und ändern.
2. **Links:** LinkedIn und Website werden nach dem Speichern zum Symbol. Die
Adresse steht im Tooltip, ein Klick öffnet sie. Zum Ändern auf den kleinen
Stift daneben.
3. **Nächster Impuls:** Wecker anklicken, Anzahl Tage eingeben, *Speichern*. Ist
der Tag erreicht, erinnert das CRM daran, die Person erneut anzusprechen.
*Kontakt aufgenommen* trägt heute als letzten Kontakt ein.
4. **Archivieren** (Kasten-Symbol am Zeilenende): die Zeile wandert nach
*Suspects → Archiv*. Dort *Zurückholen* oder *Endgültig löschen*.
5. **Lead erstellen** (Symbol daneben): die Lead-Karte öffnet sich mit allen
bekannten Daten. Titel eintragen, *Speichern* – der Lead steht dann unter
*Neuer Lead*, der Suspect im Archiv.
## Leads
Die Pipeline als Kanban über acht Spalten, von **Backlog** bis **Absage**.
+105
View File
@@ -0,0 +1,105 @@
# 13 Suspects
Die Stufe **vor** dem Lead: Personen, die interessant sein könnten, als
schlichte Tabelle. Eingeführt am 29.09.2026. Menü: **Suspects** (über Leads) mit
dem Unterpunkt **Archiv**.
```mermaid
flowchart LR
S["Suspect<br/>(Tabelle)"] -->|Lead erstellen| M["Lead-Karte<br/>vorbelegt, Titel fehlt"]
M -->|Speichern| L["Lead in 'Neuer Lead'"]
M -->|Speichern| A["Suspect → Archiv<br/>mit Verweis auf den Lead"]
S -->|Archivieren| A
A -->|Zurückholen| S
A -->|Endgültig löschen| X["weg"]
```
## Tabelle
| Spalte | Feld | Hinweis |
| --- | --- | --- |
| Name | `name` | Pflicht – ohne Namen wird eine neue Zeile nicht gespeichert |
| Funktion | `role` | Freitext |
| Unternehmen | `company` | Freitext, **kein** Verweis auf `icon_companies` |
| LinkedIn | `linkedin` | nach dem Speichern nur ein Symbol, URL im Tooltip |
| Website | `website` | ebenso; fehlt `https://`, ergänzt es der Server |
| Notiz | `notes` | einzeilig, volle Länge im Tooltip |
| Letzter Kontakt | `last_contact_on` | Datum |
| Nächster Impuls | `remind_at` | Wecker: Anzahl Tage eingeben, der Server rechnet das Datum |
**Bedienung wie eine Liste.** Unten steht immer eine leere Zeile. Gespeichert
wird, sobald der Fokus die Zeile verlässt oder Enter gedrückt wird. Vorhandene
Zellen speichern beim Verlassen der Zelle; Esc verwirft die Eingabe. Links
bearbeitet man über den kleinen Stift, der beim Überfahren neben dem Symbol
erscheint.
Name, Funktion und Unternehmen sind bewusst Freitext: ein Suspect soll in
Sekunden erfasst sein, ohne erst Kontakt und Unternehmen anzulegen.
## Nächster Impuls
Klick auf den Wecker → Anzahl Tage → Speichern. Gesetzt zeigt die Zelle das
Datum (rot, wenn fällig). Fällige Impulse meldet derselbe Wächter wie die
Lead-Erinnerungen (`ReminderWatcher`, beim Anmelden und stündlich):
| Knopf | Wirkung |
| --- | --- |
| Kontakt aufgenommen | `remind_at` leer, **Letzter Kontakt = heute** |
| In 7 Tagen erneut | `remind_at` + 7 Tage |
| Schließen (×) | nichts – meldet sich beim nächsten Abfragen wieder |
## Lead erstellen
Das Lead-Symbol am Zeilenende öffnet die **Lead-Karte vorbelegt**
(`LeadModal` mit `initial`):
- Phase **Neuer Lead**, zuständig der angemeldete Benutzer
- **Unternehmen** und **Ansprechpartner** nur, wenn es im CRM einen mit gleichem
Namen gibt (Groß/klein, Rechtsform und Satzzeichen egal)
- **Notiz** mit allem aus der Zeile: Name, Funktion, Unternehmen, LinkedIn,
Website, letzter Kontakt, Notiz
- **Titel bleibt leer** – der ist Pflicht und muss eingetragen werden
Erst beim Speichern entsteht der Lead; danach wandert der Suspect ins Archiv
und trägt `lead_id` (im Archiv als **Lead** markiert). Abbrechen ändert nichts.
## Archiv
Archivierte und in Leads übernommene Suspects, neueste zuerst. **Zurückholen**
stellt die Zeile wieder in die Tabelle, **Endgültig löschen** entfernt sie
(mit Rückfrage). Löschen geht nur aus dem Archiv – der Server antwortet sonst
mit `409`.
## Tabelle `icon_suspects`
| Spalte | Typ | Bedeutung |
| --- | --- | --- |
| `id` | INT | |
| `name`, `role`, `company` | VARCHAR(190) | |
| `linkedin`, `website` | VARCHAR(500) | immer mit Protokoll gespeichert |
| `notes` | TEXT | |
| `last_contact_on` | DATE | |
| `remind_at` | DATETIME | nächster Impuls, UTC wie bei den Leads |
| `archived` | TINYINT | |
| `lead_id` | INT NULL | → `icon_leads`, `ON DELETE SET NULL` |
| `created_by` | INT NULL | → `icon_users` |
In die Historie (als Systemeintrag „Suspects") kommen Archivieren,
Zurückholen, Übernahme als Lead und endgültiges Löschen – nicht jede
Zelländerung.
## Endpunkte
Alle unter `/api/suspects`, alle hinter `requireAuth`.
| Methode | Pfad | Zweck |
| --- | --- | --- |
| GET | `/?archived=0\|1` | Liste; aktiv älteste zuerst, Archiv neueste zuerst |
| GET | `/reminders` | fällige Impulse (`remind_at <= NOW()`, nicht archiviert) |
| POST | `/` | anlegen, `name` Pflicht |
| PATCH | `/:id` | beliebige Felder; `remind_in_days` (Zahl oder `null`), `archived`, `lead_id` |
| POST | `/:id/acknowledge` | `{ snooze_days }` verschiebt, sonst Kontakt heute + Impuls weg |
| DELETE | `/:id` | nur archivierte (`409` sonst) |
Die Zähler im Menü kommen aus `GET /api/dashboard`
(`counts.suspects_open`, `counts.suspects_archived`).
+1
View File
@@ -22,6 +22,7 @@ keine Vorkenntnisse voraus.
| [10 API-Referenz](10-api.md) | Jeder Endpunkt, seine Rechte und seine Antwort |
| [11 Glossar](11-glossar.md) | Lead, Ausschreibung, Phase, Art, Status – was was bedeutet |
| [12 Scanner](12-scanner.md) | Stellenanzeigen aus Jobbörsen: Quellen, Bewertung, Zeitplan, Endpunkte |
| [13 Suspects](13-suspects.md) | Vorstufe der Leads: Tabelle, Impuls-Erinnerung, Archiv, Übergabe als Lead |
Die Designentscheidungen stehen im [README des Projekts](../README.md).
+3 -1
View File
@@ -28,7 +28,9 @@ router.get('/', async (req, res, next) => {
(SELECT COUNT(*) FROM icon_projects WHERE status = 'Aktiv') AS projects_active,
(SELECT COUNT(*) FROM icon_leads WHERE archived = 0) AS leads_open,
(SELECT COUNT(*) FROM icon_leads WHERE archived = 1) AS leads_archived,
(SELECT COUNT(*) FROM icon_scanner_matches WHERE status = 'neu') AS scanner_new`
(SELECT COUNT(*) FROM icon_scanner_matches WHERE status = 'neu') AS scanner_new,
(SELECT COUNT(*) FROM icon_suspects WHERE archived = 0) AS suspects_open,
(SELECT COUNT(*) FROM icon_suspects WHERE archived = 1) AS suspects_archived`
);
const [byStatus] = await pool.query(
+177
View File
@@ -0,0 +1,177 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { logHistory } from '../history.js';
/*
* Suspects - die Stufe vor dem Lead (siehe docs/13-suspects.md).
*
* Eine schlichte Tabelle, die man wie eine Liste fuellt. Aus einer Zeile wird
* spaeter ein Lead; dann traegt sie `lead_id` und liegt im Archiv.
*/
const router = express.Router();
router.use(requireAuth);
const TEXT_FIELDS = { name: 190, role: 190, company: 190, linkedin: 500, website: 500, notes: 5000 };
function readBody(body) {
const out = {};
for (const [field, max] of Object.entries(TEXT_FIELDS)) {
if (body[field] === undefined) continue;
const value = String(body[field] ?? '').trim().slice(0, max);
out[field] = value || null;
}
// Links ohne Protokoll ("www.firma.de") sonst als relative Adresse geoeffnet.
for (const field of ['linkedin', 'website']) {
if (out[field] && !/^https?:\/\//i.test(out[field])) out[field] = `https://${out[field]}`;
}
if (body.last_contact_on !== undefined) {
out.last_contact_on = /^\d{4}-\d{2}-\d{2}$/.test(String(body.last_contact_on ?? ''))
? body.last_contact_on
: null;
}
// Wie bei den Leads: Tage statt Datum, das Datum rechnet der Server.
if (body.remind_in_days !== undefined) {
const n = Number(body.remind_in_days);
out.remind_at = Number.isFinite(n) && n > 0 ? inDays(n) : null;
}
if (body.archived !== undefined) out.archived = body.archived ? 1 : 0;
if (body.lead_id !== undefined) {
const n = Number(body.lead_id);
out.lead_id = Number.isInteger(n) && n > 0 ? n : null;
}
return out;
}
const inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
const SELECT = `
SELECT s.*, l.title AS lead_title
FROM icon_suspects s
LEFT JOIN icon_leads l ON l.id = s.lead_id`;
const normalize = (row) => ({ ...row, archived: Boolean(row.archived) });
router.get('/', async (req, res, next) => {
const archived = String(req.query.archived ?? '0') === '1';
try {
// Neueste unten: die Tabelle waechst wie eine Liste nach unten.
const [rows] = await pool.query(`${SELECT} WHERE s.archived = ? ORDER BY ${archived ? 's.updated_at DESC' : 's.id'}`, [
archived ? 1 : 0,
]);
res.json({ suspects: rows.map(normalize) });
} catch (err) {
next(err);
}
});
/** Faellige Impulse - fragt der Erinnerungswaechter zusammen mit den Leads ab. */
router.get('/reminders', async (req, res, next) => {
try {
const [rows] = await pool.query(
`${SELECT} WHERE s.archived = 0 AND s.remind_at IS NOT NULL AND s.remind_at <= NOW() ORDER BY s.remind_at`
);
res.json({ due: rows.map(normalize) });
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const data = readBody(req.body || {});
if (!data.name) return res.status(400).json({ error: 'Der Name ist erforderlich.' });
try {
const columns = Object.keys(data);
const [result] = await pool.query(
`INSERT INTO icon_suspects (${columns.join(', ')}, created_by) VALUES (${columns.map(() => '?').join(', ')}, ?)`,
[...columns.map((c) => data[c]), req.user.id]
);
const [[row]] = await pool.query(`${SELECT} WHERE s.id = ?`, [result.insertId]);
res.status(201).json({ suspect: normalize(row) });
} catch (err) {
next(err);
}
});
router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
const data = readBody(req.body || {});
if (data.name === null) return res.status(400).json({ error: 'Der Name darf nicht leer sein.' });
const columns = Object.keys(data);
if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
try {
const [[before]] = await pool.query('SELECT name, archived FROM icon_suspects WHERE id = ?', [id]);
if (!before) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
await pool.query(`UPDATE icon_suspects SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`, [
...columns.map((c) => data[c]),
id,
]);
if (data.archived !== undefined && data.archived !== before.archived) {
await logHistory({
entityType: 'system',
entityLabel: 'Suspects',
action: data.archived ? 'archived' : 'updated',
text: data.lead_id
? `Suspect „${before.name}" als Lead übernommen`
: data.archived
? `Suspect „${before.name}" archiviert`
: `Suspect „${before.name}" aus dem Archiv zurückgeholt`,
userId: req.user.id,
});
}
const [[row]] = await pool.query(`${SELECT} WHERE s.id = ?`, [id]);
res.json({ suspect: normalize(row) });
} catch (err) {
next(err);
}
});
/**
* Impuls quittieren: `snooze_days` verschiebt ihn, sonst gilt die Person als
* kontaktiert - Letzter Kontakt wird heute, die Erinnerung verschwindet.
*/
router.post('/:id/acknowledge', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
const days = Number(req.body?.snooze_days);
try {
const [result] =
Number.isFinite(days) && days > 0
? await pool.query('UPDATE icon_suspects SET remind_at = ? WHERE id = ?', [inDays(days), id])
: await pool.query('UPDATE icon_suspects SET remind_at = NULL, last_contact_on = CURDATE() WHERE id = ?', [id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/** Endgueltig loeschen - nur aus dem Archiv, damit nichts aus Versehen verschwindet. */
router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
try {
const [[row]] = await pool.query('SELECT name, archived FROM icon_suspects WHERE id = ?', [id]);
if (!row) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
if (!row.archived) return res.status(409).json({ error: 'Bitte zuerst archivieren.' });
await pool.query('DELETE FROM icon_suspects WHERE id = ?', [id]);
await logHistory({
entityType: 'system',
entityLabel: 'Suspects',
action: 'deleted',
text: `Suspect „${row.name}" endgültig gelöscht`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;
+35
View File
@@ -339,6 +339,41 @@ CREATE TABLE IF NOT EXISTS icon_leads (
REFERENCES icon_contacts (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- Suspects - die Stufe vor dem Lead (29.09.2026)
--
-- Personen, die interessant sein koennten, als schlichte Tabelle: Name,
-- Funktion und Unternehmen sind bewusst Freitext, nicht Verweise auf Kontakte
-- und Unternehmen - ein Suspect soll in Sekunden erfasst sein. Erst beim
-- Schritt zum Lead wird gegen die Stammdaten abgeglichen.
--
-- `remind_at` ist der "naechste Impuls": dann erinnert das CRM daran, die
-- Person erneut anzusprechen. `lead_id` zeigt nach der Uebergabe auf den Lead;
-- der Suspect selbst wandert dann ins Archiv.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_suspects (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL,
role VARCHAR(190) NULL,
company VARCHAR(190) NULL,
linkedin VARCHAR(500) NULL,
website VARCHAR(500) NULL,
notes TEXT NULL,
last_contact_on DATE NULL,
remind_at DATETIME NULL,
archived TINYINT(1) NOT NULL DEFAULT 0,
lead_id INT UNSIGNED NULL,
created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_icon_suspects_archived (archived, id),
KEY idx_icon_suspects_remind (remind_at),
CONSTRAINT fk_icon_suspects_lead FOREIGN KEY (lead_id)
REFERENCES icon_leads (id) ON DELETE SET NULL,
CONSTRAINT fk_icon_suspects_creator FOREIGN KEY (created_by)
REFERENCES icon_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS icon_lead_todos (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
lead_id INT UNSIGNED NOT NULL,
+3
View File
@@ -22,6 +22,7 @@ import networkRoutes from './routes/network.js';
import connectionRoutes from './routes/connections.js';
import dashboardRoutes from './routes/dashboard.js';
import scannerRoutes from './routes/scanner.js';
import suspectRoutes from './routes/suspects.js';
import { startScheduler } from './scanner/scheduler.js';
const app = express();
@@ -61,6 +62,8 @@ app.use('/api/contacts', contactRoutes);
app.use('/api/tenders', tenderRoutes);
app.use('/api/projects', projectRoutes);
app.use('/api/leads', leadRoutes);
// Vorstufe der Leads: Personen als schlichte Tabelle.
app.use('/api/suspects', suspectRoutes);
app.use('/api/cvs', cvRoutes);
app.use('/api/ai', aiRoutes);
// Lesender Zugriff auf die Kontakte des perinco CRM - nichts wird gespeichert.
+12
View File
@@ -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}
+7
View File
@@ -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',
+11 -3
View File
@@ -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();
+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' ? (
<>
+638
View File
@@ -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';
+34
View File
@@ -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';
+2
View File
@@ -376,6 +376,8 @@ export interface LeadTodo {
export type View =
| 'Dashboard'
| 'Suspects'
| 'Suspects · Archiv'
| 'Leads'
| 'Leads · Archiviert'
| 'Kontakte'