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 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-23 08:56:39 +02:00
co-authored by Claude Opus 5.5
parent 3e76a73f2e
commit 8cb5cbc7cc
9 changed files with 144 additions and 40 deletions
+7
View File
@@ -68,6 +68,7 @@ erDiagram
varchar title varchar title
varchar consultant "fuer welchen Berater" varchar consultant "fuer welchen Berater"
int acquisition_id "Herkunft in der Akquise, ohne FK" int acquisition_id "Herkunft in der Akquise, ohne FK"
varchar link "Ausschreibung, http(s)"
enum stage "Neu .. Beauftragt | Absage" enum stage "Neu .. Beauftragt | Absage"
tinyint probability "0-100" tinyint probability "0-100"
decimal daily_rate decimal daily_rate
@@ -214,6 +215,12 @@ Acht Eigenheiten, die man kennen muss:
den Papierkorb und leert beim Eintrag `converted_at` und `opportunity_id`; den Papierkorb und leert beim Eintrag `converted_at` und `opportunity_id`;
`found_at` bleibt. Wird eine davon wiederhergestellt, setzt `/restore` den `found_at` bleibt. Wird eine davon wiederhergestellt, setzt `/restore` den
Verweis wieder, sofern der Eintrag frei ist. 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 - **Der Papierkorb ist die Ausnahme davon.** Löschen von Hand ist eine
Entscheidung und kein Ablauf, deshalb steht sie in `deleted_at` (plus Entscheidung und kein Ablauf, deshalb steht sie in `deleted_at` (plus
`deleted_by`, ohne Fremdschlüssel - siehe `schema.sql`). Vorher war das `deleted_by`, ohne Fremdschlüssel - siehe `schema.sql`). Vorher war das
+9 -2
View File
@@ -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 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 stehen; an der Stelle des Knopfes steht **Übernommen ✓** in Blau, und ein Klick
darauf öffnet die Opportunity. Mit übernommen werden Titel, Endkunde, 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 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 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 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 Feld. In den Notizen steht zusätzlich, mit wem er sich das Projekt teilt
(*Auch beworben*). (*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 Herkunftsvermerk in den internen Notizen, damit nachvollziehbar bleibt, woher das
Projekt kam. 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 einer alten Opportunity ein Name, den die Liste nicht mehr kennt, bleibt er
wählbar und ist als *(nicht in der Liste)* gekennzeichnet. 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 Zu jeder Opportunity gehören beliebig viele **Ansprechpartner** aus den
Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Fehlt Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Fehlt
jemand noch im CRM, öffnet **Neuer Kontakt...** direkt neben der Auswahlliste jemand noch im CRM, öffnet **Neuer Kontakt...** direkt neben der Auswahlliste
+1
View File
@@ -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 | | `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 | | `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 | | `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`) | | `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 | | `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 | | `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 |
+32
View File
@@ -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 };
}
+9 -31
View File
@@ -17,6 +17,7 @@ import {
normalizeConsultants, normalizeConsultants,
splitConsultants, splitConsultants,
} from '../acquisitionRules.js'; } from '../acquisitionRules.js';
import { normalizeLink } from '../link.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
@@ -77,7 +78,6 @@ const MAX_LENGTH = {
title: 190, title: 190,
// Liste mehrerer Berater, deshalb weiter als die uebrigen Namensfelder. // Liste mehrerer Berater, deshalb weiter als die uebrigen Namensfelder.
consultant: CONSULTANT_MAX_LENGTH, consultant: CONSULTANT_MAX_LENGTH,
link: 1024,
company: 190, company: 190,
next_step: 500, next_step: 500,
}; };
@@ -114,29 +114,6 @@ function normalizeNumber(value, label, max) {
return { value: number }; 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) { function parseTags(raw) {
if (!raw) return []; if (!raw) return [];
try { 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, * 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. * niemand mehr, woher das Projekt kam.
* *
* Der Berater steht seit dem 09.09.2026 in einer eigenen Spalte und wird * Berater (seit 09.09.2026) und Link (seit 23.09.2026) stehen in eigenen
* hier trotzdem noch genannt: die Notiz ist der Herkunftsnachweis und soll * Spalten und werden hier trotzdem noch genannt: die Notiz ist der
* fuer sich lesbar bleiben. Dazu, mit wem er sich das Projekt teilt - das * Herkunftsnachweis und soll fuer sich lesbar bleiben. Dazu, mit wem der
* hat sonst keinen Platz. * Berater sich das Projekt teilt - das hat sonst keinen Platz.
*/ */
const buildNotes = (consultant) => { const buildNotes = (consultant) => {
const others = targets.filter((name) => name !== null && name !== 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) { for (const consultant of targets) {
const [created] = await conn.query( const [created] = await conn.query(
`INSERT INTO crm_opportunities `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, daily_rate, duration_days, fixed_price, start_date, end_date, next_step, tags,
notes, owner_id, created_by) notes, owner_id, created_by)
VALUES (?, ?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, 'Erstgespraech Berater', 10, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
entry.title, entry.title,
entry.company, entry.company,
@@ -1072,6 +1049,7 @@ router.post('/:id/opportunity', async (req, res, next) => {
// Jede der Opportunities kennt ihren Eintrag - sonst faende das // Jede der Opportunities kennt ihren Eintrag - sonst faende das
// Zuruecknehmen der Uebernahme nur die erste. // Zuruecknehmen der Uebernahme nur die erste.
id, id,
entry.link,
entry.description, entry.description,
entry.daily_rate, entry.daily_rate,
entry.duration_days, entry.duration_days,
+21 -7
View File
@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import { pool } from '../db.js'; import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { normalizeLink } from '../link.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); 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).` }); .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 ?? []); const contacts = await normalizeContacts(req.body?.contacts ?? []);
if (contacts.error) return res.status(400).json({ error: contacts.error }); 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 // Wer direkt in 'Beauftragt' anlegt, hat gerade gewonnen - dann steht das
// Datum sofort. // Datum sofort.
`INSERT INTO crm_opportunities `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, duration_days, fixed_price, start_date, end_date, last_interaction_at,
last_interaction_note, next_step, tags, notes, owner_id, created_by) last_interaction_note, next_step, tags, notes, owner_id, created_by)
VALUES (?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
title, title,
emptyToNull(req.body?.company), emptyToNull(req.body?.company),
consultant, consultant,
link.value,
emptyToNull(req.body?.description), emptyToNull(req.body?.description),
stage, stage,
Math.round(probability.value ?? 10), Math.round(probability.value ?? 10),
@@ -416,6 +422,13 @@ router.patch('/:id', async (req, res, next) => {
values.push(value); 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 (req.body?.stage !== undefined) {
if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungültige Phase.' }); if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungültige Phase.' });
fields.push('stage = ?'); 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 * fuer einen Kollegen, entsteht je Berater eine Kopie mit eigener Phase - wie
* bei der Uebernahme aus der Akquise, nur nachtraeglich. * 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 * Zeitraum, Tags, Notizen, Ansprechpartner, Zustaendiger und die Herkunft aus
* der Akquise (damit "Zurueck in die Akquise" auch die Kopien findet). Nicht * der Akquise (damit "Zurueck in die Akquise" auch die Kopien findet). Nicht
* kopiert wird, was zum Verlauf des Originals gehoert: letzte Interaktion, * 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) { for (const consultant of consultants) {
const [created] = await conn.query( const [created] = await conn.query(
`INSERT INTO crm_opportunities `INSERT INTO crm_opportunities
(title, company, consultant, acquisition_id, description, stage, won_at, probability, (title, company, consultant, acquisition_id, link, description, stage, won_at,
daily_rate, duration_days, fixed_price, start_date, end_date, tags, notes, probability, daily_rate, duration_days, fixed_price, start_date, end_date, tags,
owner_id, created_by) notes, owner_id, created_by)
VALUES (?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ${stage === 'Beauftragt' ? 'UTC_TIMESTAMP()' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
source.title, source.title,
source.company, source.company,
consultant, consultant,
source.acquisition_id, source.acquisition_id,
source.link,
source.description, source.description,
stage, stage,
source.probability, source.probability,
+12
View File
@@ -500,6 +500,18 @@ UPDATE crm_opportunities o
SET o.acquisition_id = a.id SET o.acquisition_id = a.id
WHERE o.acquisition_id IS NULL; 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 * Auswahllisten der Akquise. "Von", "Kanal" und "Berater" sind Dropdowns, deren
* Inhalte in den Einstellungen gepflegt werden. * Inhalte in den Einstellungen gepflegt werden.
@@ -39,6 +39,7 @@ interface FormState {
title: string; title: string;
company: string; company: string;
consultant: string; consultant: string;
link: string;
description: string; description: string;
stage: OpportunityStage; stage: OpportunityStage;
probability: string; probability: string;
@@ -65,6 +66,7 @@ function toFormState(opportunity: Opportunity | null, fallbackOwnerId: number |
title: opportunity?.title ?? '', title: opportunity?.title ?? '',
company: opportunity?.company ?? '', company: opportunity?.company ?? '',
consultant: opportunity?.consultant ?? '', consultant: opportunity?.consultant ?? '',
link: opportunity?.link ?? '',
description: opportunity?.description ?? '', description: opportunity?.description ?? '',
stage: opportunity?.stage ?? 'Neu', stage: opportunity?.stage ?? 'Neu',
probability: String(opportunity?.probability ?? 10), probability: String(opportunity?.probability ?? 10),
@@ -137,6 +139,19 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value })); 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 leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]);
const addableLeads = useMemo(() => { const addableLeads = useMemo(() => {
@@ -201,6 +216,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
title: form.title.trim(), title: form.title.trim(),
company: form.company.trim() || null, company: form.company.trim() || null,
consultant: form.consultant.trim() || null, consultant: form.consultant.trim() || null,
link: form.link.trim() || null,
description: form.description.trim() || null, description: form.description.trim() || null,
stage: form.stage, stage: form.stage,
probability: Number(form.probability) || 0, probability: Number(form.probability) || 0,
@@ -408,6 +424,41 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
))} ))}
</select> </select>
</div> </div>
{/*
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.
*/}
<div>
<label className={labelClass} htmlFor="opp-link">
Link
</label>
<div className="flex gap-2">
<input
id="opp-link"
inputMode="url"
value={form.link}
onChange={(event) => set('link', event.target.value)}
placeholder="https://..."
className={inputClass}
/>
{linkHref && (
<a
href={linkHref}
target="_blank"
rel="noopener noreferrer"
title="Link in neuem Tab öffnen"
className="flex shrink-0 items-center justify-center gap-2 rounded-xl border border-ink-200 px-4 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Öffnen
</a>
)}
</div>
</div>
</div> </div>
<DictateTextarea <DictateTextarea
+2
View File
@@ -322,6 +322,8 @@ export interface Opportunity {
acquisition_id: number | null; acquisition_id: number | null;
/** Der Eintrag existiert noch und liegt nicht im Papierkorb: der Rueckweg ist offen. */ /** Der Eintrag existiert noch und liegt nicht im Papierkorb: der Rueckweg ist offen. */
acquisition_available?: boolean; acquisition_available?: boolean;
/** Link zur Ausschreibung, bei der Uebernahme aus der Akquise mitgenommen. */
link: string | null;
description: string | null; description: string | null;
stage: OpportunityStage; stage: OpportunityStage;
/** 0-100 */ /** 0-100 */