Suspects: Vorstufe der Leads als Tabelle

Neuer Menüpunkt über Leads mit Unterpunkt Archiv.

- Tabelle Name, Funktion, Unternehmen, LinkedIn, Website, Notiz,
  Letzter Kontakt, Nächster Impuls; leere Zeile unten, speichert beim
  Verlassen. Links als Symbol mit URL im Tooltip.
- Nächster Impuls: Tage eingeben, Erinnerung über den ReminderWatcher
- Archivieren, Zurückholen, endgültig löschen (nur aus dem Archiv)
- Lead erstellen öffnet die Lead-Karte vorbelegt (LeadModal.initial),
  danach wandert der Suspect mit lead_id ins Archiv

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 12:11:36 +02:00
co-authored by Claude Opus 5.5
parent e0f6ef06cd
commit 6a452f1510
14 changed files with 1139 additions and 22 deletions
+20
View File
@@ -28,6 +28,26 @@ Oben in der Kopfleiste, oder mit `Strg+K`. Rechts im Feld zwei Symbole:
Zahlen zum Bestand und die letzten Tätigkeiten. Von hier führt jeder Eintrag in 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**.
+105
View File
@@ -0,0 +1,105 @@
# 13 Suspects
Die Stufe **vor** dem Lead: Personen, die interessant sein könnten, als
schlichte Tabelle. Eingeführt am 29.09.2026. Menü: **Suspects** (über Leads) mit
dem Unterpunkt **Archiv**.
```mermaid
flowchart LR
S["Suspect<br/>(Tabelle)"] -->|Lead erstellen| M["Lead-Karte<br/>vorbelegt, Titel fehlt"]
M -->|Speichern| L["Lead in 'Neuer Lead'"]
M -->|Speichern| A["Suspect → Archiv<br/>mit Verweis auf den Lead"]
S -->|Archivieren| A
A -->|Zurückholen| S
A -->|Endgültig löschen| X["weg"]
```
## Tabelle
| Spalte | Feld | Hinweis |
| --- | --- | --- |
| Name | `name` | Pflicht – ohne Namen wird eine neue Zeile nicht gespeichert |
| Funktion | `role` | Freitext |
| Unternehmen | `company` | Freitext, **kein** Verweis auf `icon_companies` |
| LinkedIn | `linkedin` | nach dem Speichern nur ein Symbol, URL im Tooltip |
| Website | `website` | ebenso; fehlt `https://`, ergänzt es der Server |
| Notiz | `notes` | einzeilig, volle Länge im Tooltip |
| Letzter Kontakt | `last_contact_on` | Datum |
| Nächster Impuls | `remind_at` | Wecker: Anzahl Tage eingeben, der Server rechnet das Datum |
**Bedienung wie eine Liste.** Unten steht immer eine leere Zeile. Gespeichert
wird, sobald der Fokus die Zeile verlässt oder Enter gedrückt wird. Vorhandene
Zellen speichern beim Verlassen der Zelle; Esc verwirft die Eingabe. Links
bearbeitet man über den kleinen Stift, der beim Überfahren neben dem Symbol
erscheint.
Name, Funktion und Unternehmen sind bewusst Freitext: ein Suspect soll in
Sekunden erfasst sein, ohne erst Kontakt und Unternehmen anzulegen.
## Nächster Impuls
Klick auf den Wecker → Anzahl Tage → Speichern. Gesetzt zeigt die Zelle das
Datum (rot, wenn fällig). Fällige Impulse meldet derselbe Wächter wie die
Lead-Erinnerungen (`ReminderWatcher`, beim Anmelden und stündlich):
| Knopf | Wirkung |
| --- | --- |
| Kontakt aufgenommen | `remind_at` leer, **Letzter Kontakt = heute** |
| In 7 Tagen erneut | `remind_at` + 7 Tage |
| Schließen (×) | nichts – meldet sich beim nächsten Abfragen wieder |
## Lead erstellen
Das Lead-Symbol am Zeilenende öffnet die **Lead-Karte vorbelegt**
(`LeadModal` mit `initial`):
- Phase **Neuer Lead**, zuständig der angemeldete Benutzer
- **Unternehmen** und **Ansprechpartner** nur, wenn es im CRM einen mit gleichem
Namen gibt (Groß/klein, Rechtsform und Satzzeichen egal)
- **Notiz** mit allem aus der Zeile: Name, Funktion, Unternehmen, LinkedIn,
Website, letzter Kontakt, Notiz
- **Titel bleibt leer** – der ist Pflicht und muss eingetragen werden
Erst beim Speichern entsteht der Lead; danach wandert der Suspect ins Archiv
und trägt `lead_id` (im Archiv als **Lead** markiert). Abbrechen ändert nichts.
## Archiv
Archivierte und in Leads übernommene Suspects, neueste zuerst. **Zurückholen**
stellt die Zeile wieder in die Tabelle, **Endgültig löschen** entfernt sie
(mit Rückfrage). Löschen geht nur aus dem Archiv – der Server antwortet sonst
mit `409`.
## Tabelle `icon_suspects`
| Spalte | Typ | Bedeutung |
| --- | --- | --- |
| `id` | INT | |
| `name`, `role`, `company` | VARCHAR(190) | |
| `linkedin`, `website` | VARCHAR(500) | immer mit Protokoll gespeichert |
| `notes` | TEXT | |
| `last_contact_on` | DATE | |
| `remind_at` | DATETIME | nächster Impuls, UTC wie bei den Leads |
| `archived` | TINYINT | |
| `lead_id` | INT NULL | → `icon_leads`, `ON DELETE SET NULL` |
| `created_by` | INT NULL | → `icon_users` |
In die Historie (als Systemeintrag „Suspects") kommen Archivieren,
Zurückholen, Übernahme als Lead und endgültiges Löschen – nicht jede
Zelländerung.
## Endpunkte
Alle unter `/api/suspects`, alle hinter `requireAuth`.
| Methode | Pfad | Zweck |
| --- | --- | --- |
| GET | `/?archived=0\|1` | Liste; aktiv älteste zuerst, Archiv neueste zuerst |
| GET | `/reminders` | fällige Impulse (`remind_at <= NOW()`, nicht archiviert) |
| POST | `/` | anlegen, `name` Pflicht |
| PATCH | `/:id` | beliebige Felder; `remind_in_days` (Zahl oder `null`), `archived`, `lead_id` |
| POST | `/:id/acknowledge` | `{ snooze_days }` verschiebt, sonst Kontakt heute + Impuls weg |
| DELETE | `/:id` | nur archivierte (`409` sonst) |
Die Zähler im Menü kommen aus `GET /api/dashboard`
(`counts.suspects_open`, `counts.suspects_archived`).
+1
View File
@@ -22,6 +22,7 @@ keine Vorkenntnisse voraus.
| [10 API-Referenz](10-api.md) | Jeder Endpunkt, seine Rechte und seine Antwort | | [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).
+3 -1
View File
@@ -28,7 +28,9 @@ router.get('/', async (req, res, next) => {
(SELECT COUNT(*) FROM icon_projects WHERE status = 'Aktiv') AS projects_active, (SELECT COUNT(*) FROM icon_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(
+177
View File
@@ -0,0 +1,177 @@
import express from 'express';
import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js';
import { logHistory } from '../history.js';
/*
* Suspects - die Stufe vor dem Lead (siehe docs/13-suspects.md).
*
* Eine schlichte Tabelle, die man wie eine Liste fuellt. Aus einer Zeile wird
* spaeter ein Lead; dann traegt sie `lead_id` und liegt im Archiv.
*/
const router = express.Router();
router.use(requireAuth);
const TEXT_FIELDS = { name: 190, role: 190, company: 190, linkedin: 500, website: 500, notes: 5000 };
function readBody(body) {
const out = {};
for (const [field, max] of Object.entries(TEXT_FIELDS)) {
if (body[field] === undefined) continue;
const value = String(body[field] ?? '').trim().slice(0, max);
out[field] = value || null;
}
// Links ohne Protokoll ("www.firma.de") sonst als relative Adresse geoeffnet.
for (const field of ['linkedin', 'website']) {
if (out[field] && !/^https?:\/\//i.test(out[field])) out[field] = `https://${out[field]}`;
}
if (body.last_contact_on !== undefined) {
out.last_contact_on = /^\d{4}-\d{2}-\d{2}$/.test(String(body.last_contact_on ?? ''))
? body.last_contact_on
: null;
}
// Wie bei den Leads: Tage statt Datum, das Datum rechnet der Server.
if (body.remind_in_days !== undefined) {
const n = Number(body.remind_in_days);
out.remind_at = Number.isFinite(n) && n > 0 ? inDays(n) : null;
}
if (body.archived !== undefined) out.archived = body.archived ? 1 : 0;
if (body.lead_id !== undefined) {
const n = Number(body.lead_id);
out.lead_id = Number.isInteger(n) && n > 0 ? n : null;
}
return out;
}
const inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
const SELECT = `
SELECT s.*, l.title AS lead_title
FROM icon_suspects s
LEFT JOIN icon_leads l ON l.id = s.lead_id`;
const normalize = (row) => ({ ...row, archived: Boolean(row.archived) });
router.get('/', async (req, res, next) => {
const archived = String(req.query.archived ?? '0') === '1';
try {
// Neueste unten: die Tabelle waechst wie eine Liste nach unten.
const [rows] = await pool.query(`${SELECT} WHERE s.archived = ? ORDER BY ${archived ? 's.updated_at DESC' : 's.id'}`, [
archived ? 1 : 0,
]);
res.json({ suspects: rows.map(normalize) });
} catch (err) {
next(err);
}
});
/** Faellige Impulse - fragt der Erinnerungswaechter zusammen mit den Leads ab. */
router.get('/reminders', async (req, res, next) => {
try {
const [rows] = await pool.query(
`${SELECT} WHERE s.archived = 0 AND s.remind_at IS NOT NULL AND s.remind_at <= NOW() ORDER BY s.remind_at`
);
res.json({ due: rows.map(normalize) });
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const data = readBody(req.body || {});
if (!data.name) return res.status(400).json({ error: 'Der Name ist erforderlich.' });
try {
const columns = Object.keys(data);
const [result] = await pool.query(
`INSERT INTO icon_suspects (${columns.join(', ')}, created_by) VALUES (${columns.map(() => '?').join(', ')}, ?)`,
[...columns.map((c) => data[c]), req.user.id]
);
const [[row]] = await pool.query(`${SELECT} WHERE s.id = ?`, [result.insertId]);
res.status(201).json({ suspect: normalize(row) });
} catch (err) {
next(err);
}
});
router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
const data = readBody(req.body || {});
if (data.name === null) return res.status(400).json({ error: 'Der Name darf nicht leer sein.' });
const columns = Object.keys(data);
if (columns.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
try {
const [[before]] = await pool.query('SELECT name, archived FROM icon_suspects WHERE id = ?', [id]);
if (!before) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
await pool.query(`UPDATE icon_suspects SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`, [
...columns.map((c) => data[c]),
id,
]);
if (data.archived !== undefined && data.archived !== before.archived) {
await logHistory({
entityType: 'system',
entityLabel: 'Suspects',
action: data.archived ? 'archived' : 'updated',
text: data.lead_id
? `Suspect „${before.name}" als Lead übernommen`
: data.archived
? `Suspect „${before.name}" archiviert`
: `Suspect „${before.name}" aus dem Archiv zurückgeholt`,
userId: req.user.id,
});
}
const [[row]] = await pool.query(`${SELECT} WHERE s.id = ?`, [id]);
res.json({ suspect: normalize(row) });
} catch (err) {
next(err);
}
});
/**
* Impuls quittieren: `snooze_days` verschiebt ihn, sonst gilt die Person als
* kontaktiert - Letzter Kontakt wird heute, die Erinnerung verschwindet.
*/
router.post('/:id/acknowledge', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
const days = Number(req.body?.snooze_days);
try {
const [result] =
Number.isFinite(days) && days > 0
? await pool.query('UPDATE icon_suspects SET remind_at = ? WHERE id = ?', [inDays(days), id])
: await pool.query('UPDATE icon_suspects SET remind_at = NULL, last_contact_on = CURDATE() WHERE id = ?', [id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/** Endgueltig loeschen - nur aus dem Archiv, damit nichts aus Versehen verschwindet. */
router.delete('/:id', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' });
try {
const [[row]] = await pool.query('SELECT name, archived FROM icon_suspects WHERE id = ?', [id]);
if (!row) return res.status(404).json({ error: 'Suspect nicht gefunden.' });
if (!row.archived) return res.status(409).json({ error: 'Bitte zuerst archivieren.' });
await pool.query('DELETE FROM icon_suspects WHERE id = ?', [id]);
await logHistory({
entityType: 'system',
entityLabel: 'Suspects',
action: 'deleted',
text: `Suspect „${row.name}" endgültig gelöscht`,
userId: req.user.id,
});
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;
+35
View File
@@ -339,6 +339,41 @@ CREATE TABLE IF NOT EXISTS icon_leads (
REFERENCES icon_contacts (id) ON DELETE SET NULL 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,
+3
View File
@@ -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
View File
@@ -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}
+7
View File
@@ -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',
+11 -3
View File
@@ -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();
+91 -18
View File
@@ -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' ? (
<> <>
+638
View File
@@ -0,0 +1,638 @@
import React, { useEffect, useRef, useState } from 'react';
import { formatDate, type Company, type Contact, type DirectoryUser, type Lead } from '../../types';
import { suspectsApi, type Suspect, type SuspectInput } from '../../lib/api';
import { LeadModal } from '../leads/LeadModal';
import { ConfirmDialog, Modal } from '../ui/Modal';
import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { useToast } from '../Toast';
/*
* Suspects - die Stufe vor dem Lead.
*
* Eine Tabelle, die sich wie eine Liste füllt: unten steht immer eine leere
* Zeile, jede Zelle ist direkt bearbeitbar und speichert beim Verlassen.
*
* Links (LinkedIn, Website) werden nach dem Speichern zu einem Symbol - die
* Adresse steht im Tooltip. Das spart die Breite, die eine URL sonst frisst.
*
* "Nächster Impuls" setzt eine Erinnerung in X Tagen; fällig meldet sie sich
* über denselben Wächter wie die Lead-Erinnerungen.
*
* Aus einer Zeile wird per Knopf ein Lead: die Lead-Karte öffnet sich
* vorbelegt (Titel fehlt noch), nach dem Speichern wandert der Suspect ins
* Archiv und trägt den Verweis auf den Lead.
*/
interface SuspectsProps {
archived: boolean;
companies: Company[];
contacts: Contact[];
users: DirectoryUser[];
onChanged: () => void;
}
type TextField = 'name' | 'role' | 'company' | 'linkedin' | 'website' | 'notes';
const EMPTY_DRAFT: Record<TextField, string> & { last_contact_on: string } = {
name: '',
role: '',
company: '',
linkedin: '',
website: '',
notes: '',
last_contact_on: '',
};
const cellInput =
'w-full min-w-0 rounded border border-transparent bg-transparent px-2 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 hover:border-ink-200 focus:border-brand-400 focus:bg-surface focus:outline-none';
const today = () => new Date().toISOString().slice(0, 10);
export const Suspects: React.FC<SuspectsProps> = ({ archived, companies, contacts, users, onChanged }) => {
const { toast } = useToast();
const [rows, setRows] = useState<Suspect[] | null>(null);
const [draft, setDraft] = useState({ ...EMPTY_DRAFT });
const [impulse, setImpulse] = useState<Suspect | null>(null);
const [toLead, setToLead] = useState<Suspect | null>(null);
const [removing, setRemoving] = useState<Suspect | null>(null);
const draftName = useRef<HTMLInputElement>(null);
const load = async () => {
try {
const { suspects } = await suspectsApi.list(archived);
setRows(suspects);
} catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
}
};
useEffect(() => {
setRows(null);
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [archived]);
const replace = (suspect: Suspect) =>
setRows((list) => list?.map((row) => (row.id === suspect.id ? suspect : row)) ?? null);
const update = async (row: Suspect, changes: SuspectInput) => {
try {
const { suspect } = await suspectsApi.update(row.id, changes);
replace(suspect);
return suspect;
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
return null;
}
};
/** Neue Zeile anlegen - sobald die Zeile verlassen oder Enter gedrückt wird. */
const createDraft = async () => {
const hasContent = Object.values(draft).some((value) => value.trim());
if (!hasContent) return;
if (!draft.name.trim()) {
toast('Bitte einen Namen eintragen – dann wird die Zeile gespeichert.', 'error');
return;
}
try {
const { suspect } = await suspectsApi.create({
...draft,
last_contact_on: draft.last_contact_on || null,
});
setRows((list) => [...(list ?? []), suspect]);
setDraft({ ...EMPTY_DRAFT });
onChanged();
requestAnimationFrame(() => draftName.current?.focus());
} catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
}
};
const archive = async (row: Suspect) => {
if (await update(row, { archived: true })) {
setRows((list) => list?.filter((item) => item.id !== row.id) ?? null);
toast(`„${row.name}" archiviert.`, 'success');
onChanged();
}
};
const restore = async (row: Suspect) => {
if (await update(row, { archived: false })) {
setRows((list) => list?.filter((item) => item.id !== row.id) ?? null);
toast(`„${row.name}" ist zurück in Suspects.`, 'success');
onChanged();
}
};
const remove = async () => {
if (!removing) return;
try {
await suspectsApi.remove(removing.id);
setRows((list) => list?.filter((item) => item.id !== removing.id) ?? null);
toast('Endgültig gelöscht.', 'success');
onChanged();
} catch (err) {
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
} finally {
setRemoving(null);
}
};
const leadCreated = async (lead: Lead) => {
if (!toLead) return;
const row = toLead;
try {
await suspectsApi.update(row.id, { archived: true, lead_id: lead.id });
setRows((list) => list?.filter((item) => item.id !== row.id) ?? null);
toast(`Lead angelegt – „${row.name}" liegt jetzt im Archiv.`, 'success');
} catch (err) {
toast(err instanceof Error ? err.message : 'Suspect konnte nicht archiviert werden.', 'error');
}
};
if (rows === null) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
return (
<div className="space-y-4">
<p className="max-w-3xl text-sm text-ink-500">
{archived
? 'Archivierte Suspects und solche, aus denen ein Lead wurde. Zurückholen oder endgültig löschen.'
: 'Personen, die interessant sein könnten. Einfach in die leere Zeile unten schreiben – gespeichert wird beim Verlassen der Zeile oder mit Enter.'}
</p>
<div className="overflow-x-auto rounded-xl border border-ink-200 bg-surface">
<table className="w-full min-w-[1100px] table-fixed text-sm">
<colgroup>
<col className="w-[15%]" />
<col className="w-[14%]" />
<col className="w-[14%]" />
<col className="w-[72px]" />
<col className="w-[72px]" />
<col />
<col className="w-[130px]" />
<col className="w-[120px]" />
<col className="w-[96px]" />
</colgroup>
<thead>
<tr className="border-b border-ink-200 text-left text-[11px] font-semibold uppercase tracking-wider text-ink-500">
<th className="px-3 py-3">Name</th>
<th className="px-3 py-3">Funktion</th>
<th className="px-3 py-3">Unternehmen</th>
<th className="px-3 py-3 text-center">LinkedIn</th>
<th className="px-3 py-3 text-center">Website</th>
<th className="px-3 py-3">Notiz</th>
<th className="px-3 py-3">Letzter Kontakt</th>
<th className="px-3 py-3 text-center">Nächster Impuls</th>
<th className="px-3 py-3" aria-label="Aktionen" />
</tr>
</thead>
<tbody>
{rows.map((row) =>
archived ? (
<ArchivedRow
key={row.id}
row={row}
onRestore={() => void restore(row)}
onDelete={() => setRemoving(row)}
/>
) : (
<tr key={row.id} className="border-b border-ink-100 align-middle hover:bg-ink-50/60">
{(['name', 'role', 'company'] as const).map((field) => (
<td key={field} className="px-1 py-1">
<TextCell
value={row[field]}
required={field === 'name'}
onCommit={(value) => void update(row, { [field]: value })}
/>
</td>
))}
<td className="px-1 py-1 text-center">
<LinkCell value={row.linkedin} kind="linkedin" onCommit={(value) => void update(row, { linkedin: value })} />
</td>
<td className="px-1 py-1 text-center">
<LinkCell value={row.website} kind="website" onCommit={(value) => void update(row, { website: value })} />
</td>
<td className="px-1 py-1">
<TextCell value={row.notes} onCommit={(value) => void update(row, { notes: value })} />
</td>
<td className="px-1 py-1">
<input
type="date"
value={row.last_contact_on ?? ''}
max={today()}
onChange={(event) => void update(row, { last_contact_on: event.target.value || null })}
className={cellInput}
/>
</td>
<td className="px-1 py-1 text-center">
<ImpulseButton remindAt={row.remind_at} onClick={() => setImpulse(row)} />
</td>
<td className="px-2 py-1">
<div className="flex justify-end gap-1">
<IconButton title="Archivieren" onClick={() => void archive(row)} path={ARCHIVE_ICON} />
<IconButton title="Lead erstellen" onClick={() => setToLead(row)} path={LEAD_ICON} tone="brand" />
</div>
</td>
</tr>
)
)}
{!archived && (
<tr
className="bg-ink-50/40"
onBlur={(event) => {
// Erst speichern, wenn der Fokus die Zeile ganz verlässt.
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) void createDraft();
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
void createDraft();
}
}}
>
{(['name', 'role', 'company', 'linkedin', 'website', 'notes'] as const).map((field) => (
<td key={field} className="px-1 py-1">
<input
ref={field === 'name' ? draftName : undefined}
value={draft[field]}
onChange={(event) => setDraft({ ...draft, [field]: event.target.value })}
placeholder={DRAFT_PLACEHOLDERS[field]}
className={cellInput}
/>
</td>
))}
<td className="px-1 py-1">
<input
type="date"
value={draft.last_contact_on}
max={today()}
onChange={(event) => setDraft({ ...draft, last_contact_on: event.target.value })}
className={cellInput}
/>
</td>
<td />
<td />
</tr>
)}
{archived && rows.length === 0 && (
<tr>
<td colSpan={9} className="py-12 text-center text-sm text-ink-400">
Das Archiv ist leer.
</td>
</tr>
)}
</tbody>
</table>
</div>
<ImpulseDialog
suspect={impulse}
onClose={() => setImpulse(null)}
onSave={async (days) => {
if (!impulse) return;
const saved = await update(impulse, { remind_in_days: days });
if (saved) {
toast(days ? `Erinnerung in ${days} Tag(en).` : 'Erinnerung entfernt.', 'success');
setImpulse(null);
}
}}
/>
{toLead && (
<LeadModal
leadId={null}
companies={companies}
contacts={contacts}
users={users}
initial={leadFromSuspect(toLead, companies, contacts)}
onCreated={(lead) => void leadCreated(lead)}
onSaved={onChanged}
onClose={() => setToLead(null)}
/>
)}
<ConfirmDialog
open={Boolean(removing)}
title="Endgültig löschen?"
message={`„${removing?.name}" wird gelöscht. Das lässt sich nicht rückgängig machen.`}
onConfirm={() => void remove()}
onCancel={() => setRemoving(null)}
/>
</div>
);
};
const DRAFT_PLACEHOLDERS: Record<TextField, string> = {
name: '+ Name',
role: 'Funktion',
company: 'Unternehmen',
linkedin: 'LinkedIn-URL',
website: 'Website',
notes: 'Notiz',
};
/* ------------------------------------------------------------ Bausteine */
/** Textzelle: speichert beim Verlassen, Esc verwirft. */
const TextCell: React.FC<{ value: string | null; required?: boolean; onCommit: (value: string | null) => void }> = ({
value,
required,
onCommit,
}) => {
const [text, setText] = useState(value ?? '');
useEffect(() => setText(value ?? ''), [value]);
const commit = () => {
const next = text.trim();
if (next === (value ?? '')) return;
if (required && !next) {
setText(value ?? '');
return;
}
onCommit(next || null);
};
return (
<input
value={text}
title={text.length > 30 ? text : undefined}
onChange={(event) => setText(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setText(value ?? '');
requestAnimationFrame(() => (event.target as HTMLInputElement).blur());
}
}}
className={`${cellInput} truncate ${required ? 'font-medium' : ''}`}
/>
);
};
/** Link als Symbol; die Adresse zeigt der Tooltip. Bearbeiten über den Stift. */
const LinkCell: React.FC<{ value: string | null; kind: 'linkedin' | 'website'; onCommit: (value: string | null) => void }> = ({
value,
kind,
onCommit,
}) => {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(value ?? '');
useEffect(() => setText(value ?? ''), [value]);
if (editing || !value) {
return (
<input
autoFocus={editing}
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
setEditing(false);
if (text.trim() !== (value ?? '')) onCommit(text.trim() || null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setText(value ?? '');
setEditing(false);
}
}}
placeholder="URL"
className={`${cellInput} text-xs`}
/>
);
}
return (
<span className="group inline-flex items-center gap-0.5">
<a
href={value}
target="_blank"
rel="noopener noreferrer"
title={value}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-ink-100 ${
kind === 'linkedin' ? 'text-[#0a66c2]' : 'text-brand-600'
}`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d={kind === 'linkedin' ? LINKEDIN_ICON : LINK_ICON} />
</svg>
</a>
<button
type="button"
onClick={() => setEditing(true)}
title="Link bearbeiten"
className="invisible rounded p-0.5 text-ink-400 hover:text-ink-700 group-hover:visible"
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="currentColor" aria-hidden="true">
<path d="m14.1 5.1 4.8 4.8L8.8 20H4v-4.8L14.1 5.1Zm1.4-1.4 1.8-1.8a1 1 0 0 1 1.4 0l3.4 3.4a1 1 0 0 1 0 1.4l-1.8 1.8-4.8-4.8Z" />
</svg>
</button>
</span>
);
};
/** Wecker - mit Datum daneben, wenn ein Impuls gesetzt ist; rot, wenn fällig. */
const ImpulseButton: React.FC<{ remindAt: string | null; onClick: () => void }> = ({ remindAt, onClick }) => {
const date = remindAt ? new Date(remindAt.replace(' ', 'T') + (remindAt.includes('Z') ? '' : 'Z')) : null;
const due = date ? date.getTime() <= Date.now() : false;
return (
<button
type="button"
onClick={onClick}
title={date ? `Erinnerung am ${date.toLocaleDateString('de-DE')}` : 'Erinnerung setzen'}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium hover:bg-ink-100 ${
due ? 'text-red-600' : date ? 'text-brand-700' : 'text-ink-400'
}`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d={ALARM_ICON} />
</svg>
{date && date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}
</button>
);
};
const ImpulseDialog: React.FC<{
suspect: Suspect | null;
onClose: () => void;
onSave: (days: number | null) => Promise<void>;
}> = ({ suspect, onClose, onSave }) => {
const [days, setDays] = useState('');
useEffect(() => setDays(''), [suspect]);
const valid = Number(days) >= 1 && Number.isInteger(Number(days));
return (
<Modal
open={Boolean(suspect)}
onClose={onClose}
size="sm"
title="Nächster Impuls"
subtitle={suspect ? [suspect.name, suspect.company].filter(Boolean).join(' · ') : undefined}
footer={
<>
{suspect?.remind_at && (
<button
type="button"
onClick={() => void onSave(null)}
className="mr-auto rounded-lg px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
>
Erinnerung entfernen
</button>
)}
<SecondaryButton type="button" onClick={onClose}>
Abbrechen
</SecondaryButton>
<PrimaryButton type="button" disabled={!valid} onClick={() => void onSave(Number(days))}>
Speichern
</PrimaryButton>
</>
}
>
<label className="block text-sm text-ink-700">
In wie vielen Tagen soll das CRM an einen erneuten Kontakt erinnern?
<input
type="number"
min={1}
autoFocus
value={days}
onChange={(event) => setDays(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && valid) void onSave(Number(days));
}}
placeholder="z. B. 14"
className={`${inputClass} mt-2 w-32`}
/>
</label>
{suspect?.remind_at && (
<p className="mt-3 text-xs text-ink-400">
Aktuell gesetzt: {formatDate(suspect.remind_at)}. Speichern ersetzt diesen Termin.
</p>
)}
</Modal>
);
};
const ArchivedRow: React.FC<{ row: Suspect; onRestore: () => void; onDelete: () => void }> = ({
row,
onRestore,
onDelete,
}) => (
<tr className="border-b border-ink-100 last:border-0">
<td className="truncate px-3 py-2.5 font-medium text-ink-900">
{row.name}
{row.lead_id && (
<span
title={row.lead_title ? `Lead: ${row.lead_title}` : 'Lead angelegt'}
className="ml-2 rounded border border-emerald-300 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-bold uppercase text-emerald-700"
>
Lead
</span>
)}
</td>
<td className="truncate px-3 py-2.5 text-ink-600">{row.role ?? '–'}</td>
<td className="truncate px-3 py-2.5 text-ink-600">{row.company ?? '–'}</td>
<td className="px-3 py-2.5 text-center">{row.linkedin && <StaticLink href={row.linkedin} kind="linkedin" />}</td>
<td className="px-3 py-2.5 text-center">{row.website && <StaticLink href={row.website} kind="website" />}</td>
<td className="truncate px-3 py-2.5 text-ink-600" title={row.notes ?? undefined}>
{row.notes ?? ''}
</td>
<td className="px-3 py-2.5 text-ink-600">{row.last_contact_on ? formatDate(row.last_contact_on) : '–'}</td>
<td className="px-3 py-2.5 text-center text-ink-400">{row.remind_at ? formatDate(row.remind_at) : '–'}</td>
<td className="px-2 py-2">
<div className="flex justify-end gap-1">
<IconButton title="Zurückholen" onClick={onRestore} path={RESTORE_ICON} tone="brand" />
<IconButton title="Endgültig löschen" onClick={onDelete} path={DELETE_ICON} tone="red" />
</div>
</td>
</tr>
);
const StaticLink: React.FC<{ href: string; kind: 'linkedin' | 'website' }> = ({ href, kind }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title={href}
className={`inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-ink-100 ${
kind === 'linkedin' ? 'text-[#0a66c2]' : 'text-brand-600'
}`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d={kind === 'linkedin' ? LINKEDIN_ICON : LINK_ICON} />
</svg>
</a>
);
const IconButton: React.FC<{ title: string; onClick: () => void; path: string; tone?: 'ink' | 'brand' | 'red' }> = ({
title,
onClick,
path,
tone = 'ink',
}) => (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-ink-100 ${
tone === 'brand' ? 'text-brand-600' : tone === 'red' ? 'text-red-600 hover:bg-red-50' : 'text-ink-500'
}`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d={path} />
</svg>
</button>
);
/* ------------------------------------------------------ Suspect -> Lead */
const plain = (value: string) =>
value
.toLowerCase()
.replace(/\b(gmbh|ag|se|kg|co|mbh|ug)\b\.?/g, '')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
/**
* Vorbelegung der Lead-Karte. Unternehmen und Ansprechpartner werden nur
* gesetzt, wenn es sie im CRM mit gleichem Namen gibt; alles andere landet in
* der Notiz, damit nichts verloren geht.
*/
function leadFromSuspect(row: Suspect, companies: Company[], contacts: Contact[]) {
const company = row.company ? companies.find((item) => plain(item.name) === plain(row.company!)) : undefined;
const contact = contacts.find((item) => plain(item.name) === plain(row.name));
const notes = [
`Aus Suspects: ${row.name}${row.role ? `, ${row.role}` : ''}${row.company ? ` (${row.company})` : ''}`,
row.linkedin ? `LinkedIn: ${row.linkedin}` : null,
row.website ? `Website: ${row.website}` : null,
row.last_contact_on ? `Letzter Kontakt: ${formatDate(row.last_contact_on)}` : null,
row.notes ? `\n${row.notes}` : null,
]
.filter(Boolean)
.join('\n');
return {
title: '',
stage: 'Neuer Lead' as const,
company_id: (company?.id ?? '') as number | '',
contact_id: (contact?.id ?? '') as number | '',
notes,
};
}
/* ----------------------------------------------------------------- Icons */
const ARCHIVE_ICON = 'M3 3h18v5H3V3Zm1 6h16v12H4V9Zm5 3v2h6v-2H9Z';
const LEAD_ICON = 'M3 5h18v4H3V5Zm0 6h8v8H3v-8Zm10 0h8v3h-8v-3Zm0 5h8v3h-8v-3Z';
const RESTORE_ICON = 'M12 5V2L7 6l5 4V7a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8Z';
const DELETE_ICON = 'M9 3h6l1 1h4v2H4V4h4l1-1Zm-3 5h12l-1 13H7L6 8Zm4 3v8h2v-8h-2Zm4 0v8h2v-8h-2Z';
const ALARM_ICON =
'M12 5a8 8 0 1 1 0 16 8 8 0 0 1 0-16Zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-1 2h2v4l3 2-1 1.6-4-2.6V9ZM6.3 2.3l1.4 1.4-3.9 3.9-1.4-1.4 3.9-3.9Zm11.4 0 3.9 3.9-1.4 1.4-3.9-3.9 1.4-1.4Z';
const LINK_ICON =
'M10.6 13.4a1 1 0 0 1 0-1.4l3.5-3.5a1 1 0 1 1 1.4 1.4L12 13.4a1 1 0 0 1-1.4 0ZM8.5 11.3 6.4 13.4a3 3 0 0 0 4.2 4.2l2.1-2.1 1.4 1.4-2.1 2.1a5 5 0 0 1-7-7l2.1-2.1 1.4 1.4Zm7-5.7a3 3 0 0 0-4.2 0L9.2 7.7 7.8 6.3l2.1-2.1a5 5 0 0 1 7 7l-2.1 2.1-1.4-1.4 2.1-2.1a3 3 0 0 0 0-4.2Z';
const LINKEDIN_ICON =
'M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm3.3 7H5v8h2.3v-8ZM6.2 5.8a1.3 1.3 0 1 0 0 2.7 1.3 1.3 0 0 0 0-2.7ZM19 13.4c0-2.2-1.2-3.5-3.1-3.5-1.1 0-1.9.6-2.2 1.1V10h-2.2v8h2.3v-4.2c0-1.1.5-1.8 1.4-1.8.8 0 1.3.6 1.3 1.8V18H19v-4.6Z';
+34
View File
@@ -433,6 +433,40 @@ export const dashboardApi = {
load: () => get<DashboardData>('/dashboard'), 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';
+2
View File
@@ -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'