From 6a452f1510a834fb32cfeb4d33646f026337f439 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Tue, 29 Sep 2026 12:11:36 +0200 Subject: [PATCH] Suspects: Vorstufe der Leads als Tabelle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/08-anleitung-benutzer.md | 20 + docs/13-suspects.md | 105 ++++ docs/README.md | 1 + server/src/routes/dashboard.js | 4 +- server/src/routes/suspects.js | 177 +++++++ server/src/schema.sql | 35 ++ server/src/server.js | 3 + src/App.tsx | 12 + src/components/Sidebar.tsx | 7 + src/components/leads/LeadModal.tsx | 14 +- src/components/leads/ReminderWatcher.tsx | 109 +++- src/components/suspects/Suspects.tsx | 638 +++++++++++++++++++++++ src/lib/api.ts | 34 ++ src/types.ts | 2 + 14 files changed, 1139 insertions(+), 22 deletions(-) create mode 100644 docs/13-suspects.md create mode 100644 server/src/routes/suspects.js create mode 100644 src/components/suspects/Suspects.tsx diff --git a/docs/08-anleitung-benutzer.md b/docs/08-anleitung-benutzer.md index 907e3b7..d8e560a 100644 --- a/docs/08-anleitung-benutzer.md +++ b/docs/08-anleitung-benutzer.md @@ -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**. diff --git a/docs/13-suspects.md b/docs/13-suspects.md new file mode 100644 index 0000000..1f4781f --- /dev/null +++ b/docs/13-suspects.md @@ -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
(Tabelle)"] -->|Lead erstellen| M["Lead-Karte
vorbelegt, Titel fehlt"] + M -->|Speichern| L["Lead in 'Neuer Lead'"] + M -->|Speichern| A["Suspect → Archiv
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`). diff --git a/docs/README.md b/docs/README.md index 6751ca7..4ff2b70 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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). diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js index 2007a0f..4cb7757 100644 --- a/server/src/routes/dashboard.js +++ b/server/src/routes/dashboard.js @@ -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( diff --git a/server/src/routes/suspects.js b/server/src/routes/suspects.js new file mode 100644 index 0000000..4d6e536 --- /dev/null +++ b/server/src/routes/suspects.js @@ -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; diff --git a/server/src/schema.sql b/server/src/schema.sql index 351840c..85fe120 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -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, diff --git a/server/src/server.js b/server/src/server.js index b4ac425..36b4be4 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -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. diff --git a/src/App.tsx b/src/App.tsx index 107841d..77aa73d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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') && ( + + )} + {(view === 'Leads' || view === 'Leads · Archiviert') && ( = ({ 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', diff --git a/src/components/leads/LeadModal.tsx b/src/components/leads/LeadModal.tsx index e5c49ca..fb98e43 100644 --- a/src/components/leads/LeadModal.tsx +++ b/src/components/leads/LeadModal.tsx @@ -32,6 +32,10 @@ interface LeadModalProps { companies: Company[]; contacts: Contact[]; users: DirectoryUser[]; + /** Vorbelegung beim Anlegen - z. B. aus einem Suspect. */ + initial?: Partial; + /** 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 = ({ companies, contacts, users, + initial, + onCreated, }) => { const { toast } = useToast(); const { open: openEntity } = useNavigation(); @@ -103,7 +109,7 @@ export const LeadModal: React.FC = ({ * 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 = ({ }; 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(); diff --git a/src/components/leads/ReminderWatcher.tsx b/src/components/leads/ReminderWatcher.tsx index af6bae8..fb47bf0 100644 --- a/src/components/leads/ReminderWatcher.tsx +++ b/src/components/leads/ReminderWatcher.tsx @@ -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>([]); + const { open, setView } = useNavigation(); + const [queue, setQueue] = useState([]); 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 ( + + void acknowledgeSuspect(7)}> + In 7 Tagen erneut + + void acknowledgeSuspect()}> + Kontakt aufgenommen + + + } + > +

+ Zeit, {suspect.name} erneut anzusprechen. +

+
+ {(suspect.role || suspect.company) && ( +

{[suspect.role, suspect.company].filter(Boolean).join(' · ')}

+ )} + {suspect.linkedin && ( + + LinkedIn-Profil öffnen ↗ + + )} + {suspect.notes &&

{suspect.notes}

} +

+ Letzter Kontakt: {suspect.last_contact_on ? formatDate(suspect.last_contact_on) : 'noch keiner'} +

+
+ +
+ ); + } + + 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' ? ( <> diff --git a/src/components/suspects/Suspects.tsx b/src/components/suspects/Suspects.tsx new file mode 100644 index 0000000..df038bb --- /dev/null +++ b/src/components/suspects/Suspects.tsx @@ -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 & { 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 = ({ archived, companies, contacts, users, onChanged }) => { + const { toast } = useToast(); + const [rows, setRows] = useState(null); + const [draft, setDraft] = useState({ ...EMPTY_DRAFT }); + const [impulse, setImpulse] = useState(null); + const [toLead, setToLead] = useState(null); + const [removing, setRemoving] = useState(null); + const draftName = useRef(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

Wird geladen …

; + + return ( +
+

+ {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.'} +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {rows.map((row) => + archived ? ( + void restore(row)} + onDelete={() => setRemoving(row)} + /> + ) : ( + + {(['name', 'role', 'company'] as const).map((field) => ( + + ))} + + + + + + + + ) + )} + + {!archived && ( + { + // 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) => ( + + ))} + + + )} + + {archived && rows.length === 0 && ( + + + + )} + +
NameFunktionUnternehmenLinkedInWebsiteNotizLetzter KontaktNächster Impuls +
+ void update(row, { [field]: value })} + /> + + void update(row, { linkedin: value })} /> + + void update(row, { website: value })} /> + + void update(row, { notes: value })} /> + + void update(row, { last_contact_on: event.target.value || null })} + className={cellInput} + /> + + setImpulse(row)} /> + +
+ void archive(row)} path={ARCHIVE_ICON} /> + setToLead(row)} path={LEAD_ICON} tone="brand" /> +
+
+ setDraft({ ...draft, [field]: event.target.value })} + placeholder={DRAFT_PLACEHOLDERS[field]} + className={cellInput} + /> + + setDraft({ ...draft, last_contact_on: event.target.value })} + className={cellInput} + /> + + +
+ Das Archiv ist leer. +
+
+ + 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 && ( + void leadCreated(lead)} + onSaved={onChanged} + onClose={() => setToLead(null)} + /> + )} + + void remove()} + onCancel={() => setRemoving(null)} + /> +
+ ); +}; + +const DRAFT_PLACEHOLDERS: Record = { + 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 ( + 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 ( + 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 ( + + + + + + + ); +}; + +/** 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 ( + + ); +}; + +const ImpulseDialog: React.FC<{ + suspect: Suspect | null; + onClose: () => void; + onSave: (days: number | null) => Promise; +}> = ({ suspect, onClose, onSave }) => { + const [days, setDays] = useState(''); + useEffect(() => setDays(''), [suspect]); + const valid = Number(days) >= 1 && Number.isInteger(Number(days)); + + return ( + + {suspect?.remind_at && ( + + )} + + Abbrechen + + void onSave(Number(days))}> + Speichern + + + } + > + + {suspect?.remind_at && ( +

+ Aktuell gesetzt: {formatDate(suspect.remind_at)}. Speichern ersetzt diesen Termin. +

+ )} +
+ ); +}; + +const ArchivedRow: React.FC<{ row: Suspect; onRestore: () => void; onDelete: () => void }> = ({ + row, + onRestore, + onDelete, +}) => ( + + + {row.name} + {row.lead_id && ( + + Lead + + )} + + {row.role ?? '–'} + {row.company ?? '–'} + {row.linkedin && } + {row.website && } + + {row.notes ?? ''} + + {row.last_contact_on ? formatDate(row.last_contact_on) : '–'} + {row.remind_at ? formatDate(row.remind_at) : '–'} + +
+ + +
+ + +); + +const StaticLink: React.FC<{ href: string; kind: 'linkedin' | 'website' }> = ({ href, kind }) => ( + + + +); + +const IconButton: React.FC<{ title: string; onClick: () => void; path: string; tone?: 'ink' | 'brand' | 'red' }> = ({ + title, + onClick, + path, + tone = 'ink', +}) => ( + +); + +/* ------------------------------------------------------ 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'; diff --git a/src/lib/api.ts b/src/lib/api.ts index 64b8865..3adc52c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -433,6 +433,40 @@ export const dashboardApi = { load: () => get('/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 +> & { 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'; diff --git a/src/types.ts b/src/types.ts index e897de4..0e524aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -376,6 +376,8 @@ export interface LeadTodo { export type View = | 'Dashboard' + | 'Suspects' + | 'Suspects · Archiv' | 'Leads' | 'Leads · Archiviert' | 'Kontakte'