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