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
+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,
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,
+21 -7
View File
@@ -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,
+12
View File
@@ -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.