feat(scanner): Funde mit Akquise und Opportunities abgleichen

Neu gefundene Projekte mit Treffer werden einmalig per gpt-5.6-luna mit den
Akquise-Eintraegen und Opportunities der letzten N Tage (Vorgabe 14)
verglichen. Passt ein Eintrag, zeigt der Scanner "bereits beworben!" samt
Begruendung. Schalter und Zeitraum (Tage/Wochen) in Einstellungen -> Scanner,
Neupruefung von Hand im Detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-17 17:12:17 +02:00
co-authored by Claude Opus 5
parent 87ca52a34b
commit af3ca33743
15 changed files with 549 additions and 10 deletions
+3
View File
@@ -332,6 +332,9 @@ erDiagram
varchar rate
varchar url
datetime published_at
tinyint already_applied "in Akquise/Opportunities gefunden"
text applied_refs "JSON: type, id, title, consultant, reason"
datetime applied_check_at "NULL = noch nicht abgeglichen"
timestamp created_at "gescrapt am, Aufbewahrung 7 Tage"
}
matches {
+71 -1
View File
@@ -40,12 +40,81 @@ sequenceDiagram
P->>M: E-Mail an Agent oder Standardempfaenger
end
end
opt Abgleich aktiv, Projekt hat Treffer, noch nicht geprüft
P->>P: Akquise und Opportunities der letzten N Tage laden
P->>AI: gpt-5.6-luna: Projekt vs. CRM-Einträge
AI-->>P: { matches: [{ type, id, reason }] }
P->>DB: already_applied, applied_refs, applied_check_at
end
```
Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene
Treffer (Unique-Key `agent_id, project_id`) und die Ausschlussbegriffe. Ein
Projekt, das „Arbeitnehmerüberlassung" enthält, kostet damit keinen Cent.
## Abgleich „bereits beworben!"
Dieselbe Ausschreibung kommt oft mehrfach: über zwei Börsen, über mehrere
Vermittler, mit anderem Titel und anderem Link. Damit niemand ein Projekt ein
zweites Mal anbietet, gleicht `services/appliedCheck.js` jeden Fund mit der
**Akquise-Liste** (`crm_acquisitions`) und den **Opportunities**
(`crm_opportunities`) ab. Steht er dort schon, trägt der Treffer im Dashboard,
unter *Unpassend* und im Detail die Markierung **bereits beworben!**; das Detail
nennt die passenden Einträge samt Begründung.
```mermaid
flowchart TD
A["Projekt im Lauf verarbeitet"] --> B{"applied_check_active?"}
B -->|nein| X["nichts tun"]
B -->|ja| C{"schon geprüft?<br/>applied_check_at"}
C -->|ja| X
C -->|nein| D{"mindestens ein Treffer?"}
D -->|nein| X
D -->|ja| E["Kandidaten laden:<br/>Akquise found_at, Opportunities created_at<br/>der letzten N Tage, ohne Papierkorb"]
E --> F{"Kandidaten?"}
F -->|nein| N["nicht beworben"]
F -->|ja| G{"gleicher Link?"}
G -->|ja| Y["bereits beworben"]
G -->|nein| H["gpt-5.6-luna, reasoning_effort low"]
H -->|Treffer mit bekannter Id| Y
H -->|keine| N
N --> S["am Projekt speichern"]
Y --> S
```
| Einstellung | Vorgabe | Bedeutung |
| --- | --- | --- |
| `applied_check_active` | `true` | Schalter in *Einstellungen → Scanner* |
| `applied_check_days` | `14` | Zeitraum zurück, in Tagen (Oberfläche: Tage oder Wochen), höchstens 365 |
Entscheidungen dahinter:
- **Eigenes Modell.** Die Bewertung CV gegen Projekt bleibt bei `gpt-4o-mini`;
der Abgleich nutzt `gpt-5.6-luna` (Konstante `APPLIED_CHECK_MODEL`). Er muss
umformulierte Ausschreibungen als dieselbe erkennen und ähnliche Rollen bei
anderem Kunden auseinanderhalten - das ist ein Urteil, kein Textvergleich.
Der Schlüssel ist derselbe `openai_api_key`.
- **Einmal je Projekt, nur mit Treffer.** Die Börsen liefern bei jedem Lauf
dieselben Projekte erneut; `applied_check_at` verhindert, dass sie jedes Mal
bezahlt werden. Projekte ohne Treffer zeigt die Oberfläche nie an.
- **Gleicher Link braucht keine KI.** Verglichen wird ohne Protokoll, `www`,
Query und Schrägstrich am Ende.
- **Nur Ids, die wir geschickt haben.** Nennt das Modell eine unbekannte Id,
zählt sie nicht.
- **Fehler setzen aus, nicht ab.** Scheitert der Aufruf (Schlüssel, Quote,
Netz), bleibt der Abgleich für den Rest des Laufs aus und das Projekt
ungeprüft - der nächste Lauf versucht es wieder. Die Bewertung läuft davon
unberührt weiter.
- **Momentaufnahme.** Geprüft wird beim Fund. Wird die Bewerbung erst danach
in der Akquise erfasst, holt *Auf Bewerbung prüfen* im Detail den Abgleich
von Hand nach (`POST /projects/:id/applied-check`, auch bei abgeschaltetem
Automatismus).
Die drei Spalten an `projects` legt `initScannerDatabase()` per
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` an - `sync()` ohne `alter` tut das in
bestehenden Tabellen nicht.
## Wann läuft er
```mermaid
@@ -215,7 +284,7 @@ Zertifikatswarnung. Der DNS-Eintrag muss beim Anbieter gelöscht werden.
| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload |
| Börsen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import |
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, OpenAI, Ton, E-Mail |
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, Abgleich mit Akquise/Opportunities, OpenAI, Ton, E-Mail |
**Wichtig zum Ton:** Er hängt an der Dashboard-Komponente. Wer auf einen anderen
Menüpunkt wechselt, hört nichts - die Komponente wird ausgebaut und die
@@ -240,6 +309,7 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`:
| POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Prüfung |
| GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` |
| PUT/DELETE | `/matches/:id` | Status setzen, löschen |
| POST | `/projects/:id/applied-check` | Abgleich „bereits beworben!" von Hand wiederholen |
| GET/POST | `/settings` | Geheimnisse maskiert |
| POST | `/scan` | Lauf starten, `409` wenn schon einer läuft |
| GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname |
+16
View File
@@ -819,6 +819,17 @@ wissen:
3. **Ist der Tab geschlossen, passiert nichts.** Wer unabhängig vom Browser
benachrichtigt werden will, schaltet die **E-Mail** in den Einstellungen ein.
### „bereits beworben!"
Jeder neue Treffer wird mit der **Akquise-Liste** und den **Opportunities**
abgeglichen. Steht das Projekt dort schon - auch unter anderem Titel oder von
einer anderen Börse -, trägt es in der Liste die rote Markierung **bereits
beworben!**. Im Detail steht, welcher Eintrag passt, für welchen Berater und
warum die KI ihn für dasselbe Projekt hält.
Geprüft wird einmal, beim Fund. Wer die Bewerbung erst danach in der Akquise
einträgt, klickt im Detail auf **Auf Bewerbung prüfen**.
### Unpassend
Alles, was über *Passt nicht* aussortiert wurde. Von hier bringt *Zurück ins
@@ -900,6 +911,11 @@ nicht mehr: kein Lauf im Hintergrund, und *Jetzt scannen* antwortet mit einem
Hinweis statt zu starten. Der Zustand steht auch oben im Scanner-Dashboard, damit
man nicht rätselt, warum keine neuen Treffer kommen.
Unter **Abgleich mit Akquise und Opportunities** lässt sich die Markierung
*bereits beworben!* ein- und ausschalten. **Zeitraum zurück** bestimmt, wie alt
ein Akquise-Eintrag oder eine Opportunity höchstens sein darf, um verglichen zu
werden - in Tagen oder Wochen, Vorgabe zwei Wochen.
Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
**Verbindung testen** prüft beides: die Anmeldung *und* den tatsächlichen
Zugriff auf `crm@perinco.de`. Schlägt etwas fehl, nennt die Meldung den
+1
View File
@@ -229,6 +229,7 @@ Die portierte Scanner-API unter `/api/scanner`, vollständig in
| `models/Setting.js` | Schlüssel-Wert mit `getVal`/`setVal` |
| `services/scraper.js` | Playwright je Portal; unverändert aus der alten App |
| `services/matchingEngine.js` | OpenAI `gpt-4o-mini`, `response_format: json_object`, `temperature: 0.1` |
| `services/appliedCheck.js` | Abgleich mit Akquise/Opportunities, OpenAI `gpt-5.6-luna`, Ergebnis an `projects` |
| `services/pipeline.js` | ein Lauf: scrapen, speichern, bewerten, benachrichtigen |
| `services/scheduler.js` | node-cron jede Minute, Zeitfenster, Sperrfrist, `last_scan_at` |
| `services/cleanup.js` | Projekte älter als 7 Tage löschen (Treffer kaskadieren) |
+20
View File
@@ -8,6 +8,7 @@ import { parsePdfCv } from '../scanner/services/pdfParser.js';
import { encrypt } from '../scanner/utils/crypto.js';
import { ensureUploadDir, resolveCvPath } from '../scanner/uploadDir.js';
import { isPipelineRunning, runMatchingPipeline } from '../scanner/services/pipeline.js';
import { appliedCheckSettings, checkProjectAlreadyApplied } from '../scanner/services/appliedCheck.js';
import {
scrapeFreelancermap,
scrapeFreelanceDe,
@@ -456,6 +457,25 @@ router.delete('/matches/:id', async (req, res) => {
}
});
/*
* Abgleich "bereits beworben?" fuer ein Projekt von Hand wiederholen - etwa
* nachdem die Bewerbung erst nach dem Fund in der Akquise erfasst wurde. Laeuft
* auch bei abgeschaltetem Automatismus, der Zeitraum gilt trotzdem.
*/
router.post('/projects/:id/applied-check', async (req, res) => {
try {
const project = await Project.findByPk(req.params.id);
if (!project) {
return res.status(404).json({ error: 'Projekt nicht gefunden.' });
}
const { days } = await appliedCheckSettings();
await checkProjectAlreadyApplied(project, { days });
res.json(project);
} catch (error) {
res.status(502).json({ error: `Abgleich fehlgeschlagen: ${error.message}` });
}
});
// ==========================================
// EINSTELLUNGEN
// ==========================================
+30
View File
@@ -45,6 +45,36 @@ const Project = sequelize.define('Project', {
allowNull: true,
field: 'published_at',
},
// Abgleich mit Akquise und Opportunities (services/appliedCheck.js). Die
// Spalten zieht initScannerDatabase() per ALTER TABLE nach.
alreadyApplied: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'already_applied',
},
appliedRefs: {
type: DataTypes.TEXT,
allowNull: true,
field: 'applied_refs',
comment: 'JSON array: [{ type, id, title, consultant, reason }]',
get() {
const rawValue = this.getDataValue('appliedRefs');
try {
return rawValue ? JSON.parse(rawValue) : [];
} catch {
return [];
}
},
set(value) {
this.setDataValue('appliedRefs', value ? JSON.stringify(value) : null);
},
},
appliedCheckAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'applied_check_at',
},
}, {
tableName: 'projects',
indexes: [
+14 -1
View File
@@ -19,6 +19,17 @@ const initScannerDatabase = async () => {
await sequelize.sync();
console.log('[Scanner] Tabellen synchronisiert.');
// Spalten, die nach dem ersten Anlegen dazukamen. sync() ohne alter legt
// sie in bestehenden Tabellen nicht an; MariaDB kennt IF NOT EXISTS.
const columnMigrations = [
'ALTER TABLE projects ADD COLUMN IF NOT EXISTS already_applied TINYINT(1) NOT NULL DEFAULT 0',
'ALTER TABLE projects ADD COLUMN IF NOT EXISTS applied_refs TEXT NULL',
'ALTER TABLE projects ADD COLUMN IF NOT EXISTS applied_check_at DATETIME NULL',
];
for (const statement of columnMigrations) {
await sequelize.query(statement);
}
// Ensure every supported portal exists. Per-row findOrCreate so newly added
// portals appear after upgrade without manual SQL.
const defaultPortals = [
@@ -56,7 +67,9 @@ const initScannerDatabase = async () => {
{ keyName: 'smtp_from', value: 'scanner@perinco.de' },
{ keyName: 'notification_email_recipient', value: '' },
{ keyName: 'match_threshold', value: '70' },
{ keyName: 'display_threshold', value: '50' }
{ keyName: 'display_threshold', value: '50' },
{ keyName: 'applied_check_active', value: 'true' },
{ keyName: 'applied_check_days', value: '14' }
];
for (const s of defaultSettings) {
+188
View File
@@ -0,0 +1,188 @@
import { OpenAI } from 'openai';
import { pool } from '../../db.js';
import Setting from '../models/Setting.js';
/*
* Abgleich "bereits beworben?".
*
* Findet der Scanner ein Projekt, prueft dieser Dienst, ob es in der Akquise
* oder bei den Opportunities des CRM schon auftaucht - dieselbe Ausschreibung
* kommt oft ueber mehrere Boersen oder Vermittler, mit anderem Titel und
* anderem Link. Ein reiner Textvergleich findet das nicht, deshalb entscheidet
* ein Sprachmodell.
*
* Geprueft wird einmal je Projekt (`applied_check_at`), und nur fuer Projekte
* mit mindestens einem Treffer - alles andere zeigt die Oberflaeche nie an.
*
* Einstellungen (Scanner-Tabelle `settings`):
* applied_check_active 'true' | 'false'
* applied_check_days Zeitraum in Tagen, Vorgabe 14
*/
export const APPLIED_CHECK_MODEL = 'gpt-5.6-luna';
export const APPLIED_CHECK_DEFAULT_DAYS = 14;
const MAX_DAYS = 365;
/** Mehr Kandidaten schicken wir nicht - bei 14 Tagen sind es in der Praxis wenige Dutzend. */
const MAX_CANDIDATES = 300;
const clip = (text, length) => {
const value = String(text ?? '').replace(/\s+/g, ' ').trim();
return value.length > length ? `${value.slice(0, length)}…` : value;
};
/** Link ohne Protokoll, www, Query und Slash am Ende - reicht fuer "gleiche Ausschreibung". */
const normalizeUrl = (url) =>
String(url ?? '')
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/^www\./, '')
.replace(/[?#].*$/, '')
.replace(/\/+$/, '');
export async function appliedCheckSettings() {
const active = (await Setting.getVal('applied_check_active', 'true')) !== 'false';
const parsed = Number.parseInt(await Setting.getVal('applied_check_days', ''), 10);
const days = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, MAX_DAYS) : APPLIED_CHECK_DEFAULT_DAYS;
return { active, days };
}
/** Akquise-Eintraege und Opportunities der letzten `days` Tage, ohne Papierkorb. */
async function loadCandidates(days) {
const [acquisitions] = await pool.query(
`SELECT id, found_at, title, company, provider, sender, consultant, link, description
FROM crm_acquisitions
WHERE deleted_at IS NULL AND found_at >= CURDATE() - INTERVAL ? DAY
ORDER BY found_at DESC
LIMIT ?`,
[days, MAX_CANDIDATES]
);
const [opportunities] = await pool.query(
`SELECT id, created_at, title, company, consultant, stage, description
FROM crm_opportunities
WHERE deleted_at IS NULL AND created_at >= NOW() - INTERVAL ? DAY
ORDER BY created_at DESC
LIMIT ?`,
[days, MAX_CANDIDATES]
);
return [
...acquisitions.map((row) => ({
type: 'acquisition',
id: row.id,
title: row.title,
company: row.company,
provider: row.provider || row.sender,
consultant: row.consultant,
link: row.link,
date: row.found_at,
description: row.description,
})),
...opportunities.map((row) => ({
type: 'opportunity',
id: row.id,
title: row.title,
company: row.company,
consultant: row.consultant,
stage: row.stage,
date: row.created_at,
description: row.description,
})),
];
}
const toRef = (candidate, reason) => ({
type: candidate.type,
id: candidate.id,
title: candidate.title,
consultant: candidate.consultant || null,
reason,
});
async function askModel(project, candidates) {
const apiKey = (await Setting.getVal('openai_api_key')) || process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error('OpenAI API Key is not configured.');
const openai = new OpenAI({ apiKey });
const system = `Du pruefst, ob eine neu gefundene Projektausschreibung bereits im CRM erfasst ist.
Dieselbe Ausschreibung erscheint oft auf mehreren Portalen oder bei mehreren Vermittlern - mit anderem Titel, anderem Link oder gekuerzter Beschreibung.
Ein Kandidat gilt nur dann als dasselbe Projekt, wenn Aufgabe, Rolle und die wesentlichen Rahmendaten (Technologien, Branche/Kunde, Ort, Zeitraum) klar uebereinstimmen.
Aehnliche Rolle bei anderem Kunden oder anderem Inhalt ist KEIN Treffer. Im Zweifel kein Treffer.
Antworte ausschliesslich als JSON:
{ "matches": [ { "type": "acquisition" | "opportunity", "id": 123, "reason": "kurze Begruendung auf Deutsch" } ] }
Keine Treffer: { "matches": [] }`;
const user = `### NEU GEFUNDENES PROJEKT
Titel: ${clip(project.title, 300)}
Portal: ${project.portal}
Firma: ${clip(project.company, 200) || '-'}
Ort: ${clip(project.location, 200) || '-'}
Rate: ${clip(project.rate, 100) || '-'}
Beschreibung: ${clip(project.description, 4000) || '-'}
### KANDIDATEN IM CRM
${JSON.stringify(
candidates.map((c) => ({
type: c.type,
id: c.id,
title: clip(c.title, 200),
company: c.company || undefined,
provider: c.provider || undefined,
stage: c.stage || undefined,
link: c.link || undefined,
description: clip(c.description, 500) || undefined,
}))
)}`;
const response = await openai.chat.completions.create({
model: APPLIED_CHECK_MODEL,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
response_format: { type: 'json_object' },
reasoning_effort: 'low',
});
const parsed = JSON.parse(response.choices[0]?.message?.content || '{}');
return Array.isArray(parsed.matches) ? parsed.matches : [];
}
/**
* Prueft ein Projekt und speichert das Ergebnis am Projekt.
* Wirft bei KI- oder Datenbankfehlern; das Projekt bleibt dann ungeprueft und
* kommt beim naechsten Lauf wieder dran.
*
* @param {import('sequelize').Model} project
* @param {{ days: number }} options
*/
export async function checkProjectAlreadyApplied(project, { days }) {
const candidates = await loadCandidates(days);
let refs = [];
if (candidates.length > 0) {
// Gleicher Link ist eindeutig - dafuer braucht es kein Modell.
const projectUrl = normalizeUrl(project.url);
refs = candidates
.filter((c) => c.link && projectUrl && normalizeUrl(c.link) === projectUrl)
.map((c) => toRef(c, 'Gleicher Link zur Ausschreibung.'));
if (refs.length === 0) {
const byKey = new Map(candidates.map((c) => [`${c.type}:${c.id}`, c]));
for (const hit of await askModel(project, candidates)) {
const candidate = byKey.get(`${hit?.type}:${Number(hit?.id)}`);
// Nur Ids, die wir auch geschickt haben - ein erfundener Treffer zaehlt nicht.
if (candidate) refs.push(toRef(candidate, clip(hit.reason, 300)));
}
}
}
project.alreadyApplied = refs.length > 0;
project.appliedRefs = refs;
project.appliedCheckAt = new Date();
await project.save();
return refs;
}
+26
View File
@@ -1,6 +1,7 @@
import { Agent, Portal, Project, Match, Setting } from '../models/index.js';
import { scrapeFreelancermap, scrapeFreelanceDe, scrapeHays, scrapeEtengo, scrapeSThreePortal, SThreePortals } from './scraper.js';
import { evaluateMatch } from './matchingEngine.js';
import { appliedCheckSettings, checkProjectAlreadyApplied } from './appliedCheck.js';
import nodemailer from 'nodemailer';
// Helper to encrypt/decrypt credentials if needed, or send email notifications
@@ -236,6 +237,13 @@ const executePipeline = async () => {
const thresholdStr = await Setting.getVal('match_threshold', '70');
const matchThreshold = parseInt(thresholdStr, 10) || 70;
// Abgleich "bereits beworben?" - einmal je Lauf gelesen. Scheitert die KI
// einmal (Schluessel, Quote, Netz), bleibt er fuer den Rest des Laufs aus,
// statt jedes Projekt mit demselben Fehler zu versuchen.
const appliedCheck = await appliedCheckSettings();
let appliedCheckAvailable = appliedCheck.active;
let appliedCheckedCount = 0;
for (const scrapedProj of scrapedProjects) {
try {
// Find or create project in DB
@@ -312,6 +320,23 @@ const executePipeline = async () => {
}
}
// Nur Projekte mit Treffer - nur die zeigt die Oberflaeche an.
if (appliedCheckAvailable && !project.appliedCheckAt) {
const hasMatch = await Match.count({ where: { projectId: project.id } });
if (hasMatch > 0) {
try {
const refs = await checkProjectAlreadyApplied(project, appliedCheck);
appliedCheckedCount++;
if (refs.length > 0) {
console.log(`[Abgleich] Projekt [${project.title}] bereits im CRM: ${refs.map((r) => `${r.type}#${r.id}`).join(', ')}`);
}
} catch (checkErr) {
appliedCheckAvailable = false;
console.error(`[Abgleich] fehlgeschlagen, fuer diesen Lauf ausgesetzt: ${checkErr.message}`);
}
}
}
} catch (dbErr) {
console.error('Error inserting project or creating match:', dbErr.message);
}
@@ -320,5 +345,6 @@ const executePipeline = async () => {
console.log(`--- PIPELINE COMPLETED ---`);
console.log(`Stored ${newProjectsStoredCount} new projects.`);
console.log(`Evaluated ${matchesEvaluatedCount} new matches.`);
console.log(`Checked ${appliedCheckedCount} projects against Akquise/Opportunities.`);
return true;
};
+66 -5
View File
@@ -1,13 +1,21 @@
import React from 'react';
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { MatchBadge, MatchStatusBadge, formatDateTime } from './scannerUi';
import type { MatchStatus, ScannerMatch } from '../../lib/scannerApi';
import { useToast } from '../Toast';
import { AlreadyAppliedBadge, MatchBadge, MatchStatusBadge, formatDateTime } from './scannerUi';
import {
scannerApi,
type MatchStatus,
type ScannerMatch,
type ScannerProject,
} from '../../lib/scannerApi';
interface MatchDetailModalProps {
match: ScannerMatch;
onClose: () => void;
onStatusChange: (status: MatchStatus) => void;
onDelete: () => void;
/** Nach "Neu prüfen" - das Projekt kann an mehreren Treffern haengen. */
onProjectChange?: (project: ScannerProject) => void;
/**
* "rejected" ist die Ansicht aus Unpassend: dort gibt es nur
* Zurueckholen und Loeschen, keine Statuskette.
@@ -27,9 +35,32 @@ export const MatchDetailModal: React.FC<MatchDetailModalProps> = ({
onClose,
onStatusChange,
onDelete,
onProjectChange,
variant = 'default',
}) => {
const project = match.Project;
const { addToast } = useToast();
const [project, setProject] = useState(match.Project);
const [isChecking, setIsChecking] = useState(false);
const recheck = async () => {
if (!project) return;
setIsChecking(true);
try {
const updated = await scannerApi.recheckApplied(project.id);
setProject(updated);
onProjectChange?.(updated);
addToast(
updated.alreadyApplied
? 'Projekt ist bereits im CRM erfasst.'
: 'Kein passender Eintrag in Akquise oder Opportunities.',
updated.alreadyApplied ? 'error' : 'success'
);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
} finally {
setIsChecking(false);
}
};
return (
<Modal
@@ -52,7 +83,29 @@ export const MatchDetailModal: React.FC<MatchDetailModalProps> = ({
>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="min-w-0">
<h3 className="border-b border-ink-100 pb-3 text-lg text-ink-900">{project?.title}</h3>
<h3 className="flex flex-wrap items-center gap-2 border-b border-ink-100 pb-3 text-lg text-ink-900">
<span>{project?.title}</span>
{project?.alreadyApplied && <AlreadyAppliedBadge />}
</h3>
{project?.alreadyApplied && (project.appliedRefs?.length ?? 0) > 0 && (
<div className="mt-3 rounded-xl border border-red-200 bg-red-50 p-4">
<p className="text-[11px] font-extrabold uppercase tracking-wider text-red-700">
Schon im CRM erfasst
</p>
<ul className="mt-2 space-y-2 text-sm text-ink-700">
{project.appliedRefs!.map((ref) => (
<li key={`${ref.type}-${ref.id}`}>
<span className="font-bold">
{ref.type === 'acquisition' ? 'Akquise' : 'Opportunity'}: {ref.title}
</span>
{ref.consultant && <span className="text-ink-500"> · {ref.consultant}</span>}
{ref.reason && <p className="text-xs text-ink-500">{ref.reason}</p>}
</li>
))}
</ul>
</div>
)}
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-xs text-ink-400">
<div className="flex gap-1.5">
@@ -173,6 +226,14 @@ export const MatchDetailModal: React.FC<MatchDetailModalProps> = ({
>
Börse öffnen ↗
</a>
<button
onClick={recheck}
disabled={isChecking}
title="Mit Akquise und Opportunities abgleichen"
className="w-full rounded-xl border border-ink-200 py-2.5 text-sm font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700 disabled:opacity-60"
>
{isChecking ? 'Prüfe…' : 'Auf Bewerbung prüfen'}
</button>
{variant === 'default' && (
<button
onClick={() => onStatusChange('rejected')}
@@ -12,6 +12,7 @@ import {
primaryButtonClass,
secondaryButtonClass,
projectTimestamp,
AlreadyAppliedBadge,
} from './scannerUi';
import {
SCANNER_SETTINGS_DEFAULTS,
@@ -629,7 +630,10 @@ export const ScannerDashboard: React.FC = () => {
{match.Agent?.name ?? '—'}
</td>
<td className="max-w-md px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate font-bold text-ink-900">{match.Project?.title}</p>
{match.Project?.alreadyApplied && <AlreadyAppliedBadge />}
</div>
<p className="truncate text-xs text-ink-400">
{match.Project?.location || 'Remote'}
</p>
@@ -663,6 +667,11 @@ export const ScannerDashboard: React.FC = () => {
onClose={() => setSelectedMatch(null)}
onStatusChange={(status) => changeStatus(selectedMatch, status)}
onDelete={() => setMatchToDelete(selectedMatch)}
onProjectChange={(project) =>
setMatches((prev) =>
prev.map((m) => (m.projectId === project.id ? { ...m, Project: project } : m))
)
}
/>
)}
@@ -10,6 +10,7 @@ import {
dangerButtonClass,
formatDateTime,
secondaryButtonClass,
AlreadyAppliedBadge,
} from './scannerUi';
import { scannerApi, type ScannerMatch } from '../../lib/scannerApi';
@@ -108,7 +109,10 @@ export const ScannerRejected: React.FC = () => {
{match.Agent?.name ?? '—'}
</td>
<td className="max-w-md px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate font-bold text-ink-900">{match.Project?.title}</p>
{match.Project?.alreadyApplied && <AlreadyAppliedBadge />}
</div>
<p className="truncate text-xs text-ink-400">
{match.Project?.location || 'Remote'}
</p>
@@ -142,6 +146,11 @@ export const ScannerRejected: React.FC = () => {
onClose={() => setSelectedMatch(null)}
onStatusChange={() => restore(selectedMatch)}
onDelete={() => setMatchToDelete(selectedMatch)}
onProjectChange={(project) =>
setMatches((prev) =>
prev.map((m) => (m.projectId === project.id ? { ...m, Project: project } : m))
)
}
/>
)}
+10
View File
@@ -72,6 +72,16 @@ export const MatchStatusBadge: React.FC<{ status: MatchStatus }> = ({ status })
);
};
/** Markierung aus dem Abgleich mit Akquise und Opportunities. */
export const AlreadyAppliedBadge: React.FC = () => (
<span
title="Dieses Projekt steht schon in der Akquise oder bei den Opportunities."
className="inline-flex shrink-0 items-center rounded-md border border-red-200 bg-red-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-red-700"
>
bereits beworben!
</span>
);
const PORTAL_STATUS_LABELS: Record<PortalStatus, { label: string; className: string; dot: string }> =
{
connected: {
+62 -1
View File
@@ -52,12 +52,16 @@ export const ScannerSettings: React.FC = () => {
const [values, setValues] = useState<ScannerSettingsValues>(SCANNER_SETTINGS_DEFAULTS);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
/** Nur Anzeige - gespeichert werden immer Tage. */
const [periodUnit, setPeriodUnit] = useState<'days' | 'weeks'>('weeks');
const load = async () => {
setIsLoading(true);
try {
const data = await scannerApi.settings();
setValues({ ...SCANNER_SETTINGS_DEFAULTS, ...data });
const merged = { ...SCANNER_SETTINGS_DEFAULTS, ...data };
setValues(merged);
setPeriodUnit(Number(merged.applied_check_days) % 7 === 0 ? 'weeks' : 'days');
} catch (err) {
addToast(
err instanceof Error ? err.message : 'Scanner-Einstellungen nicht erreichbar.',
@@ -106,6 +110,10 @@ export const ScannerSettings: React.FC = () => {
const isScanningActive = values.scanning_active === 'true';
const emailActive = values.email_notifications_active === 'true';
const keyStored = values.openai_api_key === SECRET_MASK;
const appliedCheckActive = values.applied_check_active === 'true';
const periodDays = Math.max(1, Number.parseInt(values.applied_check_days, 10) || 14);
const periodFactor = periodUnit === 'weeks' ? 7 : 1;
const periodValue = Math.max(1, Math.round(periodDays / periodFactor));
return (
<form onSubmit={handleSubmit} className="space-y-5">
@@ -180,6 +188,59 @@ export const ScannerSettings: React.FC = () => {
</div>
</Section>
<Section
title="Abgleich mit Akquise und Opportunities"
description="Jedes neu gefundene Projekt mit Treffer wird mit den Einträgen der Akquise-Liste und den Opportunities verglichen. Ist es dort schon erfasst – auch unter anderem Titel oder von einer anderen Börse –, erscheint es im Dashboard mit „bereits beworben!“. Die KI (gpt-5.6-luna) nutzt den OpenAI-Key unten."
accent={appliedCheckActive ? 'border-l-brand-500' : 'border-l-ink-300'}
>
<Checkbox
checked={appliedCheckActive}
onChange={(checked) => setFlag('applied_check_active', checked)}
label="Abgleich aktiviert"
/>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div className="md:col-span-2">
<label className={labelClass}>Zeitraum zurück</label>
<div className="flex gap-2">
<input
type="number"
min={1}
max={periodUnit === 'weeks' ? 52 : 365}
required
disabled={!appliedCheckActive}
value={periodValue}
onChange={(event) => {
const next = Math.max(1, Number.parseInt(event.target.value, 10) || 1);
set('applied_check_days', String(Math.min(365, next * periodFactor)));
}}
className={`${inputClass} w-28`}
/>
<select
value={periodUnit}
disabled={!appliedCheckActive}
onChange={(event) => {
const unit = event.target.value as 'days' | 'weeks';
setPeriodUnit(unit);
// Der Zeitraum bleibt: "2 Wochen" wird zu "14 Tage". Nach Wochen
// gerundet wird nur, wenn die Tage nicht aufgehen.
if (unit === 'weeks') {
set('applied_check_days', String(Math.min(364, Math.max(1, Math.round(periodDays / 7)) * 7)));
}
}}
className={`${inputClass} w-36`}
>
<option value="days">Tage</option>
<option value="weeks">Wochen</option>
</select>
</div>
<p className="mt-1 text-xs text-ink-400">
Verglichen wird mit Akquise-Einträgen und Opportunities der letzten {periodDays}{' '}
{periodDays === 1 ? 'Tag' : 'Tage'} (Papierkorb ausgenommen).
</p>
</div>
</div>
</Section>
<Section
title="OpenAI"
description="Wird für die semantische Bewertung des Lebenslaufs gegen die Projektbeschreibung gebraucht (gpt-4o-mini)."
+22
View File
@@ -51,6 +51,20 @@ export interface ScannerProject {
*/
created_at?: string;
createdAt?: string;
/** Abgleich mit Akquise und Opportunities: dort schon erfasst. */
alreadyApplied?: boolean;
appliedRefs?: AppliedRef[];
/** null = noch nicht geprueft (kein Treffer bisher, Abgleich aus, KI-Fehler). */
appliedCheckAt?: string | null;
}
/** Eintrag im CRM, der zum gefundenen Projekt passt. */
export interface AppliedRef {
type: 'acquisition' | 'opportunity';
id: number;
title: string;
consultant: string | null;
reason: string;
}
export type MatchStatus = 'new' | 'reviewed' | 'applied' | 'rejected';
@@ -86,6 +100,9 @@ export interface ScannerSettings {
smtp_pass: string;
smtp_from: string;
notification_email_recipient: string;
applied_check_active: string;
/** Zeitraum in Tagen */
applied_check_days: string;
}
export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = {
@@ -102,6 +119,8 @@ export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = {
smtp_pass: '',
smtp_from: 'scanner@perinco.de',
notification_email_recipient: '',
applied_check_active: 'true',
applied_check_days: '14',
};
/** Sentinel des Backends fuer gesetzte Geheimnisse. */
@@ -186,6 +205,9 @@ export const scannerApi = {
removeMatch: (id: number) =>
apiRequest<{ message: string }>(`/scanner/matches/${id}`, { method: 'DELETE' }),
recheckApplied: (projectId: number) =>
apiRequest<ScannerProject>(`/scanner/projects/${projectId}/applied-check`, { method: 'POST' }),
// ---------- Einstellungen ----------
settings: () => apiRequest<Partial<ScannerSettings>>('/scanner/settings'),
saveSettings: (input: Partial<ScannerSettings>) =>