From b8a78f609ccc10b555c6a7fec044ce75be044e39 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Tue, 18 Aug 2026 11:56:58 +0200 Subject: [PATCH] feat(akquise): gefundene Projekte als Eingang vor den Opportunities Neuer Menuepunkt ueber den Opportunities, mit dem Unterpunkt "Archiv". Die Liste traegt genau die Spalten, die beim Durchgehen zaehlen: Datum, Von, Anbieter, Kanal, Projekttitel, Berater, ein Knopf fuer die Uebernahme und die Symbole fuer Bearbeiten und Loeschen. Eingabe steht ueber der Liste und ist bewusst kein Dialog - Projekte kommen in Serie herein. Nach dem Eintragen bleiben Datum, Anbieter, Kanal und Berater stehen, damit drei Projekte aus einer Mail nicht dreimal dieselben Angaben kosten. Von, Anbieter und Berater schlagen frueher benutzte Werte vor. Was in der Liste keine Spalte hat, aber in der Opportunity vorkommt (Endkunde, Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum, naechster Schritt, Notizen), klappt darunter auf. Eingang, Archiv und Loeschung ergeben sich aus dem Funddatum - 14 bzw. 90 Tage - und aus converted_at. Es gibt keine Zustandsspalte, die ein Job pflegen muesste und die von der Wirklichkeit abweichen kann. Der Loeschlauf raeumt beim Abruf der Liste, hoechstens einmal pro Stunde; die Abfragen filtern Faelliges ohnehin heraus. Die Uebernahme legt Opportunity und Merker in einer Transaktion an. Anbieter, Von, Kanal, Berater und Link haben in der Opportunity kein Feld und wandern als Herkunftsvermerk in die Notizen. Der Titel wird nur dann zum Link, wenn eine http(s)-Adresse hinterlegt ist: "hays.de/projekt/1" ergaenzt das Backend zu https://, und die Anzeige laesst nichts anderes durch als http und https. Co-Authored-By: Claude Opus 5 (1M context) --- docs/01-architektur.md | 4 +- docs/02-datenmodell.md | 22 +- docs/05-frontend.md | 4 + docs/07-anleitung-benutzer.md | 79 ++- docs/09-quellcode.md | 8 +- docs/10-glossar.md | 1 + server/src/routes/acquisitions.js | 508 ++++++++++++++++++ server/src/schema.sql | 46 ++ server/src/server.js | 2 + src/App.tsx | 7 + src/components/Sidebar.tsx | 22 +- .../acquisition/AcquisitionForm.tsx | 508 ++++++++++++++++++ .../acquisition/AcquisitionTable.tsx | 245 +++++++++ src/components/acquisition/Acquisitions.tsx | 259 +++++++++ src/lib/api.ts | 48 ++ src/types.ts | 77 +++ 16 files changed, 1833 insertions(+), 7 deletions(-) create mode 100644 server/src/routes/acquisitions.js create mode 100644 src/components/acquisition/AcquisitionForm.tsx create mode 100644 src/components/acquisition/AcquisitionTable.tsx create mode 100644 src/components/acquisition/Acquisitions.tsx diff --git a/docs/01-architektur.md b/docs/01-architektur.md index efa1acf..1cb956e 100644 --- a/docs/01-architektur.md +++ b/docs/01-architektur.md @@ -90,8 +90,8 @@ sofort draußen, ohne auf den Ablauf des Tokens zu warten. ```mermaid flowchart TD L["Anmeldung"] --> R{"Rolle"} - R -->|admin| A["Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"] - R -->|user| U["Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Mein Konto, Whisper"] + R -->|admin| A["Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Nutzerverwaltung, Mein Konto, Postfach, Scanner, Whisper"] + R -->|user| U["Akquise, Opportunities, Interviews, Kontakte, Mails, Scanner
Einstellungen: Mein Konto, Whisper"] ``` Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 89e6dd6..5810ecd 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -23,6 +23,8 @@ erDiagram crm_leads ||--o{ crm_lead_connections : from_lead_id crm_leads ||--o{ crm_opportunity_contacts : ist crm_opportunities ||--o{ crm_opportunity_contacts : hat + crm_users ||--o{ crm_acquisitions : created_by + crm_acquisitions |o--o| crm_opportunities : "wird zu" crm_users { int id PK @@ -74,6 +76,18 @@ erDiagram int lead_id PK,FK varchar role "z.B. Entscheider" } + crm_acquisitions { + int id PK + date found_at "entscheidet Eingang, Archiv, Loeschung" + varchar sender "Von" + varchar provider "Anbieter" + varchar channel "Mail | Telefon | Portal | ..." + varchar title "Projekttitel" + varchar consultant "Berater" + varchar link "macht den Titel klickbar" + datetime converted_at "uebernommen -> raus aus der Liste" + int opportunity_id FK + } crm_events { int id PK varchar title @@ -89,7 +103,7 @@ erDiagram } ``` -Vier Eigenheiten, die man kennen muss: +Fünf Eigenheiten, die man kennen muss: - **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des @@ -106,6 +120,12 @@ Vier Eigenheiten, die man kennen muss: - **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden wenige Kilobyte. +- **`crm_acquisitions` hat keine Zustandsspalte.** Eingang, Archiv und Löschung + ergeben sich aus `found_at` (14 bzw. 90 Tage) und `converted_at`. Eine Spalte, + die ein Job umsetzen müsste, kann von der Wirklichkeit abweichen - eine + gerechnete Grenze nicht. `converted_at` ist dabei die Wahrheit, nicht + `opportunity_id`: wird die Opportunity gelöscht, fällt der Verweis auf NULL, + der Eintrag soll aber nicht wieder im Eingang auftauchen. ### Postfach crm@perinco.de diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 1f2eafd..1db5720 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -9,6 +9,9 @@ sitzt und keine tiefen Links braucht. ```mermaid flowchart TD SB["Seitenleiste"] --> K["Kategorien"] + K --> AQ["Akquise"] + AQ --> AQ1["Eingang
Funde der letzten 14 Tage"] + AQ --> AQ2["Archiv
älter; nach 90 Tagen gelöscht"] K --> O["Opportunities
(Startansicht)"] K --> C["Interviews"] K --> L["Kontakte"] @@ -61,6 +64,7 @@ flowchart TB APP --> NAV APP --> DATA DATA --> V1["Opportunities"] + NOTE["Akquise lädt selbst
acquisitionsApi, nicht über loadData()"] DATA --> V2["Interviews"] DATA --> V3["Kontakte
mail_pool = false"] DATA --> V5["Aus Mails
mail_pool = true + Kontakte zum Abgleich"] diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index a4504e6..0cb0a5e 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -9,6 +9,7 @@ Ein CRM für die Projektvermittlung. Es beantwortet vier Fragen: | Frage | Bereich | | --- | --- | +| Welche Projekte haben wir gefunden? | **Akquise** | | Welche Geschäfte sind in Arbeit und was sind sie wert? | **Opportunities** | | Wer ist wann wo, und wer ist beteiligt? | **Interviews** | | Wen kennen wir, und wer kennt wen? | **Kontakte** | @@ -31,6 +32,9 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. ``` ┌──────────────┬───────────────────────────────────────────┐ │ Kategorien │ Titel der Ansicht [Hell/Dunkel] │ +│ Akquise ▾ │ │ +│ Eingang │ │ +│ Archiv │ │ │ Opportunities├──────────────────────────────────────────┤ │ Interviews │ │ │ Kontakte ▾ │ Inhalt │ @@ -69,9 +73,82 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. > Alle Abbildungen in dieser Anleitung zeigen die Anwendung mit **erfundenen > Beispieldaten**. Firmen, Personen und Zahlen darin gibt es nicht. +## Akquise + +Der Eingang für gefundene Projekte - alles, was per Mail, Telefon oder über ein +Portal hereinkommt, bevor daraus ein Geschäft wird. Zwei Unterpunkte: **Eingang** +mit den Funden der letzten 14 Tage und **Archiv** mit allem Älteren. + +### Eintragen + +Über der Liste steht das Formular. Absichtlich kein Fenster, das nach jedem +Eintrag zugeht: kommen drei Projekte in einer Mail, tippt man sie hintereinander +weg. + +| Feld | wofür | +| --- | --- | +| **Datum** | wann gefunden. Vorbelegt mit heute; entscheidet später über Archiv und Löschung | +| **Von** | wer es geschickt hat | +| **Anbieter** | Vermittler oder Portal, z. B. Hays | +| **Kanal** | Mail, Telefon, Portal, LinkedIn, XING, Empfehlung, Sonstiges | +| **Projekttitel** | das einzige Pflichtfeld | +| **Berater** | für wen das Projekt passt | +| **Link** | Adresse der Ausschreibung. Ist einer hinterlegt, wird der Titel in der Liste anklickbar und öffnet ihn in einem neuen Tab | + +*Von*, *Anbieter* und *Berater* schlagen vor, was schon einmal eingetragen wurde - +antippen statt tippen. Beim Link genügt `hays.de/projekt/1`, das `https://` wird +ergänzt. + +Unter **Projektdaten für die Opportunity** klappt auf, was in der Liste keine +Spalte hat, später aber in der Opportunity stehen soll: Endkunde, Projektstart +und -ende, Tagessatz, Laufzeit, Projektpreis, Projektbeschreibung, nächster +Schritt und interne Notizen. Alles freiwillig und jederzeit nachtragbar; das +Volumen wird schon hier mitgerechnet. + +Nach *Eintragen* bleiben **Datum, Anbieter, Kanal und Berater stehen**, der Rest +wird geleert - für das nächste Projekt aus derselben Quelle. + +### Die Liste + +| Spalte | Bedeutung | +| --- | --- | +| **Datum** | Funddatum; der Mauszeiger verrät das Alter in Tagen | +| **Von**, **Anbieter**, **Kanal** | woher es kam | +| **Projekttitel** | mit Link anklickbar (neuer Tab); darunter klein Endkunde, Tagessatz bzw. Festpreis und Personentage | +| **Berater** | für wen | +| **Opportunity** | ein Klick macht daraus eine Opportunity in der Phase *Neu* | +| Stift / Papierkorb | bearbeiten (füllt das Formular oben) und löschen | + +Die Spaltenköpfe sortieren, die Suche geht über Titel, Anbieter, Berater, +Endkunde, Beschreibung und Notizen. + +### Was von selbst passiert + +* Nach **14 Tagen** rutscht ein Eintrag ins **Archiv**. Dort lässt er sich weiter + bearbeiten und jederzeit übernehmen. +* Nach **90 Tagen** wird er **gelöscht**. Wer ein Projekt behalten will, + übernimmt es als Opportunity. +* Wird ein Eintrag **übernommen**, verschwindet er aus Eingang und Archiv - er + lebt als Opportunity weiter. + +Verschoben wird dabei nichts: Eingang und Archiv sind dieselbe Liste, nur nach +dem Funddatum geteilt. Es gibt also keinen Zustand, der klemmen kann. + +### Aus dem Eintrag wird eine Opportunity + +Der Knopf **Opportunity** legt sie sofort an: Phase *Neu*, 10 % Wahrscheinlichkeit, +zuständig ist der angemeldete Benutzer. Mit übernommen werden Titel, Endkunde, +Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt. +Anbieter, Von, Kanal, Berater und Link haben in der Opportunity kein Feld - sie +landen als Herkunftsvermerk in den internen Notizen, damit nachvollziehbar +bleibt, woher das Projekt kam. + +Ansprechpartner hängt man danach in der Opportunity dran; dort geht das auch für +Kontakte, die es noch nicht gibt. + ## Opportunities -Der Startbereich: eine Opportunity ist ein Geschäft in Arbeit. Oben rechts +Eine Opportunity ist ein Geschäft in Arbeit. Oben rechts wechselt man die Darstellung: | Ansicht | wofür | diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index a8359a6..44f7817 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -83,6 +83,7 @@ Alle unter `router.use(requireAuth)`. | `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete | | `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin | | `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | ändern/löschen nur Ersteller, Zuständiger oder Admin | +| `acquisitions.js` | `GET /?view=eingang\|archiv\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id`, `POST /:id/opportunity` | ändern/löschen nur Ersteller oder Admin | | `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin | Wiederkehrende Muster in diesen Routern: @@ -255,6 +256,9 @@ Warum genau diese Konstanten festgelegt sind, steht in | Datei | Inhalt | | --- | --- | +| `components/acquisition/Acquisitions.tsx` | Akquise und Archiv (`view`); Suche, Sortierung, Übernahme, Löschen | +| `components/acquisition/AcquisitionForm.tsx` | Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag | +| `components/acquisition/AcquisitionTable.tsx` | die Liste; `safeLink()` lässt nur http(s) zum Link werden | | `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `KindBadge`, `Avatar`, Farben der Kontaktart | | `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs | | `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme | @@ -275,11 +279,11 @@ nicht auseinanderlaufen: | Export | Zweck | | --- | --- | -| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS` | Ansichten und Reiter | +| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS`, `MAIL_VIEWS`, `ACQUISITION_VIEWS`, `CONTACT_VIEWS` | Ansichten, Reiter und aufklappbare Gruppen | | `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter | | `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id | | `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert | -| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank | +| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_KINDS`, `ACQUISITION_CHANNELS`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank | | `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik | ## Datenfluss im Frontend diff --git a/docs/10-glossar.md b/docs/10-glossar.md index 69a8a64..b62ee53 100644 --- a/docs/10-glossar.md +++ b/docs/10-glossar.md @@ -21,6 +21,7 @@ Quelltext. | **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird übernommen, ergänzt oder verworfen. | | **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. | | **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz und Laufzeit oder Projektpreis, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. | +| **Akquise** | Eingang für gefundene Projekte, bevor daraus eine Opportunity wird. Tabelle `crm_acquisitions`. Nach 14 Tagen im **Archiv**, nach 90 Tagen gelöscht. | | **Art** (eines Kontakts) | Recruiter, Partner oder Endkunde. Spalte `crm_leads.kind`; hieß bis 18.08.2026 „Status" mit den Werten Neu .. Verloren. | | **Papierkorb** | Im CRM gelöschte Mails. Umkehrbar, bis er geleert wird. Im Postfach crm@perinco.de bleiben die Mails in jedem Fall; der Abgleich holt gelöschte nicht wieder herein. | | **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. | diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js new file mode 100644 index 0000000..b91e423 --- /dev/null +++ b/server/src/routes/acquisitions.js @@ -0,0 +1,508 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(requireAuth); + +/* + * Akquise: gefundene Projekte, aus denen eine Opportunity werden kann. + * + * Drei Zustaende, alle aus `found_at` und `converted_at` abgeleitet - es gibt + * keine Spalte, die ein Job pflegen muesste: + * + * Eingang ("Akquise") found_at >= heute - 14 Tage, nicht uebernommen + * Archiv aelter als 14 Tage, nicht uebernommen + * weg aelter als 90 Tage (wird geloescht) oder uebernommen + * + * Uebernommene Eintraege bleiben in der Tabelle stehen: sie sind der Nachweis, + * woher eine Opportunity kommt. Nur der Eingang und das Archiv zeigen sie nicht. + */ + +/** Grenze zwischen Eingang und Archiv, in Tagen. */ +const ARCHIVE_AFTER_DAYS = 14; +/** Ab hier loescht das Archiv selbst. */ +const DELETE_AFTER_DAYS = 90; + +const CHANNELS = new Set([ + 'Mail', + 'Telefon', + 'Portal', + 'LinkedIn', + 'XING', + 'Empfehlung', + 'Sonstiges', +]); + +// `link` fehlt hier absichtlich: der Wert wird eigens geprueft (normalizeLink). +const TEXT_FIELDS = [ + 'sender', + 'provider', + 'title', + 'consultant', + 'company', + 'description', + 'next_step', + 'notes', +]; +const DATE_FIELDS = ['found_at', 'start_date', 'end_date']; +const NUMBER_FIELDS = [ + ['daily_rate', 'Der Tagessatz', 1_000_000], + ['duration_days', 'Die Laufzeit', 100_000], + ['fixed_price', 'Der Projektpreis', 1_000_000_000], +]; + +/** Laengengrenzen der Spalten - abgewiesen statt stillschweigend abgeschnitten. */ +const MAX_LENGTH = { + sender: 190, + provider: 190, + title: 190, + consultant: 190, + link: 1024, + company: 190, + next_step: 500, +}; + +function emptyToNull(value) { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text === '' ? null : text; +} + +/** Akzeptiert `YYYY-MM-DD` (auch mit angehaengter Uhrzeit) oder null. */ +function normalizeDate(value) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value).trim()); + if (!match) return { error: 'Ungültiges Datum (erwartet JJJJ-MM-TT).' }; + + const [, year, month, day] = match; + const probe = new Date(Number(year), Number(month) - 1, Number(day)); + if ( + probe.getFullYear() !== Number(year) || + probe.getMonth() !== Number(month) - 1 || + probe.getDate() !== Number(day) + ) { + return { error: 'Dieses Datum gibt es nicht.' }; + } + return { value: `${year}-${month}-${day}` }; +} + +function normalizeNumber(value, label, max) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const number = Number(String(value).replace(',', '.')); + if (!Number.isFinite(number) || number < 0) return { error: `${label} muss eine Zahl ab 0 sein.` }; + if (number > max) return { error: `${label} ist unrealistisch hoch.` }; + return { value: number }; +} + +/** + * Nur http und https. Ohne diese Pruefung liesse sich ein `javascript:`-Verweis + * hinterlegen, den der Titel in der Liste anklickbar macht. + */ +function normalizeLink(value) { + const text = emptyToNull(value); + if (text === null) return { value: null }; + + // Ohne Schema ist "hays.de/projekt/123" gemeint - das ergaenzen wir. + const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text) ? text : `https://${text}`; + let url; + try { + url = new URL(candidate); + } catch { + return { error: 'Der Link ist keine gültige Adresse.' }; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return { error: 'Der Link muss mit http:// oder https:// beginnen.' }; + } + if (candidate.length > MAX_LENGTH.link) return { error: 'Der Link ist zu lang (max. 1024 Zeichen).' }; + return { value: candidate }; +} + +function parseTags(raw) { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === 'string') : []; + } catch { + return []; + } +} + +function serializeTags(value) { + if (!Array.isArray(value)) return null; + return JSON.stringify([...new Set(value.map((tag) => String(tag).trim()).filter(Boolean))]); +} + +/** DECIMAL kommt als String aus mysql2 - im Frontend sollen es Zahlen sein. */ +function mapAcquisition(row) { + return { + ...row, + daily_rate: row.daily_rate === null ? null : Number(row.daily_rate), + duration_days: row.duration_days === null ? null : Number(row.duration_days), + fixed_price: row.fixed_price === null ? null : Number(row.fixed_price), + tags: parseTags(row.tags), + }; +} + +const SELECT_ACQUISITIONS = ` + SELECT a.*, + creator.name AS created_by_name, + opp.title AS opportunity_title, + opp.stage AS opportunity_stage + FROM crm_acquisitions a + LEFT JOIN crm_users creator ON creator.id = a.created_by + LEFT JOIN crm_opportunities opp ON opp.id = a.opportunity_id +`; + +async function fetchAcquisition(id) { + const [rows] = await pool.query(`${SELECT_ACQUISITIONS} WHERE a.id = ? LIMIT 1`, [id]); + return rows[0] ? mapAcquisition(rows[0]) : null; +} + +/** + * Alles aelter als 90 Tage entfernen. Uebernommene Eintraege bleiben - sie + * gehoeren zur Opportunity, nicht zum Archiv. + * + * Das laeuft beim Abruf der Liste, hoechstens aber einmal pro Stunde je Prozess. + * Ein eigener Zeitplan waere eine weitere Stelle, die ausfallen kann; und die + * Abfragen filtern die Altlasten ohnehin heraus, der Loeschlauf raeumt nur + * hinterher. + */ +let lastPurgeAt = 0; +async function purgeOldEntries() { + if (Date.now() - lastPurgeAt < 3_600_000) return; + lastPurgeAt = Date.now(); + + const [result] = await pool.query( + `DELETE FROM crm_acquisitions + WHERE converted_at IS NULL + AND found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)`, + [DELETE_AFTER_DAYS] + ); + if (result.affectedRows > 0) { + console.log( + `[Akquise] ${result.affectedRows} Eintrag/Eintraege aelter als ${DELETE_AFTER_DAYS} Tage geloescht.` + ); + } +} + +/** Aendern und loeschen darf der Ersteller und jeder Admin. */ +function mayModify(user, row) { + return user.role === 'admin' || row.created_by === user.id; +} + +/** + * Felder aus dem Anfragekoerper pruefen. `partial` laesst fehlende Felder weg + * (PATCH), sonst werden alle gesetzt (POST). + */ +function readFields(body, { partial }) { + const columns = {}; + + for (const field of TEXT_FIELDS) { + if (partial && body?.[field] === undefined) continue; + const value = emptyToNull(body?.[field]); + if (MAX_LENGTH[field] && value !== null && value.length > MAX_LENGTH[field]) { + return { error: `Der Wert für "${field}" ist zu lang (max. ${MAX_LENGTH[field]} Zeichen).` }; + } + columns[field] = value; + } + + if (!partial || body?.link !== undefined) { + const link = normalizeLink(body?.link); + if (link.error) return { error: link.error }; + columns.link = link.value; + } + + if (!partial || body?.channel !== undefined) { + const channel = emptyToNull(body?.channel); + if (channel !== null && !CHANNELS.has(channel)) { + return { error: 'Unbekannter Kanal.' }; + } + columns.channel = channel; + } + + for (const field of DATE_FIELDS) { + if (partial && body?.[field] === undefined) continue; + const result = normalizeDate(body?.[field]); + if (result.error) return { error: result.error }; + columns[field] = result.value; + } + + for (const [field, label, max] of NUMBER_FIELDS) { + if (partial && body?.[field] === undefined) continue; + const result = normalizeNumber(body?.[field], label, max); + if (result.error) return { error: result.error }; + columns[field] = result.value; + } + + if (!partial || body?.tags !== undefined) { + columns.tags = serializeTags(body?.tags) ?? '[]'; + } + + if (columns.title !== undefined && !columns.title) { + return { error: 'Ein Projekttitel ist erforderlich.' }; + } + if (columns.start_date && columns.end_date && columns.end_date < columns.start_date) { + return { error: 'Das Projektende darf nicht vor dem Start liegen.' }; + } + + return { columns }; +} + +// GET /api/acquisitions?view=eingang|archiv|alle +router.get('/', async (req, res, next) => { + try { + await purgeOldEntries(); + + const view = String(req.query.view ?? 'eingang'); + const where = ['a.converted_at IS NULL']; + const params = []; + + if (view === 'eingang') { + where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'); + params.push(ARCHIVE_AFTER_DAYS); + } else if (view === 'archiv') { + // Die untere Grenze steht bewusst auch hier: der Loeschlauf raeumt nur + // stuendlich, angezeigt werden soll nichts, was schon faellig ist. + where.push('a.found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY)'); + where.push('a.found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)'); + params.push(ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS); + } else if (view !== 'alle') { + return res.status(400).json({ error: 'Unbekannte Ansicht.' }); + } + + const [rows] = await pool.query( + `${SELECT_ACQUISITIONS} WHERE ${where.join(' AND ')} + ORDER BY a.found_at DESC, a.id DESC`, + params + ); + + // Beide Zaehler in einem Rutsch: die Oberflaeche zeigt sie am Menue und + // muesste sonst zwei Listen laden, um eine Zahl zu kennen. + const [[counts]] = await pool.query( + `SELECT + SUM(found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS eingang, + SUM(found_at < DATE_SUB(CURDATE(), INTERVAL ? DAY) + AND found_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)) AS archiv + FROM crm_acquisitions WHERE converted_at IS NULL`, + [ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS] + ); + + /* + * Vorschlaege fuer die Eingabe, aus dem gesamten Bestand - nicht nur aus der + * gerade gezeigten Liste. Wer denselben Anbieter zum zwanzigsten Mal + * eintraegt, soll ihn nicht zum zwanzigsten Mal ausschreiben muessen. + */ + const [suggestionRows] = await pool.query( + // Klammern sind Pflicht: ORDER BY und LIMIT in einem UNION-Zweig gehoeren + // sonst zur Gesamtabfrage. + `(SELECT 'provider' AS field, provider AS value FROM crm_acquisitions + WHERE provider IS NOT NULL GROUP BY provider ORDER BY MAX(found_at) DESC LIMIT 60) + UNION ALL + (SELECT 'consultant', consultant FROM crm_acquisitions + WHERE consultant IS NOT NULL GROUP BY consultant ORDER BY MAX(found_at) DESC LIMIT 60) + UNION ALL + (SELECT 'sender', sender FROM crm_acquisitions + WHERE sender IS NOT NULL GROUP BY sender ORDER BY MAX(found_at) DESC LIMIT 60)` + ); + const suggestions = { providers: [], consultants: [], senders: [] }; + const bucket = { provider: 'providers', consultant: 'consultants', sender: 'senders' }; + for (const row of suggestionRows) suggestions[bucket[row.field]].push(row.value); + + res.json({ + archive_after_days: ARCHIVE_AFTER_DAYS, + delete_after_days: DELETE_AFTER_DAYS, + suggestions, + counts: { + eingang: Number(counts.eingang ?? 0), + archiv: Number(counts.archiv ?? 0), + }, + entries: rows.map(mapAcquisition), + }); + } catch (err) { + next(err); + } +}); + +// POST /api/acquisitions +router.post('/', async (req, res, next) => { + const parsed = readFields(req.body, { partial: false }); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + const columns = parsed.columns; + // Ohne Angabe gilt heute: gefunden wurde es dann, wenn es eingetragen wird. + if (columns.found_at === null) columns.found_at = new Date().toISOString().slice(0, 10); + columns.created_by = req.user.id; + + try { + const keys = Object.keys(columns); + const [result] = await pool.query( + `INSERT INTO crm_acquisitions (${keys.join(', ')}) + VALUES (${keys.map(() => '?').join(', ')})`, + keys.map((key) => columns[key]) + ); + res.status(201).json(await fetchAcquisition(result.insertId)); + } catch (err) { + next(err); + } +}); + +// PATCH /api/acquisitions/:id +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 Akquise-ID.' }); + + try { + const [rows] = await pool.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]); + const existing = rows[0]; + if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); + if (!mayModify(req.user, existing)) { + return res.status(403).json({ + error: 'Diesen Eintrag darf nur ändern, wer ihn angelegt hat, oder ein Administrator.', + }); + } + if (existing.converted_at !== null) { + return res + .status(409) + .json({ error: 'Der Eintrag wurde schon in eine Opportunity übernommen.' }); + } + + const parsed = readFields(req.body, { partial: true }); + if (parsed.error) return res.status(400).json({ error: parsed.error }); + + const columns = parsed.columns; + if ('title' in columns && !columns.title) { + return res.status(400).json({ error: 'Der Projekttitel darf nicht leer sein.' }); + } + + // Datumsgrenzen gegen den gespeicherten Stand pruefen, nicht nur gegen das, + // was gerade mitgeschickt wurde. + const start = 'start_date' in columns ? columns.start_date : existing.start_date; + const end = 'end_date' in columns ? columns.end_date : existing.end_date; + if (start && end && end < start) { + return res.status(400).json({ error: 'Das Projektende darf nicht vor dem Start liegen.' }); + } + + const keys = Object.keys(columns); + if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); + + await pool.query( + `UPDATE crm_acquisitions SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`, + [...keys.map((key) => columns[key]), id] + ); + res.json(await fetchAcquisition(id)); + } catch (err) { + next(err); + } +}); + +// DELETE /api/acquisitions/:id +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 Akquise-ID.' }); + + try { + const [rows] = await pool.query( + 'SELECT created_by FROM crm_acquisitions WHERE id = ? LIMIT 1', + [id] + ); + const existing = rows[0]; + if (!existing) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); + if (!mayModify(req.user, existing)) { + return res.status(403).json({ + error: 'Diesen Eintrag darf nur löschen, wer ihn angelegt hat, oder ein Administrator.', + }); + } + + await pool.query('DELETE FROM crm_acquisitions WHERE id = ?', [id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +/* + * POST /api/acquisitions/:id/opportunity + * + * Aus dem Eintrag wird eine Opportunity in der Phase "Neu". Beides in einer + * Transaktion: entweder die Opportunity steht und der Eintrag ist als + * uebernommen vermerkt, oder es bleibt alles wie es war. Sonst koennte eine + * Opportunity entstehen, die der Eingang weiterhin anbietet. + */ +router.post('/:id/opportunity', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Akquise-ID.' }); + + const conn = await pool.getConnection(); + try { + const [rows] = await conn.query('SELECT * FROM crm_acquisitions WHERE id = ? LIMIT 1', [id]); + const entry = rows[0]; + if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden.' }); + if (entry.converted_at !== null) { + return res.status(409).json({ + error: 'Aus diesem Eintrag wurde schon eine Opportunity.', + opportunity_id: entry.opportunity_id, + }); + } + + /* + * Was in der Liste steht, aber in der Opportunity kein Feld hat (Anbieter, + * Kanal, Absender, Link, Berater), wandert in die Notizen. Sonst wuesste + * hinterher niemand mehr, woher das Projekt kam. + */ + const origin = [ + ['Anbieter', entry.provider], + ['Von', entry.sender], + ['Kanal', entry.channel], + ['Berater', entry.consultant], + ['Link', entry.link], + ] + .filter(([, value]) => value) + .map(([label, value]) => `${label}: ${value}`); + + const notes = [ + entry.notes, + origin.length > 0 ? `Aus der Akquise vom ${entry.found_at}\n${origin.join('\n')}` : null, + ] + .filter(Boolean) + .join('\n\n'); + + await conn.beginTransaction(); + const [created] = await conn.query( + `INSERT INTO crm_opportunities + (title, company, description, stage, probability, daily_rate, duration_days, + fixed_price, start_date, end_date, next_step, tags, notes, owner_id, created_by) + VALUES (?, ?, ?, 'Neu', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + entry.title, + entry.company, + entry.description, + entry.daily_rate, + entry.duration_days, + entry.fixed_price, + entry.start_date, + entry.end_date, + entry.next_step, + entry.tags ?? '[]', + notes || null, + req.user.id, + req.user.id, + ] + ); + + await conn.query( + 'UPDATE crm_acquisitions SET opportunity_id = ?, converted_at = UTC_TIMESTAMP() WHERE id = ?', + [created.insertId, id] + ); + await conn.commit(); + + res.status(201).json({ ok: true, opportunity_id: created.insertId }); + } catch (err) { + await conn.rollback().catch(() => {}); + next(err); + } finally { + conn.release(); + } +}); + +export default router; diff --git a/server/src/schema.sql b/server/src/schema.sql index a7137dd..e8c3491 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -351,3 +351,49 @@ CREATE TABLE IF NOT EXISTS crm_mail_sync_state ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1); + +-- =========================================================================== +-- Akquise: gefundene Projekte, bevor daraus eine Opportunity wird +-- +-- Der Eingang ist eine Arbeitsliste, kein Archiv: was aelter als 14 Tage ist, +-- erscheint in der Unteransicht "Archiv", was aelter als 90 Tage ist, wird +-- geloescht. Beides ergibt sich aus `found_at` - es gibt bewusst keine +-- Zustandsspalte, die von einem Job gepflegt werden muesste und auseinander +-- laufen kann. +-- +-- `converted_at` ist der Merker fuer "in eine Opportunity uebernommen". Er ist +-- die Wahrheit, nicht `opportunity_id`: wird die Opportunity spaeter geloescht, +-- faellt der Verweis auf NULL, der Eintrag soll aber nicht wieder im Eingang +-- auftauchen. +-- =========================================================================== +CREATE TABLE IF NOT EXISTS crm_acquisitions ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + found_at DATE NOT NULL, -- Spalte "Datum": wann gefunden + sender VARCHAR(190) NULL, -- "Von": wer es geschickt hat + provider VARCHAR(190) NULL, -- "Anbieter": Vermittler, Portal + channel VARCHAR(60) NULL, -- "Kanal": Mail, Telefon, Portal, ... + title VARCHAR(190) NOT NULL, -- Projekttitel + consultant VARCHAR(190) NULL, -- "Berater", fuer den es passt + link VARCHAR(1024) NULL, -- Ausschreibung; macht den Titel klickbar + -- Ab hier: steht nicht in der Liste, wandert aber in die Opportunity. + company VARCHAR(190) NULL, -- Endkunde + description TEXT NULL, + daily_rate DECIMAL(10,2) NULL, + duration_days DECIMAL(6,1) NULL, + fixed_price DECIMAL(12,2) NULL, + start_date DATE NULL, + end_date DATE NULL, + next_step VARCHAR(500) NULL, + notes TEXT NULL, + tags LONGTEXT NULL, -- JSON-Array als Text, wie bei crm_leads + opportunity_id INT UNSIGNED NULL, + converted_at DATETIME 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, + PRIMARY KEY (id), + KEY idx_crm_acq_found (found_at), + KEY idx_crm_acq_converted (converted_at), + CONSTRAINT fk_crm_acq_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE SET NULL, + CONSTRAINT fk_crm_acq_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/src/server.js b/server/src/server.js index 05d70f7..58e4dfb 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -8,6 +8,7 @@ import leadRoutes from './routes/leads.js'; import connectionRoutes from './routes/connections.js'; import eventRoutes from './routes/events.js'; import opportunityRoutes from './routes/opportunities.js'; +import acquisitionRoutes from './routes/acquisitions.js'; import scannerRoutes from './routes/scanner.js'; import mailRoutes from './routes/mail.js'; import { requireAuth } from './middleware/auth.js'; @@ -47,6 +48,7 @@ app.use('/api/leads', leadRoutes); app.use('/api/connections', connectionRoutes); app.use('/api/events', eventRoutes); app.use('/api/opportunities', opportunityRoutes); +app.use('/api/acquisitions', acquisitionRoutes); // Projekt Scanner. Komplett hinter der CRM-Anmeldung - der urspruengliche // HTTP-Basic-Auth-Zugang des Scanners entfaellt damit. diff --git a/src/App.tsx b/src/App.tsx index a4cf8a8..830b477 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { Header } from './components/Header'; import { LoginScreen } from './components/LoginScreen'; import { Leads } from './components/leads/Leads'; import { MailPoolLeads } from './components/leads/MailPoolLeads'; +import { Acquisitions } from './components/acquisition/Acquisitions'; import { Opportunities } from './components/opportunities/Opportunities'; import { Calendar } from './components/calendar/Calendar'; import { parseSqlDateTime } from './components/calendar/calendarUtils'; @@ -138,6 +139,8 @@ const App: React.FC = () => { // Untertitel der Kopfzeile. Die Scanner-Reiter bringen ihre Kennzahlen selbst // mit, deshalb steht hier nur, worum es auf der Seite geht. const STATIC_SUBTITLES: Partial> = { + Akquise: 'Gefundene Projekte - eintragen, prüfen, zur Opportunity machen', + Archiv: 'Akquise-Einträge älter als 14 Tage - nach 90 Tagen werden sie gelöscht', Mails: 'Postfach crm@perinco.de - Mails zuordnen und Kontakte übernehmen', Papierkorb: 'Im CRM gelöschte Mails - im Postfach crm@perinco.de bleiben sie erhalten', Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen', @@ -185,6 +188,10 @@ const App: React.FC = () => { />
+ {currentView === 'Akquise' && loadData(true)} />} + {currentView === 'Archiv' && ( + loadData(true)} /> + )} {currentView === 'Opportunities' && ( void; + onCancelEdit: () => void; +} + +interface FormState { + found_at: string; + sender: string; + provider: string; + channel: string; + title: string; + consultant: string; + link: string; + company: string; + description: string; + daily_rate: string; + duration_days: string; + fixed_price: string; + start_date: string; + end_date: string; + next_step: string; + notes: string; +} + +const today = () => new Date().toISOString().slice(0, 10); + +/** Zahlen kommen als String ins Formular, damit leere Felder leer bleiben. */ +const numberToInput = (value: number | null) => + value === null ? '' : String(value).replace('.', ','); + +function toFormState(entry: Acquisition | null): FormState { + return { + found_at: entry?.found_at?.slice(0, 10) ?? today(), + sender: entry?.sender ?? '', + provider: entry?.provider ?? '', + channel: entry?.channel ?? '', + title: entry?.title ?? '', + consultant: entry?.consultant ?? '', + link: entry?.link ?? '', + company: entry?.company ?? '', + description: entry?.description ?? '', + daily_rate: numberToInput(entry?.daily_rate ?? null), + duration_days: numberToInput(entry?.duration_days ?? null), + fixed_price: numberToInput(entry?.fixed_price ?? null), + start_date: entry?.start_date?.slice(0, 10) ?? '', + end_date: entry?.end_date?.slice(0, 10) ?? '', + next_step: entry?.next_step ?? '', + notes: entry?.notes ?? '', + }; +} + +/** Freitextfeld mit Vorschlagsliste aus den bisherigen Eintraegen. */ +const SuggestInput: React.FC<{ + id: string; + label: string; + value: string; + options: string[]; + placeholder?: string; + onChange: (value: string) => void; +}> = ({ id, label, value, options, placeholder, onChange }) => ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + className={inputClass} + /> + + {options.map((option) => ( + +
+); + +export const AcquisitionForm: React.FC = ({ + editing, + suggestions, + onSaved, + onCancelEdit, +}) => { + const { addToast } = useToast(); + const [form, setForm] = useState(() => toFormState(editing)); + const [showMore, setShowMore] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const titleRef = useRef(null); + const rootRef = useRef(null); + + const set = (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })); + + /* + * Beim Wechsel in den Bearbeitungsmodus das Formular fuellen, aufklappen, wenn + * dort Angaben stehen, und hinscrollen - der Stift steht weit unten in der + * Liste, das Formular oben. + */ + useEffect(() => { + setForm(toFormState(editing)); + if (editing) { + const hasExtras = Boolean( + editing.company || + editing.description || + editing.daily_rate !== null || + editing.duration_days !== null || + editing.fixed_price !== null || + editing.start_date || + editing.end_date || + editing.next_step || + editing.notes + ); + setShowMore(hasExtras); + rootRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + titleRef.current?.focus(); + } + }, [editing]); + + // Volumen mitrechnen wie in der Opportunity: Festpreis schlaegt Tagessatz. + const volume = useMemo(() => { + const toNumber = (value: string) => Number(value.replace(',', '.')); + if (form.fixed_price.trim() !== '') { + const fixed = toNumber(form.fixed_price); + return Number.isFinite(fixed) ? fixed : null; + } + if (form.daily_rate.trim() === '' || form.duration_days.trim() === '') return null; + const rate = toNumber(form.daily_rate); + const days = toNumber(form.duration_days); + return Number.isFinite(rate) && Number.isFinite(days) ? rate * days : null; + }, [form.daily_rate, form.duration_days, form.fixed_price]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!form.title.trim()) { + addToast('Ein Projekttitel ist erforderlich.', 'error'); + return; + } + if (form.start_date && form.end_date && form.end_date < form.start_date) { + addToast('Das Projektende darf nicht vor dem Start liegen.', 'error'); + return; + } + + const payload: AcquisitionInput = { + found_at: form.found_at || today(), + sender: form.sender.trim() || null, + provider: form.provider.trim() || null, + channel: (form.channel as AcquisitionChannel) || null, + title: form.title.trim(), + consultant: form.consultant.trim() || null, + link: form.link.trim() || null, + company: form.company.trim() || null, + description: form.description.trim() || null, + daily_rate: form.daily_rate.trim() === '' ? null : form.daily_rate.replace(',', '.'), + duration_days: form.duration_days.trim() === '' ? null : form.duration_days.replace(',', '.'), + fixed_price: form.fixed_price.trim() === '' ? null : form.fixed_price.replace(',', '.'), + start_date: form.start_date || null, + end_date: form.end_date || null, + next_step: form.next_step.trim() || null, + notes: form.notes.trim() || null, + }; + + setIsSaving(true); + try { + if (editing) { + await acquisitionsApi.update(editing.id, payload); + addToast('Eintrag aktualisiert.', 'success'); + onCancelEdit(); + } else { + await acquisitionsApi.create(payload); + addToast('Projekt eingetragen.', 'success'); + /* + * Datum, Anbieter und Kanal bleiben stehen: wer eine Mail mit drei + * Projekten abarbeitet, aendert nur Titel und Berater. Zurueckgesetzt + * wird, was je Projekt anders ist. + */ + setForm((prev) => ({ + ...prev, + title: '', + link: '', + company: '', + description: '', + daily_rate: '', + duration_days: '', + fixed_price: '', + start_date: '', + end_date: '', + next_step: '', + notes: '', + })); + titleRef.current?.focus(); + } + onSaved(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+

+ {editing ? 'Eintrag bearbeiten' : 'Projekt eintragen'} +

+

+ {editing + ? `Angelegt von ${editing.created_by_name ?? 'unbekannt'}` + : 'Datum, Anbieter und Kanal bleiben nach dem Eintragen stehen - für mehrere Projekte aus derselben Quelle.'} +

+
+ {editing && ( + + )} +
+ +
+
+
+ + set('found_at', event.target.value)} + className={inputClass} + /> +
+ set('sender', value)} + /> + set('provider', value)} + /> +
+ + +
+
+ +
+
+ + set('title', event.target.value)} + placeholder="z. B. SAP FI Rollout Süddeutschland" + className={inputClass} + /> +
+ set('consultant', value)} + /> +
+ + {/* Absichtlich kein type="url": das Backend ergaenzt ein fehlendes + https:// selbst, die Browserpruefung wuerde "hays.de/projekt/1" + aber ohne Meldung abweisen und das Formular blockieren. */} + set('link', event.target.value)} + placeholder="hays.de/projekt/1 - macht den Titel in der Liste klickbar" + className={inputClass} + /> +
+
+ + {/* Alles, was in der Liste keine Spalte hat, aber in die Opportunity wandert. */} +
+ + + {showMore && ( +
+
+
+ + set('company', event.target.value)} + placeholder="Firma hinter dem Projekt" + className={inputClass} + /> +
+
+ + set('start_date', event.target.value)} + className={inputClass} + /> +
+
+ + set('end_date', event.target.value)} + className={inputClass} + /> +
+
+ +
+
+ + set('daily_rate', event.target.value)} + placeholder="1150" + className={inputClass} + /> +
+
+ + set('duration_days', event.target.value)} + placeholder="40" + className={inputClass} + /> +
+
+ + set('fixed_price', event.target.value)} + placeholder="Festpreis statt Tagessatz × Laufzeit" + className={inputClass} + /> +
+
+ + {volume !== null && ( +

+ Volumen: {formatEuro(volume)} + {form.fixed_price.trim() !== '' ? ' (Festpreis)' : ' (Tagessatz × Laufzeit)'} +

+ )} + + set('description', value)} + rows={4} + placeholder="Aufgabe, Umfang, Rahmenbedingungen - wandert in die Opportunity." + /> + +
+ + set('next_step', event.target.value)} + placeholder="z. B. Profil bis Donnerstag schicken" + className={inputClass} + /> +
+ + set('notes', value)} + rows={3} + placeholder="Was sonst noch wichtig ist." + /> +
+ )} +
+ +
+ + {!editing && ( + + Nur der Projekttitel ist Pflicht - alles andere lässt sich später ergänzen. + + )} +
+
+
+ ); +}; diff --git a/src/components/acquisition/AcquisitionTable.tsx b/src/components/acquisition/AcquisitionTable.tsx new file mode 100644 index 0000000..1501485 --- /dev/null +++ b/src/components/acquisition/AcquisitionTable.tsx @@ -0,0 +1,245 @@ +import React from 'react'; +import { formatEuro, type Acquisition } from '../../types'; + +/* + * Die Liste der gefundenen Projekte. Genau die Spalten, die beim Durchgehen + * zaehlen - alles Weitere steht im Formular und in der Opportunity. + * + * Der Titel ist ein Link, sobald einer hinterlegt ist. Ohne Link bleibt er + * einfacher Text: ein Link, der nirgends hinfuehrt, ist schlimmer als keiner. + */ + +export type AcquisitionSortKey = 'found_at' | 'sender' | 'provider' | 'channel' | 'title' | 'consultant'; + +export interface AcquisitionSortConfig { + key: AcquisitionSortKey; + direction: 'asc' | 'desc'; +} + +interface AcquisitionTableProps { + entries: Acquisition[]; + sortConfig: AcquisitionSortConfig; + onSort: (key: AcquisitionSortKey) => void; + editingId: number | null; + busyId: number | null; + onEdit: (entry: Acquisition) => void; + onDelete: (entry: Acquisition) => void; + onConvert: (entry: Acquisition) => void; +} + +const COLUMNS: { key: AcquisitionSortKey | null; label: string; className?: string }[] = [ + { key: 'found_at', label: 'Datum' }, + { key: 'sender', label: 'Von' }, + { key: 'provider', label: 'Anbieter' }, + { key: 'channel', label: 'Kanal' }, + { key: 'title', label: 'Projekttitel' }, + { key: 'consultant', label: 'Berater' }, + { key: null, label: 'Opportunity' }, + { key: null, label: '', className: 'text-right' }, +]; + +/** Farbe je Kanal, damit sich die Liste beim Durchsehen sortiert anfuehlt. */ +const CHANNEL_STYLES: Record = { + Mail: 'bg-sky-50 text-sky-700 border-sky-200', + Telefon: 'bg-amber-50 text-amber-700 border-amber-200', + Portal: 'bg-violet-50 text-violet-700 border-violet-200', + LinkedIn: 'bg-blue-50 text-blue-700 border-blue-200', + XING: 'bg-teal-50 text-teal-700 border-teal-200', + Empfehlung: 'bg-brand-50 text-brand-700 border-brand-200', + Sonstiges: 'bg-ink-100 text-ink-600 border-ink-200', +}; + +/** Kurzform des Funddatums plus Alter in Tagen als Titelhinweis. */ +function formatFound(value: string): { label: string; title: string } { + const date = new Date(`${value.slice(0, 10)}T00:00:00`); + if (Number.isNaN(date.getTime())) return { label: '—', title: '' }; + + const days = Math.round((Date.now() - date.getTime()) / 86_400_000); + const age = days <= 0 ? 'heute' : days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`; + return { + label: date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }), + title: `Gefunden ${age}`, + }; +} + +/** + * Nur absolute http- und https-Adressen werden zum Link. Das Backend legt + * ohnehin nur solche ab; die Pruefung hier faengt Altbestand und Eintraege ab, + * die auf anderem Weg in die Tabelle gekommen sind. "hays.de/projekt/1" wuerde + * der Browser sonst als Pfad innerhalb des CRM auflegen und ins Leere fuehren. + */ +function safeLink(value: string | null): string | null { + if (!value) return null; + return /^https?:\/\//i.test(value) ? value : null; +} + +/** Kurze Zusammenfassung der Angaben, die keine eigene Spalte haben. */ +function extras(entry: Acquisition): string[] { + const parts: string[] = []; + if (entry.company) parts.push(entry.company); + if (entry.fixed_price !== null) parts.push(`${formatEuro(entry.fixed_price)} Festpreis`); + else if (entry.daily_rate !== null) parts.push(`${formatEuro(entry.daily_rate)}/Tag`); + if (entry.duration_days !== null) parts.push(`${entry.duration_days} PT`); + return parts; +} + +const SortIcon: React.FC<{ active: boolean; direction: 'asc' | 'desc' }> = ({ active, direction }) => ( + + + +); + +export const AcquisitionTable: React.FC = ({ + entries, + sortConfig, + onSort, + editingId, + busyId, + onEdit, + onDelete, + onConvert, +}) => ( +
+
+ + + + {COLUMNS.map((column) => ( + + ))} + + + + + {entries.map((entry) => { + const found = formatFound(entry.found_at); + const link = safeLink(entry.link); + const extraParts = extras(entry); + const isBusy = busyId === entry.id; + + return ( + + + + + + + + + + + + + + + ); + })} + +
+ {column.key ? ( + + ) : ( + column.label + )} +
+ {found.label} + {entry.sender || '—'}{entry.provider || '—'} + {entry.channel ? ( + + {entry.channel} + + ) : ( + — + )} + + {link ? ( + + {entry.title} + ↗ + + ) : ( + {entry.title} + )} + {extraParts.length > 0 && ( +

{extraParts.join(' · ')}

+ )} +
{entry.consultant || '—'} + + +
+ + +
+
+
+
+); diff --git a/src/components/acquisition/Acquisitions.tsx b/src/components/acquisition/Acquisitions.tsx new file mode 100644 index 0000000..eda48fe --- /dev/null +++ b/src/components/acquisition/Acquisitions.tsx @@ -0,0 +1,259 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { AcquisitionForm } from './AcquisitionForm'; +import { + AcquisitionTable, + type AcquisitionSortConfig, + type AcquisitionSortKey, +} from './AcquisitionTable'; +import { ConfirmDialog } from '../ui/Modal'; +import { useToast } from '../Toast'; +import { useNavigation } from '../../lib/navigation'; +import { acquisitionsApi } from '../../lib/api'; +import type { Acquisition, AcquisitionList } from '../../types'; + +/* + * Akquise: gefundene Projekte in einer Liste, mit der Eingabe darueber. + * + * Derselbe Aufbau traegt das Archiv (`view = 'archiv'`): dieselbe Liste, nur + * ohne Eingabe - im Archiv wird nichts Neues eingetragen, es wird abgearbeitet + * oder es verfaellt. + * + * Eingang und Archiv trennt allein das Funddatum; das entscheidet das Backend. + * Nichts hier verschiebt Eintraege, es gibt keinen Zustand, der driften kann. + */ + +interface AcquisitionsProps { + /** 'archiv' zeigt die Eintraege, die aelter als die Frist sind. */ + view?: 'eingang' | 'archiv'; + /** Opportunities der App neu laden - nach einer Uebernahme. */ + reload: () => void; +} + +/** Vergleichswert je Sortierschluessel; leere Felder sortieren ans Ende. */ +function sortValue(entry: Acquisition, key: AcquisitionSortKey): string | null { + switch (key) { + case 'found_at': + return entry.found_at; + case 'title': + return entry.title.toLowerCase(); + default: + return entry[key]?.toLowerCase() ?? null; + } +} + +export const Acquisitions: React.FC = ({ view = 'eingang', reload }) => { + const { addToast } = useToast(); + const { goTo } = useNavigation(); + const isArchive = view === 'archiv'; + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [search, setSearch] = useState(''); + const [editing, setEditing] = useState(null); + const [toDelete, setToDelete] = useState(null); + const [busyId, setBusyId] = useState(null); + const [sortConfig, setSortConfig] = useState({ + key: 'found_at', + direction: 'desc', + }); + + const load = useCallback( + async (silent = false) => { + if (!silent) setIsLoading(true); + try { + setData(await acquisitionsApi.list(view)); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Akquise nicht abrufbar.', 'error'); + } finally { + setIsLoading(false); + } + }, + [view, addToast] + ); + + useEffect(() => { + load(); + }, [load]); + + // Beim Wechsel zwischen Eingang und Archiv nichts Angefangenes mitnehmen. + useEffect(() => { + setEditing(null); + setSearch(''); + }, [view]); + + const entries = useMemo(() => { + const all = data?.entries ?? []; + const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); + + const matches = all.filter((entry) => { + if (terms.length === 0) return true; + const haystack = [ + entry.title, + entry.sender, + entry.provider, + entry.channel, + entry.consultant, + entry.company, + entry.description, + entry.notes, + entry.tags.join(' '), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }); + + const direction = sortConfig.direction === 'asc' ? 1 : -1; + return [...matches].sort((a, b) => { + const left = sortValue(a, sortConfig.key); + const right = sortValue(b, sortConfig.key); + if (left === right) return (a.id - b.id) * direction; + if (left === null) return 1; + if (right === null) return -1; + return left.localeCompare(right, 'de') * direction; + }); + }, [data, search, sortConfig]); + + const handleSort = (key: AcquisitionSortKey) => { + setSortConfig((prev) => + prev.key === key + ? { key, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : // Datum beginnt absteigend - das Neueste zuerst ist der Normalfall. + { key, direction: key === 'found_at' ? 'desc' : 'asc' } + ); + }; + + const convert = async (entry: Acquisition) => { + setBusyId(entry.id); + try { + await acquisitionsApi.toOpportunity(entry.id); + addToast(`Opportunity „${entry.title}" angelegt - Phase „Neu".`, 'success'); + if (editing?.id === entry.id) setEditing(null); + await load(true); + // Die Opportunity-Liste der App kennt den neuen Eintrag noch nicht. + reload(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Übernahme fehlgeschlagen.', 'error'); + await load(true); + } finally { + setBusyId(null); + } + }; + + const confirmDelete = async () => { + if (!toDelete) return; + try { + await acquisitionsApi.remove(toDelete.id); + addToast('Eintrag gelöscht.', 'success'); + if (editing?.id === toDelete.id) setEditing(null); + await load(true); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error'); + } finally { + setToDelete(null); + } + }; + + const archiveAfter = data?.archive_after_days ?? 14; + const deleteAfter = data?.delete_after_days ?? 90; + + return ( +
+ {isArchive ? ( +
+

+ Älter als {archiveAfter} Tage - hierher rutscht alles von selbst. +

+

+ Nach {deleteAfter} Tagen wird ein Eintrag automatisch gelöscht. Bis dahin lässt er sich + weiter bearbeiten und jederzeit in eine Opportunity übernehmen. Neue Projekte werden + unter Akquise eingetragen. +

+
+ ) : ( + load(true)} + onCancelEdit={() => setEditing(null)} + /> + )} + + {/* Steuerleiste */} +
+
+ setSearch(event.target.value)} + placeholder="Titel, Anbieter, Berater, Notizen suchen" + className="w-full rounded-xl border border-ink-200 bg-surface py-2.5 pl-10 pr-4 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25" + /> + + + +
+ +
+ + {entries.length} von {data?.entries.length ?? 0} + {isArchive ? ' im Archiv' : ' im Eingang'} + + {!isArchive && (data?.counts.archiv ?? 0) > 0 && ( + + )} +
+
+ + {isLoading && !data ? ( +
+ +
+ ) : entries.length === 0 ? ( +
+ {search + ? 'Kein Eintrag passt zur Suche.' + : isArchive + ? `Das Archiv ist leer - es gibt keine Projekte, die älter als ${archiveAfter} Tage sind.` + : 'Noch keine Projekte eingetragen. Oben ausfüllen und auf „Eintragen" klicken.'} +
+ ) : ( + + )} + + {toDelete && ( + setToDelete(null)} + /> + )} +
+ ); +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index c9e4e33..144db13 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,6 @@ import type { + Acquisition, + AcquisitionList, AppUser, CalendarEvent, Lead, @@ -197,6 +199,52 @@ export const opportunitiesApi = { remove: (id: number) => request<{ ok: true }>(`/opportunities/${id}`, { method: 'DELETE' }), }; +// ---------- Akquise ---------- + +/** + * Zahlen gehen als Text raus, damit ein leeres Feld auch leer bleibt und nicht + * als 0 in der Datenbank landet - dasselbe Muster wie bei den Opportunities. + */ +export type AcquisitionInput = Partial< + Omit< + Acquisition, + | 'id' + | 'created_at' + | 'updated_at' + | 'converted_at' + | 'opportunity_id' + | 'opportunity_title' + | 'opportunity_stage' + | 'created_by' + | 'created_by_name' + | 'daily_rate' + | 'duration_days' + | 'fixed_price' + > +> & { + daily_rate?: string | number | null; + duration_days?: string | number | null; + fixed_price?: string | number | null; +}; + +export type AcquisitionView = 'eingang' | 'archiv' | 'alle'; + +export const acquisitionsApi = { + list: (view: AcquisitionView = 'eingang') => + request(`/acquisitions?view=${view}`), + create: (input: AcquisitionInput) => + request('/acquisitions', { method: 'POST', body: JSON.stringify(input) }), + update: (id: number, input: AcquisitionInput) => + request(`/acquisitions/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), + remove: (id: number) => request<{ ok: true }>(`/acquisitions/${id}`, { method: 'DELETE' }), + + /** Legt die Opportunity an und vermerkt den Eintrag als uebernommen. */ + toOpportunity: (id: number) => + request<{ ok: true; opportunity_id: number }>(`/acquisitions/${id}/opportunity`, { + method: 'POST', + }), +}; + // ---------- Interviews (Termine) ---------- export interface EventInput { diff --git a/src/types.ts b/src/types.ts index ea9cb4d..cae20e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,6 @@ export type ViewState = + | 'Akquise' + | 'Archiv' | 'Opportunities' | 'Interviews' | 'Kontakte' @@ -45,6 +47,12 @@ export const CONTACT_VIEWS: ViewState[] = ['Kontakte', 'Aus Mails']; */ export const MAIL_VIEWS: ViewState[] = ['Mails', 'Papierkorb']; +/** + * Akquise. "Akquise" ist der Eingang der letzten 14 Tage, "Archiv" alles + * Aeltere. Beides ist dieselbe Liste, nur nach dem Funddatum geteilt. + */ +export const ACQUISITION_VIEWS: ViewState[] = ['Akquise', 'Archiv']; + export type Role = 'admin' | 'user'; export interface AppUser { @@ -240,6 +248,75 @@ export interface Opportunity { export type OpportunityViewMode = 'grid' | 'list' | 'pipeline'; +// ---------- Akquise ---------- + +/** + * Wie das Projekt zu uns kam. Feste Auswahl, damit sich die Liste nach dem Kanal + * gruppieren laesst - als Freitext waeren "Mail", "E-Mail" und "mail" drei Kanaele. + */ +export const ACQUISITION_CHANNELS = [ + 'Mail', + 'Telefon', + 'Portal', + 'LinkedIn', + 'XING', + 'Empfehlung', + 'Sonstiges', +] as const; + +export type AcquisitionChannel = (typeof ACQUISITION_CHANNELS)[number]; + +/** + * Ein gefundenes Projekt, bevor daraus eine Opportunity wird. + * + * Die ersten Felder stehen in der Liste, die uebrigen nur im Formular - sie + * wandern bei der Uebernahme in die Opportunity. + */ +export interface Acquisition { + id: number; + /** Wann gefunden - entscheidet ueber Eingang, Archiv und Loeschung. */ + found_at: string; + /** "Von": wer es geschickt hat. */ + sender: string | null; + /** "Anbieter": Vermittler oder Portal. */ + provider: string | null; + channel: AcquisitionChannel | null; + title: string; + /** "Berater", fuer den es passt. */ + consultant: string | null; + /** Ausschreibung; ist er gesetzt, wird der Titel in der Liste klickbar. */ + link: string | null; + company: string | null; + description: string | null; + daily_rate: number | null; + duration_days: number | null; + fixed_price: number | null; + start_date: string | null; + end_date: string | null; + next_step: string | null; + notes: string | null; + tags: string[]; + /** Gesetzt = uebernommen; der Eintrag verschwindet dann aus beiden Listen. */ + converted_at: string | null; + opportunity_id: number | null; + opportunity_title?: string | null; + opportunity_stage?: OpportunityStage | null; + created_by: number | null; + created_by_name?: string | null; + created_at?: string; + updated_at?: string; +} + +/** Zaehler und Fristen liefert das Backend mit - sie stehen dort, nicht hier. */ +export interface AcquisitionList { + archive_after_days: number; + delete_after_days: number; + counts: { eingang: number; archiv: number }; + /** Bisher benutzte Werte aus dem gesamten Bestand, fuer die Eingabehilfen. */ + suggestions: { providers: string[]; consultants: string[]; senders: string[] }; + entries: Acquisition[]; +} + /** * Projektvolumen. Ein gesetzter Projektpreis gilt als Festpreis und schlaegt die * Rechnung aus Tagessatz x Personentagen; sonst braucht es beide Werte.