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
@@ -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
|
Zahlen zum Bestand und die letzten Tätigkeiten. Von hier führt jeder Eintrag in
|
||||||
die zugehörige Karte.
|
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
|
## Leads
|
||||||
|
|
||||||
Die Pipeline als Kanban über acht Spalten, von **Backlog** bis **Absage**.
|
Die Pipeline als Kanban über acht Spalten, von **Backlog** bis **Absage**.
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -22,6 +22,7 @@ keine Vorkenntnisse voraus.
|
|||||||
| [10 API-Referenz](10-api.md) | Jeder Endpunkt, seine Rechte und seine Antwort |
|
| [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 |
|
| [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 |
|
| [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).
|
Die Designentscheidungen stehen im [README des Projekts](../README.md).
|
||||||
|
|
||||||
|
|||||||
@@ -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_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 = 0) AS leads_open,
|
||||||
(SELECT COUNT(*) FROM icon_leads WHERE archived = 1) AS leads_archived,
|
(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(
|
const [byStatus] = await pool.query(
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -339,6 +339,41 @@ CREATE TABLE IF NOT EXISTS icon_leads (
|
|||||||
REFERENCES icon_contacts (id) ON DELETE SET NULL
|
REFERENCES icon_contacts (id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS icon_lead_todos (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
lead_id INT UNSIGNED NOT NULL,
|
lead_id INT UNSIGNED NOT NULL,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import networkRoutes from './routes/network.js';
|
|||||||
import connectionRoutes from './routes/connections.js';
|
import connectionRoutes from './routes/connections.js';
|
||||||
import dashboardRoutes from './routes/dashboard.js';
|
import dashboardRoutes from './routes/dashboard.js';
|
||||||
import scannerRoutes from './routes/scanner.js';
|
import scannerRoutes from './routes/scanner.js';
|
||||||
|
import suspectRoutes from './routes/suspects.js';
|
||||||
import { startScheduler } from './scanner/scheduler.js';
|
import { startScheduler } from './scanner/scheduler.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -61,6 +62,8 @@ app.use('/api/contacts', contactRoutes);
|
|||||||
app.use('/api/tenders', tenderRoutes);
|
app.use('/api/tenders', tenderRoutes);
|
||||||
app.use('/api/projects', projectRoutes);
|
app.use('/api/projects', projectRoutes);
|
||||||
app.use('/api/leads', leadRoutes);
|
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/cvs', cvRoutes);
|
||||||
app.use('/api/ai', aiRoutes);
|
app.use('/api/ai', aiRoutes);
|
||||||
// Lesender Zugriff auf die Kontakte des perinco CRM - nichts wird gespeichert.
|
// Lesender Zugriff auf die Kontakte des perinco CRM - nichts wird gespeichert.
|
||||||
|
|||||||
+12
@@ -10,6 +10,7 @@ import { Dashboard } from './components/dashboard/Dashboard';
|
|||||||
import { Contacts } from './components/contacts/Contacts';
|
import { Contacts } from './components/contacts/Contacts';
|
||||||
import { Companies } from './components/companies/Companies';
|
import { Companies } from './components/companies/Companies';
|
||||||
import { Leads } from './components/leads/Leads';
|
import { Leads } from './components/leads/Leads';
|
||||||
|
import { Suspects } from './components/suspects/Suspects';
|
||||||
import { ReminderWatcher } from './components/leads/ReminderWatcher';
|
import { ReminderWatcher } from './components/leads/ReminderWatcher';
|
||||||
import { Tenders } from './components/tenders/Tenders';
|
import { Tenders } from './components/tenders/Tenders';
|
||||||
import { LiveBoard } from './components/tenders/LiveBoard';
|
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') && (
|
{(view === 'Leads' || view === 'Leads · Archiviert') && (
|
||||||
<Leads
|
<Leads
|
||||||
key={view}
|
key={view}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ view, onSelect, counts, collap
|
|||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
icon: icon('M3 3h8v8H3V3Zm10 0h8v5h-8V3ZM3 13h8v8H3v-8Zm10-3h8v11h-8V10Z'),
|
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',
|
view: 'Leads',
|
||||||
label: 'Leads',
|
label: 'Leads',
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ interface LeadModalProps {
|
|||||||
companies: Company[];
|
companies: Company[];
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
users: DirectoryUser[];
|
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';
|
type Tab = 'stamm' | 'todos' | 'historie';
|
||||||
@@ -61,6 +65,8 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
|||||||
companies,
|
companies,
|
||||||
contacts,
|
contacts,
|
||||||
users,
|
users,
|
||||||
|
initial,
|
||||||
|
onCreated,
|
||||||
}) => {
|
}) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { open: openEntity } = useNavigation();
|
const { open: openEntity } = useNavigation();
|
||||||
@@ -103,7 +109,7 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
|||||||
* Beim Anlegen ist der angemeldete Benutzer voreingestellt - in aller Regel
|
* Beim Anlegen ist der angemeldete Benutzer voreingestellt - in aller Regel
|
||||||
* kümmert sich der, der den Datensatz erfasst. Ändern lässt es sich weiterhin.
|
* 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;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -132,8 +138,10 @@ export const LeadModal: React.FC<LeadModalProps> = ({
|
|||||||
};
|
};
|
||||||
if (remindIn) body.remind_in_days = Number(remindIn);
|
if (remindIn) body.remind_in_days = Number(remindIn);
|
||||||
|
|
||||||
if (isNew) await leadsApi.create(body);
|
if (isNew) {
|
||||||
else await leadsApi.update(leadId, body);
|
const { lead: created } = await leadsApi.create(body);
|
||||||
|
onCreated?.(created);
|
||||||
|
} else await leadsApi.update(leadId, body);
|
||||||
toast('Gespeichert.', 'success');
|
toast('Gespeichert.', 'success');
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { formatStamp, type Lead } from '../../types';
|
import { formatDate, formatStamp, type Lead } from '../../types';
|
||||||
import { leadsApi } from '../../lib/api';
|
import { leadsApi, suspectsApi, type Suspect } from '../../lib/api';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { PrimaryButton, SecondaryButton } from '../ui/form';
|
import { PrimaryButton, SecondaryButton } from '../ui/form';
|
||||||
@@ -8,15 +8,17 @@ import { useNavigation } from '../../lib/navigation';
|
|||||||
import { useToast } from '../Toast';
|
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
|
* alt ein Lead wurde einen Monat lang nicht angefasst; dann fragt das
|
||||||
* CRM, ob er archiviert werden soll. Wird er das nicht, laeuft die
|
* CRM, ob er archiviert werden soll. Wird er das nicht, laeuft die
|
||||||
* Frist von vorn - das erledigt der Server, indem das Quittieren
|
* Frist von vorn - das erledigt der Server, indem das Quittieren
|
||||||
* `last_contact_at` auf jetzt setzt.
|
* `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:
|
* Abgefragt wird beim Anmelden und danach stuendlich. Oefter waere sinnlos:
|
||||||
* die Fristen sind Tage, nicht Minuten.
|
* die Fristen sind Tage, nicht Minuten.
|
||||||
@@ -24,23 +26,29 @@ import { useToast } from '../Toast';
|
|||||||
|
|
||||||
const POLL_MS = 60 * 60 * 1000;
|
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 }) => {
|
export const ReminderWatcher: React.FC<{ onChanged: () => void }> = ({ onChanged }) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { open } = useNavigation();
|
const { open, setView } = useNavigation();
|
||||||
const [queue, setQueue] = useState<Array<{ lead: Lead; kind: 'due' | 'stale' }>>([]);
|
const [queue, setQueue] = useState<Item[]>([]);
|
||||||
const [staleDays, setStaleDays] = useState(30);
|
const [staleDays, setStaleDays] = useState(30);
|
||||||
|
|
||||||
const check = useCallback(async () => {
|
const check = useCallback(async () => {
|
||||||
try {
|
// Beide Abfragen unabhaengig: faellt eine aus, kommt die andere trotzdem.
|
||||||
const result = await leadsApi.reminders();
|
const [leads, suspects] = await Promise.allSettled([leadsApi.reminders(), suspectsApi.reminders()]);
|
||||||
setStaleDays(result.stale_days);
|
const items: Item[] = [];
|
||||||
setQueue([
|
if (leads.status === 'fulfilled') {
|
||||||
...result.due.map((lead) => ({ lead, kind: 'due' as const })),
|
setStaleDays(leads.value.stale_days);
|
||||||
...result.stale.map((lead) => ({ lead, kind: 'stale' as const })),
|
items.push(
|
||||||
]);
|
...leads.value.due.map((lead) => ({ lead, kind: 'due' as const })),
|
||||||
} catch {
|
...leads.value.stale.map((lead) => ({ lead, kind: 'stale' as const }))
|
||||||
/* Kein Netz oder abgelaufene Sitzung - beim naechsten Durchlauf erneut. */
|
);
|
||||||
}
|
}
|
||||||
|
if (suspects.status === 'fulfilled') {
|
||||||
|
items.push(...suspects.value.due.map((suspect) => ({ suspect, kind: 'suspect' as const })));
|
||||||
|
}
|
||||||
|
setQueue(items);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,8 +60,73 @@ export const ReminderWatcher: React.FC<{ onChanged: () => void }> = ({ onChanged
|
|||||||
const current = queue[0];
|
const current = queue[0];
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
const { lead, kind } = current;
|
|
||||||
const next = () => setQueue((list) => list.slice(1));
|
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) => {
|
const acknowledge = async (snoozeDays?: number) => {
|
||||||
try {
|
try {
|
||||||
@@ -88,7 +161,7 @@ export const ReminderWatcher: React.FC<{ onChanged: () => void }> = ({ onChanged
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClose={() => void acknowledge()}
|
onClose={() => void acknowledge()}
|
||||||
title={kind === 'due' ? 'Erinnerung' : 'Lead liegt lange still'}
|
title={kind === 'due' ? 'Erinnerung' : 'Lead liegt lange still'}
|
||||||
subtitle={queue.length > 1 ? `${queue.length} offene Erinnerungen` : undefined}
|
subtitle={subtitle}
|
||||||
footer={
|
footer={
|
||||||
kind === 'due' ? (
|
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'),
|
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 */
|
/* ---------------------------------------------------------------- Scanner */
|
||||||
|
|
||||||
export type ScannerSourceId = 'ba' | 'stepstone' | 'karriere' | 'adzuna' | 'freelancermap';
|
export type ScannerSourceId = 'ba' | 'stepstone' | 'karriere' | 'adzuna' | 'freelancermap';
|
||||||
|
|||||||
@@ -376,6 +376,8 @@ export interface LeadTodo {
|
|||||||
|
|
||||||
export type View =
|
export type View =
|
||||||
| 'Dashboard'
|
| 'Dashboard'
|
||||||
|
| 'Suspects'
|
||||||
|
| 'Suspects · Archiv'
|
||||||
| 'Leads'
|
| 'Leads'
|
||||||
| 'Leads · Archiviert'
|
| 'Leads · Archiviert'
|
||||||
| 'Kontakte'
|
| 'Kontakte'
|
||||||
|
|||||||
Reference in New Issue
Block a user