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.