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
@@ -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<OpportunityModalProps> = ({
const set = <K extends keyof FormState>(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<OpportunityModalProps> = ({
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<OpportunityModalProps> = ({
))}
</select>
</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>
<DictateTextarea
+2
View File
@@ -322,6 +322,8 @@ export interface Opportunity {
acquisition_id: number | null;
/** Der Eintrag existiert noch und liegt nicht im Papierkorb: der Rueckweg ist offen. */
acquisition_available?: boolean;
/** Link zur Ausschreibung, bei der Uebernahme aus der Akquise mitgenommen. */
link: string | null;
description: string | null;
stage: OpportunityStage;
/** 0-100 */