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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user