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:
co-authored by
Claude Opus 5.5
parent
3e76a73f2e
commit
8cb5cbc7cc
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user