From 8cb5cbc7ccf4c67ec922dcc46c02e1e2271e511a Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Wed, 23 Sep 2026 08:56:39 +0200 Subject: [PATCH] feat(opportunities): Link zur Ausschreibung als eigenes Feld Die Uebernahme aus der Akquise schrieb den Link bisher nur in die Notizen. Jetzt hat die Opportunity eine Spalte `link`, gefuellt bei Uebernahme und Kopie; im Dialog steht sie rechts neben der Phase mit Knopf "Oeffnen". schema.sql traegt den Link fuer aeltere Uebernahmen nach. Die Pruefung liegt in server/src/link.js und gilt fuer Akquise und Opportunities gleich. Co-Authored-By: Claude Opus 5.5 --- docs/02-datenmodell.md | 7 +++ docs/07-anleitung-benutzer.md | 11 +++- docs/09-quellcode.md | 1 + server/src/link.js | 32 ++++++++++++ server/src/routes/acquisitions.js | 40 ++++----------- server/src/routes/opportunities.js | 28 +++++++--- server/src/schema.sql | 12 +++++ .../opportunities/OpportunityModal.tsx | 51 +++++++++++++++++++ src/types.ts | 2 + 9 files changed, 144 insertions(+), 40 deletions(-) create mode 100644 server/src/link.js diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 5f9cb59..cc93862 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -68,6 +68,7 @@ erDiagram varchar title varchar consultant "fuer welchen Berater" int acquisition_id "Herkunft in der Akquise, ohne FK" + varchar link "Ausschreibung, http(s)" enum stage "Neu .. Beauftragt | Absage" tinyint probability "0-100" decimal daily_rate @@ -214,6 +215,12 @@ Acht Eigenheiten, die man kennen muss: den Papierkorb und leert beim Eintrag `converted_at` und `opportunity_id`; `found_at` bleibt. Wird eine davon wiederhergestellt, setzt `/restore` den Verweis wieder, sofern der Eintrag frei ist. +- **Der Link zur Ausschreibung** steht seit 23.09.2026 auch in + `crm_opportunities.link` (VARCHAR 1024). Die Übernahme kopiert ihn aus + `crm_acquisitions.link`, `/copy` von der Quelle. Beide Seiten prüfen mit + derselben Regel (`server/src/link.js`: nur http/https, ohne Schema wird + `https://` ergänzt). `schema.sql` trägt den Link für ältere Übernahmen über + `acquisition_id` nach - nur in leere Felder. - **Der Papierkorb ist die Ausnahme davon.** Löschen von Hand ist eine Entscheidung und kein Ablauf, deshalb steht sie in `deleted_at` (plus `deleted_by`, ohne Fremdschlüssel - siehe `schema.sql`). Vorher war das diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 97494ff..b6e81c3 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -377,7 +377,7 @@ Der Knopf **Opportunity** legt sie sofort an: Phase *Erstgespräch Berater*, 10 zuständig ist der angemeldete Benutzer. Der Eintrag bleibt danach in der Liste stehen; an der Stelle des Knopfes steht **Übernommen ✓** in Blau, und ein Klick darauf öffnet die Opportunity. Mit übernommen werden Titel, Endkunde, -Beschreibung, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt. +Beschreibung, Link, Tagessatz, Laufzeit, Projektpreis, Zeitraum und nächster Schritt. Der **Berater** wandert in das gleichnamige Feld der Opportunity und steht damit in Pipeline, Liste und Kacheln - für wen ein Projekt läuft, ist neben dem Titel die wichtigste Angabe. Sind **mehrere Berater** hinterlegt, entsteht **je Berater @@ -385,7 +385,7 @@ eine Opportunity** mit denselben Projektdaten, jede mit ihrem eigenen Namen im Feld. In den Notizen steht zusätzlich, mit wem er sich das Projekt teilt (*Auch beworben*). -Anbieter, Von, Kanal und Link haben in der Opportunity kein Feld - sie landen als +Anbieter, Von und Kanal haben in der Opportunity kein Feld - sie landen als Herkunftsvermerk in den internen Notizen, damit nachvollziehbar bleibt, woher das Projekt kam. @@ -490,6 +490,13 @@ der Akquise zu dreien, entstehen bei der Übernahme drei Opportunities. Steht in einer alten Opportunity ein Name, den die Liste nicht mehr kennt, bleibt er wählbar und ist als *(nicht in der Liste)* gekennzeichnet. +Rechts neben der **Phase** steht der **Link** zur Ausschreibung. Bei der +Übernahme aus der Akquise kommt er mit, sonst lässt er sich eintragen - +`hays.de/projekt/1` genügt, das `https://` wird ergänzt. Der Knopf **Öffnen** +daneben zeigt die Ausschreibung in einem neuen Tab. Opportunities, die vor dem +23.09.2026 übernommen wurden, haben den Link beim ersten Start danach aus ihrem +Akquise-Eintrag bekommen, sofern der noch existiert. + Zu jeder Opportunity gehören beliebig viele **Ansprechpartner** aus den Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Fehlt jemand noch im CRM, öffnet **Neuer Kontakt...** direkt neben der Auswahlliste diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index bee3cda..c1bc3e5 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -102,6 +102,7 @@ Alle unter `router.use(requireAuth)`. | `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /:id/copy`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist. `/:id/copy` (`{ consultants, stage }`) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und `acquisition_id` kommen mit, Interaktion und nächster Schritt nicht; jeder Angemeldete | | `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist | | `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt. Für Administratoren zusätzlich `ms_secret`, sobald das Client-Secret in weniger als 30 Tagen abläuft | +| `link.js` (kein Router) | `normalizeLink()` für den Link zur Ausschreibung: nur http/https, ohne Schema wird `https://` ergänzt, max. 1024 Zeichen. Geteilt von Akquise und Opportunities, damit ein übernommener Link nicht an einer anderen Prüfung scheitert | | `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IN_INBOX`, `IN_ARCHIVE`, `RETURNED`, `IS_OPEN` (nur noch für den Löschlauf), `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) | | `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin | | `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 | diff --git a/server/src/link.js b/server/src/link.js new file mode 100644 index 0000000..5d9ce95 --- /dev/null +++ b/server/src/link.js @@ -0,0 +1,32 @@ +/* + * Pruefung fuer Links zur Ausschreibung. Akquise und Opportunity nutzen + * dieselbe Regel - der Link wandert bei der Uebernahme von der einen in die + * andere und soll dort nicht an einer strengeren Pruefung scheitern. + */ + +export const LINK_MAX_LENGTH = 1024; + +/** + * Nur http und https. Ohne diese Pruefung liesse sich ein `javascript:`-Verweis + * hinterlegen, den die Oberflaeche anklickbar macht. + */ +export function normalizeLink(value) { + const text = value === null || value === undefined ? '' : String(value).trim(); + if (text === '') 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 > LINK_MAX_LENGTH) { + return { error: `Der Link ist zu lang (max. ${LINK_MAX_LENGTH} Zeichen).` }; + } + return { value: candidate }; +} diff --git a/server/src/routes/acquisitions.js b/server/src/routes/acquisitions.js index fc8aa95..e3eb009 100644 --- a/server/src/routes/acquisitions.js +++ b/server/src/routes/acquisitions.js @@ -17,6 +17,7 @@ import { normalizeConsultants, splitConsultants, } from '../acquisitionRules.js'; +import { normalizeLink } from '../link.js'; const router = express.Router(); router.use(requireAuth); @@ -77,7 +78,6 @@ const MAX_LENGTH = { title: 190, // Liste mehrerer Berater, deshalb weiter als die uebrigen Namensfelder. consultant: CONSULTANT_MAX_LENGTH, - link: 1024, company: 190, next_step: 500, }; @@ -114,29 +114,6 @@ function normalizeNumber(value, label, max) { 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 { @@ -1019,13 +996,13 @@ router.post('/:id/opportunity', async (req, res, next) => { /* * Was in der Liste steht, aber in der Opportunity kein Feld hat (Anbieter, - * Kanal, Absender, Link), wandert in die Notizen. Sonst wuesste hinterher + * Kanal, Absender), wandert in die Notizen. Sonst wuesste hinterher * niemand mehr, woher das Projekt kam. * - * Der Berater steht seit dem 09.09.2026 in einer eigenen Spalte und wird - * hier trotzdem noch genannt: die Notiz ist der Herkunftsnachweis und soll - * fuer sich lesbar bleiben. Dazu, mit wem er sich das Projekt teilt - das - * hat sonst keinen Platz. + * Berater (seit 09.09.2026) und Link (seit 23.09.2026) stehen in eigenen + * Spalten und werden hier trotzdem noch genannt: die Notiz ist der + * Herkunftsnachweis und soll fuer sich lesbar bleiben. Dazu, mit wem der + * Berater sich das Projekt teilt - das hat sonst keinen Platz. */ const buildNotes = (consultant) => { const others = targets.filter((name) => name !== null && name !== consultant); @@ -1061,10 +1038,10 @@ router.post('/:id/opportunity', async (req, res, next) => { for (const consultant of targets) { const [created] = await conn.query( `INSERT INTO crm_opportunities - (title, company, consultant, acquisition_id, description, stage, probability, + (title, company, consultant, acquisition_id, link, description, stage, probability, daily_rate, duration_days, fixed_price, start_date, end_date, next_step, tags, notes, owner_id, created_by) - VALUES (?, ?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ entry.title, entry.company, @@ -1072,6 +1049,7 @@ router.post('/:id/opportunity', async (req, res, next) => { // Jede der Opportunities kennt ihren Eintrag - sonst faende das // Zuruecknehmen der Uebernahme nur die erste. id, + entry.link, entry.description, entry.daily_rate, entry.duration_days, diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js index a0bd141..cae4ff1 100644 --- a/server/src/routes/opportunities.js +++ b/server/src/routes/opportunities.js @@ -1,6 +1,7 @@ import express from 'express'; import { pool } from '../db.js'; import { requireAuth } from '../middleware/auth.js'; +import { normalizeLink } from '../link.js'; const router = express.Router(); router.use(requireAuth); @@ -328,6 +329,10 @@ router.post('/', async (req, res, next) => { .json({ error: `Der Berater ist zu lang (max. ${CONSULTANT_MAX_LENGTH} Zeichen).` }); } + // Link zur Ausschreibung - dieselbe Pruefung wie in der Akquise. + const link = normalizeLink(req.body?.link); + if (link.error) return res.status(400).json({ error: link.error }); + const contacts = await normalizeContacts(req.body?.contacts ?? []); if (contacts.error) return res.status(400).json({ error: contacts.error }); @@ -338,14 +343,15 @@ router.post('/', async (req, res, next) => { // Wer direkt in 'Beauftragt' anlegt, hat gerade gewonnen - dann steht das // Datum sofort. `INSERT INTO crm_opportunities - (title, company, consultant, description, stage, won_at, probability, daily_rate, + (title, company, consultant, link, description, stage, won_at, probability, daily_rate, duration_days, fixed_price, start_date, end_date, last_interaction_at, last_interaction_note, next_step, tags, notes, owner_id, created_by) - VALUES (?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ title, emptyToNull(req.body?.company), consultant, + link.value, emptyToNull(req.body?.description), stage, Math.round(probability.value ?? 10), @@ -416,6 +422,13 @@ router.patch('/:id', async (req, res, next) => { values.push(value); } + if (req.body?.link !== undefined) { + const link = normalizeLink(req.body.link); + if (link.error) return res.status(400).json({ error: link.error }); + fields.push('link = ?'); + values.push(link.value); + } + if (req.body?.stage !== undefined) { if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungültige Phase.' }); fields.push('stage = ?'); @@ -708,7 +721,7 @@ router.post('/:id/acquisition', async (req, res, next) => { * fuer einen Kollegen, entsteht je Berater eine Kopie mit eigener Phase - wie * bei der Uebernahme aus der Akquise, nur nachtraeglich. * - * Kopiert wird, was das Projekt beschreibt: Titel, Firma, Beschreibung, Zahlen, + * Kopiert wird, was das Projekt beschreibt: Titel, Firma, Link, Beschreibung, Zahlen, * Zeitraum, Tags, Notizen, Ansprechpartner, Zustaendiger und die Herkunft aus * der Akquise (damit "Zurueck in die Akquise" auch die Kopien findet). Nicht * kopiert wird, was zum Verlauf des Originals gehoert: letzte Interaktion, @@ -764,15 +777,16 @@ router.post('/:id/copy', async (req, res, next) => { for (const consultant of consultants) { const [created] = await conn.query( `INSERT INTO crm_opportunities - (title, company, consultant, acquisition_id, description, stage, won_at, probability, - daily_rate, duration_days, fixed_price, start_date, end_date, tags, notes, - owner_id, created_by) - VALUES (?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (title, company, consultant, acquisition_id, link, description, stage, won_at, + probability, daily_rate, duration_days, fixed_price, start_date, end_date, tags, + notes, owner_id, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ source.title, source.company, consultant, source.acquisition_id, + source.link, source.description, stage, source.probability, diff --git a/server/src/schema.sql b/server/src/schema.sql index f34f03d..ef97515 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -500,6 +500,18 @@ UPDATE crm_opportunities o SET o.acquisition_id = a.id WHERE o.acquisition_id IS NULL; +/* + * Link zur Ausschreibung. Bis zum 23.09.2026 hatte die Opportunity kein Feld + * dafuer, und die Uebernahme schrieb den Link nur in die Notizen. Das UPDATE + * traegt ihn fuer uebernommene Opportunities aus ihrem Akquise-Eintrag nach - + * nur in leere Felder, damit bei jedem Start harmlos. + */ +ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS link VARCHAR(1024) NULL AFTER acquisition_id; +UPDATE crm_opportunities o + JOIN crm_acquisitions a ON a.id = o.acquisition_id + SET o.link = a.link + WHERE o.link IS NULL AND a.link IS NOT NULL; + /* * Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren * Inhalte in den Einstellungen gepflegt werden. diff --git a/src/components/opportunities/OpportunityModal.tsx b/src/components/opportunities/OpportunityModal.tsx index 2154600..b3e27a6 100644 --- a/src/components/opportunities/OpportunityModal.tsx +++ b/src/components/opportunities/OpportunityModal.tsx @@ -39,6 +39,7 @@ interface FormState { title: string; company: string; consultant: string; + link: string; description: string; stage: OpportunityStage; probability: string; @@ -65,6 +66,7 @@ function toFormState(opportunity: Opportunity | null, fallbackOwnerId: number | title: opportunity?.title ?? '', company: opportunity?.company ?? '', consultant: opportunity?.consultant ?? '', + link: opportunity?.link ?? '', description: opportunity?.description ?? '', stage: opportunity?.stage ?? 'Neu', probability: String(opportunity?.probability ?? 10), @@ -137,6 +139,19 @@ export const OpportunityModal: React.FC = ({ const set = (key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })); + // Nur http(s) wird anklickbar - wie die Pruefung im Server. + const linkHref = useMemo(() => { + const text = form.link.trim(); + if (!text) return null; + const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(text) ? text : `https://${text}`; + try { + const url = new URL(candidate); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null; + } catch { + return null; + } + }, [form.link]); + const leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]); const addableLeads = useMemo(() => { @@ -201,6 +216,7 @@ export const OpportunityModal: React.FC = ({ title: form.title.trim(), company: form.company.trim() || null, consultant: form.consultant.trim() || null, + link: form.link.trim() || null, description: form.description.trim() || null, stage: form.stage, probability: Number(form.probability) || 0, @@ -408,6 +424,41 @@ export const OpportunityModal: React.FC = ({ ))} + {/* + Link zur Ausschreibung. Kommt bei der Uebernahme aus der Akquise mit; + der Knopf oeffnet ihn in einem neuen Tab. Ohne Schema ergaenzt der + Server "https://" - der Knopf tut dasselbe, damit er schon vor dem + Speichern funktioniert. + */} +
+ +
+ set('link', event.target.value)} + placeholder="https://..." + className={inputClass} + /> + {linkHref && ( + + + + + Öffnen + + )} +
+