Scanner: Stellenanzeigen aus Jobbörsen (DACH, max. 21 Tage)
Neuer Menüpunkt Scanner mit Dashboard und Suchagenten, Zeitplan und API-Schlüssel unter Einstellungen -> Scanner. - Quellen: Agentur für Arbeit (API), StepStone, karriere.at, freelancermap (HTML) sowie Adzuna und Jooble (kostenlose Schlüssel) - Quote = gefundene Schlagwörter, Titel zählt doppelt; keine KI - Abgleich mit icon_companies, bekannte Firmen oben mit Abzeichen - Zeitplan Vorgabe stündlich Mo-Fr 06-20 Uhr, einstellbar - Sperre gegen parallele Läufe wie im perinco-Scanner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6fe5b18c11
commit
1b82f580db
@@ -0,0 +1,332 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
scannerApi,
|
||||
type ScannerAgent,
|
||||
type ScannerAgentInput,
|
||||
type ScannerOverview,
|
||||
type ScannerSourceId,
|
||||
} from '../../lib/api';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { useToast } from '../Toast';
|
||||
import { ConfirmDialog, Modal } from '../ui/Modal';
|
||||
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||
import { SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
|
||||
|
||||
/*
|
||||
* Scanner -> Suchagenten.
|
||||
*
|
||||
* Ein Agent = Schlagwörter + Börsen + Ausschlussbegriffe. Jedes Schlagwort
|
||||
* wird in jeder gewählten Börse einzeln gesucht; eine Anzeige, die einen
|
||||
* Ausschlussbegriff enthält, wird gar nicht erst gespeichert.
|
||||
*/
|
||||
|
||||
const EMPTY: ScannerAgentInput = {
|
||||
name: '',
|
||||
keywords: [],
|
||||
sources: ['ba', 'stepstone', 'karriere'],
|
||||
excludes: [],
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
export const ScannerAgents: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const { isAdmin } = useAuth();
|
||||
const { goToSettings } = useNavigation();
|
||||
const [agents, setAgents] = useState<ScannerAgent[] | null>(null);
|
||||
const [sources, setSources] = useState<ScannerOverview['sources']>([]);
|
||||
const [editing, setEditing] = useState<{ id: number | null; value: ScannerAgentInput } | null>(null);
|
||||
const [removing, setRemoving] = useState<ScannerAgent | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [{ agents: list }, overview] = await Promise.all([scannerApi.agents(), scannerApi.overview()]);
|
||||
setAgents(list);
|
||||
setSources(overview.sources);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const ready = (id: ScannerSourceId) => sources.find((item) => item.id === id)?.ready ?? true;
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (editing.id) await scannerApi.updateAgent(editing.id, editing.value);
|
||||
else await scannerApi.createAgent(editing.value);
|
||||
toast('Suchagent gespeichert – er sucht ab dem nächsten Lauf mit.', 'success');
|
||||
setEditing(null);
|
||||
void load();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async (agent: ScannerAgent) => {
|
||||
try {
|
||||
await scannerApi.updateAgent(agent.id, { ...agent, is_active: !agent.is_active });
|
||||
void load();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!removing) return;
|
||||
try {
|
||||
await scannerApi.removeAgent(removing.id);
|
||||
toast('Suchagent gelöscht.', 'success');
|
||||
setRemoving(null);
|
||||
void load();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const value = editing?.value;
|
||||
const patch = (changes: Partial<ScannerAgentInput>) =>
|
||||
setEditing((current) => (current ? { ...current, value: { ...current.value, ...changes } } : current));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="max-w-3xl text-sm text-ink-500">
|
||||
Jeder Agent sucht seine Schlagwörter in den gewählten Börsen. Je mehr Schlagwörter in einer Anzeige
|
||||
vorkommen, desto höher die Quote – ein Treffer im Titel zählt doppelt. Anzeigen mit einem
|
||||
Ausschlussbegriff fallen raus.
|
||||
</p>
|
||||
<PrimaryButton type="button" onClick={() => setEditing({ id: null, value: EMPTY })}>
|
||||
+ Neuer Suchagent
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{agents === null ? (
|
||||
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-ink-200 py-16 text-center text-sm text-ink-400">
|
||||
Noch keine Suchagenten.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{agents.map((agent) => (
|
||||
<article
|
||||
key={agent.id}
|
||||
className={`rounded-xl border border-ink-200 bg-surface p-5 ${agent.is_active ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-ink-900">{agent.name}</h3>
|
||||
<p className="text-xs text-ink-400">
|
||||
{agent.is_active ? 'aktiv' : 'pausiert'} · {agent.matches} Treffer
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<SecondaryButton type="button" className="px-3 py-1 text-xs" onClick={() => void toggle(agent)}>
|
||||
{agent.is_active ? 'Pausieren' : 'Aktivieren'}
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
className="px-3 py-1 text-xs"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
id: agent.id,
|
||||
value: {
|
||||
name: agent.name,
|
||||
keywords: agent.keywords,
|
||||
sources: agent.sources,
|
||||
excludes: agent.excludes,
|
||||
is_active: agent.is_active,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Bearbeiten
|
||||
</SecondaryButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoving(agent)}
|
||||
className="rounded-lg px-2 text-xs text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row label="Schlagwörter">
|
||||
{agent.keywords.map((term) => (
|
||||
<span key={term} className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs text-brand-700">
|
||||
{term}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
<Row label="Börsen">
|
||||
{agent.sources.map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
title={ready(id) ? undefined : 'Schlüssel fehlt – Börse wird übersprungen'}
|
||||
className={`rounded-full border px-2 py-0.5 text-xs ${
|
||||
ready(id) ? 'border-ink-200 bg-ink-50 text-ink-600' : 'border-amber-200 bg-amber-50 text-amber-700'
|
||||
}`}
|
||||
>
|
||||
{SOURCE_LABELS[id] ?? id}
|
||||
{!ready(id) && ' ⚠'}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
{agent.excludes.length > 0 && (
|
||||
<Row label="Ausschluss">
|
||||
{agent.excludes.map((term) => (
|
||||
<span key={term} className="rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs text-red-700">
|
||||
{term}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={Boolean(editing)}
|
||||
onClose={() => setEditing(null)}
|
||||
size="md"
|
||||
title={editing?.id ? 'Suchagent bearbeiten' : 'Neuer Suchagent'}
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton type="button" onClick={() => setEditing(null)}>
|
||||
Abbrechen
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={busy || !value?.name.trim() || !value?.keywords.length || !value?.sources.length}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
Speichern
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{value && (
|
||||
<div className="space-y-5">
|
||||
<Field label="Name" hint="Z. B. die Rolle oder der Berater, für den gesucht wird.">
|
||||
<input
|
||||
value={value.name}
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
placeholder="Interim CFO"
|
||||
className={inputClass}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Schlagwörter" hint="Enter oder Komma trennt. Jedes Schlagwort ist eine eigene Suche je Börse.">
|
||||
<TermInput
|
||||
value={value.keywords}
|
||||
onChange={(keywords) => patch({ keywords })}
|
||||
placeholder="z. B. Interim CFO, Finanzleiter, SAP FI"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert.">
|
||||
<TermInput
|
||||
value={value.excludes}
|
||||
onChange={(excludes) => patch({ excludes })}
|
||||
placeholder="z. B. Praktikum, Werkstudent, Ausbildung"
|
||||
tone="red"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<p className="field-label mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-500">Börsen</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{SOURCE_ORDER.map((id) => {
|
||||
const info = sources.find((item) => item.id === id);
|
||||
const checked = value.sources.includes(id);
|
||||
return (
|
||||
<label
|
||||
key={id}
|
||||
className={`flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
checked ? 'border-brand-400 bg-brand-50' : 'border-ink-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
className="mt-0.5"
|
||||
onChange={() =>
|
||||
patch({
|
||||
sources: checked ? value.sources.filter((item) => item !== id) : [...value.sources, id],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium text-ink-900">{SOURCE_LABELS[id]}</span>
|
||||
<span className="block text-xs text-ink-400">
|
||||
{info?.countries}
|
||||
{info && !info.ready && ' · Schlüssel fehlt'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sources.some((item) => !item.ready) && (
|
||||
<p className="mt-2 text-xs text-ink-400">
|
||||
Adzuna und Jooble brauchen einen kostenlosen API-Schlüssel
|
||||
{isAdmin ? (
|
||||
<>
|
||||
{' '}
|
||||
–{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-brand-600 hover:underline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
goToSettings('scanner');
|
||||
}}
|
||||
>
|
||||
Einstellungen → Scanner
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
' (Administrator: Einstellungen → Scanner)'
|
||||
)}
|
||||
. Ohne Schlüssel werden sie übersprungen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-ink-700">
|
||||
<input type="checkbox" checked={value.is_active} onChange={(event) => patch({ is_active: event.target.checked })} />
|
||||
aktiv
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removing)}
|
||||
title="Suchagent löschen?"
|
||||
message={`„${removing?.name}" und seine ${removing?.matches ?? 0} Treffer werden gelöscht.`}
|
||||
onConfirm={() => void remove()}
|
||||
onCancel={() => setRemoving(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-ink-400">{label}</p>
|
||||
<div className="flex flex-wrap gap-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,548 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
scannerApi,
|
||||
type ScannerAgent,
|
||||
type ScannerMatch,
|
||||
type ScannerMatchStatus,
|
||||
type ScannerOverview,
|
||||
} from '../../lib/api';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { useToast } from '../Toast';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||
import {
|
||||
SOURCE_LABELS,
|
||||
SOURCE_ORDER,
|
||||
STATUS_LABELS,
|
||||
ScoreBox,
|
||||
StatusBadge,
|
||||
WEEKDAY_LABELS,
|
||||
formatAge,
|
||||
formatDateTime,
|
||||
} from './scannerUi';
|
||||
|
||||
/*
|
||||
* Scanner -> Dashboard.
|
||||
*
|
||||
* Kennzahlen, Zustand der Börsen und die Trefferliste. Vorbild ist das
|
||||
* Dashboard des perinco-Scanners; statt Lebenslauf-Abgleich zeigt die Quote,
|
||||
* wie viele Schlagwörter des Agenten in der Anzeige stehen.
|
||||
*
|
||||
* Treffer bei Unternehmen, die schon im CRM stehen, stehen oben und tragen
|
||||
* ein Abzeichen - das ist der eigentliche Wert: eine offene Stelle bei einem
|
||||
* Kunden oder bekannten Unternehmen ist ein Anlass anzurufen.
|
||||
*/
|
||||
|
||||
const PERIODS = [
|
||||
{ value: 24, label: 'letzte 24 Stunden' },
|
||||
{ value: 72, label: 'letzte 3 Tage' },
|
||||
{ value: 168, label: 'letzte 7 Tage' },
|
||||
{ value: 504, label: 'letzte 21 Tage' },
|
||||
{ value: 0, label: 'alle' },
|
||||
];
|
||||
|
||||
const POLL_MS = 30_000;
|
||||
const POLL_RUNNING_MS = 5_000;
|
||||
|
||||
export const ScannerDashboard: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const { isAdmin } = useAuth();
|
||||
const { open, goToSettings } = useNavigation();
|
||||
|
||||
const [overview, setOverview] = useState<ScannerOverview | null>(null);
|
||||
const [agents, setAgents] = useState<ScannerAgent[]>([]);
|
||||
const [matches, setMatches] = useState<ScannerMatch[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [detail, setDetail] = useState<ScannerMatch | null>(null);
|
||||
|
||||
const [hours, setHours] = useState(168);
|
||||
const [agentId, setAgentId] = useState('');
|
||||
const [source, setSource] = useState('');
|
||||
const [minScore, setMinScore] = useState(0);
|
||||
const [knownOnly, setKnownOnly] = useState(false);
|
||||
const [showDiscarded, setShowDiscarded] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<'known' | 'score' | 'published'>('known');
|
||||
|
||||
const wasRunning = useRef(false);
|
||||
|
||||
const loadMatches = useCallback(async () => {
|
||||
try {
|
||||
const data = await scannerApi.matches({
|
||||
hours: hours || undefined,
|
||||
agentId: agentId || undefined,
|
||||
source: source || undefined,
|
||||
minScore: minScore || undefined,
|
||||
known: knownOnly ? 1 : undefined,
|
||||
status: showDiscarded ? 'alle' : undefined,
|
||||
q: search.trim() || undefined,
|
||||
sort,
|
||||
});
|
||||
setMatches(data.matches);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error');
|
||||
}
|
||||
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, sort, toast]);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
const data = await scannerApi.overview();
|
||||
setOverview(data);
|
||||
// Lauf gerade fertig geworden -> Liste auffrischen.
|
||||
if (wasRunning.current && !data.status.running) {
|
||||
const run = data.lastRun;
|
||||
toast(
|
||||
run ? `Suchlauf fertig: ${run.jobs_found} Anzeigen, ${run.matches_new} neue Treffer.` : 'Suchlauf fertig.',
|
||||
'success'
|
||||
);
|
||||
void loadMatches();
|
||||
}
|
||||
wasRunning.current = data.status.running;
|
||||
} catch {
|
||||
/* Übersicht ist Beiwerk - die Liste meldet eigene Fehler. */
|
||||
}
|
||||
}, [loadMatches, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
scannerApi
|
||||
.agents()
|
||||
.then((data) => setAgents(data.agents))
|
||||
.catch(() => undefined);
|
||||
}, [loadOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => void loadMatches(), search ? 300 : 0);
|
||||
return () => clearTimeout(handle);
|
||||
}, [loadMatches, search]);
|
||||
|
||||
const running = overview?.status.running ?? false;
|
||||
useEffect(() => {
|
||||
const handle = setInterval(() => void loadOverview(), running ? POLL_RUNNING_MS : POLL_MS);
|
||||
return () => clearInterval(handle);
|
||||
}, [loadOverview, running]);
|
||||
|
||||
const startScan = async () => {
|
||||
try {
|
||||
await scannerApi.scan();
|
||||
wasRunning.current = true;
|
||||
toast('Suchlauf gestartet – das dauert ein paar Minuten.', 'success');
|
||||
void loadOverview();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Start fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const setStatus = async (match: ScannerMatch, status: ScannerMatchStatus) => {
|
||||
try {
|
||||
await scannerApi.setStatus(match.id, status);
|
||||
setMatches((list) => list?.map((item) => (item.id === match.id ? { ...item, status } : item)) ?? null);
|
||||
setDetail((current) => (current?.id === match.id ? { ...current, status } : current));
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Status nicht gespeichert.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = (match: ScannerMatch) => {
|
||||
setDetail(match);
|
||||
if (match.status === 'neu') void setStatus(match, 'gesehen');
|
||||
};
|
||||
|
||||
const readySources = overview?.sources.filter((item) => item.ready).length ?? 0;
|
||||
const failing = overview?.sources.filter((item) => item.last && !item.last.ok) ?? [];
|
||||
|
||||
const scheduleText = useMemo(() => {
|
||||
if (!overview) return '';
|
||||
const s = overview.schedule;
|
||||
if (!s.active) return 'Automatische Suche aus';
|
||||
const days = s.days.map((d) => WEEKDAY_LABELS[d - 1]).join(', ');
|
||||
const every = s.interval % 60 === 0 ? `${s.interval / 60 === 1 ? 'stündlich' : `alle ${s.interval / 60} h`}` : `alle ${s.interval} min`;
|
||||
return `${every} · ${days} · ${s.from}–${s.to} Uhr`;
|
||||
}, [overview]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Kopf */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-ink-500">
|
||||
Offene Stellen aus Jobbörsen in DACH, höchstens {overview?.schedule.maxAge ?? 21} Tage alt.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded border px-2 py-0.5 font-bold uppercase tracking-wide ${
|
||||
overview?.schedule.active
|
||||
? 'border-emerald-300 bg-emerald-50 text-emerald-700'
|
||||
: 'border-ink-200 bg-ink-50 text-ink-500'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${overview?.schedule.active ? 'bg-emerald-500' : 'bg-ink-300'}`} />
|
||||
{overview?.schedule.active ? 'Automatische Suche aktiv' : 'Automatische Suche aus'}
|
||||
</span>
|
||||
<span className="text-ink-500">{overview?.schedule.active ? scheduleText : ''}</span>
|
||||
{overview?.lastScanAt && (
|
||||
<span className="text-ink-400">· letzter Lauf {formatDateTime(overview.lastScanAt)}</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => goToSettings('scanner')} className="text-brand-600 hover:underline">
|
||||
Zeitplan ändern
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PrimaryButton type="button" onClick={() => void startScan()} disabled={running} className="flex items-center gap-2">
|
||||
<svg viewBox="0 0 24 24" className={`h-4 w-4 ${running ? 'animate-spin' : ''}`} fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8Zm8 8a8 8 0 0 1-15.4 3H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2Z" />
|
||||
</svg>
|
||||
{running ? 'Suche läuft …' : 'Jetzt scannen'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* Kennzahlen */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Kpi
|
||||
value={overview?.stats.agents ?? '–'}
|
||||
label="Aktive Agenten"
|
||||
icon="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9v-1c0-3 3-5 7-5s7 2 7 5v1H5Z"
|
||||
/>
|
||||
<Kpi
|
||||
value={overview ? `${readySources} / ${overview.sources.length}` : '–'}
|
||||
label="Börsen bereit"
|
||||
icon="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm6.9 6h-3a15 15 0 0 0-1.3-3.9A8 8 0 0 1 18.9 8ZM12 4c.8 1.2 1.5 2.5 1.9 4h-3.8c.4-1.5 1.1-2.8 1.9-4ZM4.3 14a8 8 0 0 1 0-4h3.4a17 17 0 0 0 0 4H4.3Zm.8 2h3a15 15 0 0 0 1.3 3.9A8 8 0 0 1 5.1 16Zm3-8h-3a8 8 0 0 1 4.3-3.9C8.9 5.3 8.4 6.6 8.1 8ZM12 20c-.8-1.2-1.5-2.5-1.9-4h3.8c-.4 1.5-1.1 2.8-1.9 4Zm2.3-6H9.7a15 15 0 0 1 0-4h4.6a15 15 0 0 1 0 4Zm.3 5.9c.6-1.2 1-2.5 1.3-3.9h3a8 8 0 0 1-4.3 3.9Zm1.7-5.9a17 17 0 0 0 0-4h3.4a8 8 0 0 1 0 4h-3.4Z"
|
||||
/>
|
||||
<Kpi
|
||||
value={overview?.stats.jobs ?? '–'}
|
||||
label="Gefundene Anzeigen"
|
||||
icon="M4 4h16v4H4V4Zm0 6h16v10H4V10Zm3 3v2h10v-2H7Z"
|
||||
/>
|
||||
<Kpi
|
||||
value={overview?.stats.matches ?? '–'}
|
||||
label={`Treffer · ${overview?.stats.known ?? 0} bei bekannten Firmen`}
|
||||
icon="M12 2 3 7v10l9 5 9-5V7l-9-5Zm-1 14-4-4 1.4-1.4 2.6 2.6 5.6-5.6L18 9l-7 7Z"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{failing.length > 0 && (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800">
|
||||
Im letzten Lauf ohne Ergebnis: {failing.map((item) => item.label).join(', ')} –{' '}
|
||||
{failing[0].last?.errors[0] ?? 'unbekannter Fehler'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Trefferliste */}
|
||||
<section className="rounded-xl border border-ink-200 bg-surface">
|
||||
<div className="flex flex-wrap items-end gap-3 border-b border-ink-200 p-4">
|
||||
<h2 className="mr-auto text-sm font-bold uppercase tracking-wider text-ink-900">Stellen-Treffer</h2>
|
||||
|
||||
<Filter label="Zeitraum">
|
||||
<select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}>
|
||||
{PERIODS.map((period) => (
|
||||
<option key={period.value} value={period.value}>
|
||||
{period.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Agent">
|
||||
<select value={agentId} onChange={(event) => setAgentId(event.target.value)} className={inputClass}>
|
||||
<option value="">alle</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Börse">
|
||||
<select value={source} onChange={(event) => setSource(event.target.value)} className={inputClass}>
|
||||
<option value="">alle</option>
|
||||
{SOURCE_ORDER.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{SOURCE_LABELS[id]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Ab %">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={minScore}
|
||||
onChange={(event) => setMinScore(Number(event.target.value) || 0)}
|
||||
className={`${inputClass} w-20`}
|
||||
/>
|
||||
</Filter>
|
||||
<Filter label="Sortierung">
|
||||
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className={inputClass}>
|
||||
<option value="known">bekannte Firmen zuerst</option>
|
||||
<option value="score">beste Quote</option>
|
||||
<option value="published">neueste Anzeige</option>
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Suche">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Titel, Firma, Ort"
|
||||
className={`${inputClass} w-44`}
|
||||
/>
|
||||
</Filter>
|
||||
<div className="flex flex-col gap-1 pb-1 text-sm text-ink-600">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={knownOnly} onChange={(event) => setKnownOnly(event.target.checked)} />
|
||||
nur bekannte Firmen
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={showDiscarded} onChange={(event) => setShowDiscarded(event.target.checked)} />
|
||||
verworfene zeigen
|
||||
</label>
|
||||
</div>
|
||||
<span className="pb-2 text-xs text-ink-400">
|
||||
{matches?.length ?? 0} / {total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{matches === null ? (
|
||||
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
|
||||
) : matches.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-ink-400">
|
||||
{agents.length === 0 ? (
|
||||
<>Noch keine Suchagenten angelegt – unter <em>Scanner → Suchagenten</em> geht es los.</>
|
||||
) : (
|
||||
'Keine Treffer für diese Auswahl.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-[11px] font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-4 py-3">Match</th>
|
||||
<th className="px-4 py-3">Agent</th>
|
||||
<th className="px-4 py-3">Stelle</th>
|
||||
<th className="px-4 py-3">Unternehmen</th>
|
||||
<th className="px-4 py-3">Börse</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Gefunden am</th>
|
||||
<th className="px-4 py-3 text-right">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matches.map((match) => (
|
||||
<tr
|
||||
key={match.id}
|
||||
className={`border-b border-ink-100 last:border-0 hover:bg-ink-50 ${
|
||||
match.known ? 'bg-brand-50/40' : ''
|
||||
} ${match.status === 'verworfen' ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<ScoreBox score={match.score} />
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-ink-900">{match.agent_name}</td>
|
||||
<td className="max-w-md px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDetail(match)}
|
||||
className="text-left font-semibold text-ink-900 hover:text-brand-700"
|
||||
>
|
||||
{match.title}
|
||||
</button>
|
||||
<p className="mt-0.5 text-xs text-ink-500">
|
||||
{[match.location, match.country, match.employment_type].filter(Boolean).join(' · ') || '–'}
|
||||
<span className="text-ink-400"> · {formatAge(match.published_at)}</span>
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-ink-800">{match.company ?? '–'}</span>
|
||||
{match.known && match.company_id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open('company', match.company_id!)}
|
||||
title={`Im CRM: ${match.company_name}`}
|
||||
className={`ml-2 inline-flex rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
||||
match.is_customer
|
||||
? 'border-emerald-300 bg-emerald-50 text-emerald-700'
|
||||
: 'border-brand-300 bg-brand-50 text-brand-700'
|
||||
}`}
|
||||
>
|
||||
{match.is_customer ? 'Kunde' : 'Bekannt'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-600">{SOURCE_LABELS[match.source] ?? match.source}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={match.status} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-ink-500">{formatDateTime(match.created_at)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDetail(match)}
|
||||
className="rounded-md border border-ink-200 px-3 py-1 text-xs font-semibold text-ink-700 hover:bg-ink-100"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<MatchDetail
|
||||
match={detail}
|
||||
onClose={() => setDetail(null)}
|
||||
onStatus={(status) => detail && void setStatus(detail, status)}
|
||||
onOpenCompany={(id) => {
|
||||
setDetail(null);
|
||||
open('company', id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Kpi: React.FC<{ value: React.ReactNode; label: string; icon: string }> = ({ value, label, icon }) => (
|
||||
<div className="flex items-center gap-4 rounded-xl border border-ink-200 bg-surface p-5">
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
|
||||
<path d={icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-2xl font-bold tabular-nums text-ink-900">{value}</p>
|
||||
<p className="truncate text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Filter: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
|
||||
/** Schlagwörter im Text hervorheben. */
|
||||
function highlight(text: string, terms: string[]): React.ReactNode {
|
||||
if (!text || terms.length === 0) return text;
|
||||
const pattern = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
|
||||
return text.split(pattern).map((part, index) =>
|
||||
index % 2 === 1 ? (
|
||||
<mark key={index} className="rounded bg-brand-100 px-0.5 text-brand-800">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const MatchDetail: React.FC<{
|
||||
match: ScannerMatch | null;
|
||||
onClose: () => void;
|
||||
onStatus: (status: ScannerMatchStatus) => void;
|
||||
onOpenCompany: (id: number) => void;
|
||||
}> = ({ match, onClose, onStatus, onOpenCompany }) => (
|
||||
<Modal
|
||||
open={Boolean(match)}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={match?.title ?? ''}
|
||||
subtitle={
|
||||
match
|
||||
? [match.company, match.location, match.country, SOURCE_LABELS[match.source]].filter(Boolean).join(' · ')
|
||||
: undefined
|
||||
}
|
||||
footer={
|
||||
match && (
|
||||
<>
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
Schließen
|
||||
</SecondaryButton>
|
||||
<a
|
||||
href={match.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
|
||||
>
|
||||
Anzeige öffnen ↗
|
||||
</a>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{match && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ScoreBox score={match.score} />
|
||||
<div className="text-sm text-ink-600">
|
||||
Agent <strong className="text-ink-900">{match.agent_name}</strong> · veröffentlicht{' '}
|
||||
{formatAge(match.published_at)} · gefunden {formatDateTime(match.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Gefundene Schlagwörter</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{match.hits.map((hit) => (
|
||||
<span key={hit} className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
|
||||
{hit}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{match.known && match.company_id && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-brand-200 bg-brand-50 px-4 py-3 text-sm">
|
||||
<span className="text-brand-800">
|
||||
<strong>{match.company_name}</strong> steht bereits im CRM
|
||||
{match.is_customer ? ' – als Kunde.' : '.'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCompany(match.company_id!)}
|
||||
className="font-semibold text-brand-700 hover:underline"
|
||||
>
|
||||
Unternehmen öffnen →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Auszug</p>
|
||||
<p className="whitespace-pre-line text-sm leading-relaxed text-ink-700">
|
||||
{match.description ? highlight(match.description, match.hits) : 'Die Börse liefert in der Trefferliste keinen Text – Details in der Anzeige.'}
|
||||
</p>
|
||||
{match.employment_type && <p className="mt-2 text-xs text-ink-500">Art: {match.employment_type}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Status</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(STATUS_LABELS) as ScannerMatchStatus[]).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => onStatus(status)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm ${
|
||||
match.status === status
|
||||
? 'border-brand-500 bg-brand-50 font-semibold text-brand-700'
|
||||
: 'border-ink-200 text-ink-600 hover:bg-ink-50'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import type { ScannerMatchStatus, ScannerSourceId } from '../../lib/api';
|
||||
|
||||
/* Gemeinsame Beschriftungen und Bausteine von Dashboard und Suchagenten. */
|
||||
|
||||
export const SOURCE_LABELS: Record<ScannerSourceId, string> = {
|
||||
ba: 'Agentur für Arbeit',
|
||||
stepstone: 'StepStone',
|
||||
karriere: 'karriere.at',
|
||||
adzuna: 'Adzuna',
|
||||
jooble: 'Jooble',
|
||||
freelancermap: 'freelancermap',
|
||||
};
|
||||
|
||||
export const SOURCE_ORDER: ScannerSourceId[] = ['ba', 'stepstone', 'karriere', 'adzuna', 'jooble', 'freelancermap'];
|
||||
|
||||
export const STATUS_LABELS: Record<ScannerMatchStatus, string> = {
|
||||
neu: 'Neu',
|
||||
gesehen: 'Gesehen',
|
||||
in_bearbeitung: 'In Bearbeitung',
|
||||
verworfen: 'Verworfen',
|
||||
};
|
||||
|
||||
export const STATUS_TONES: Record<ScannerMatchStatus, string> = {
|
||||
neu: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||
gesehen: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
in_bearbeitung: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
verworfen: 'border-ink-200 bg-ink-50 text-ink-500',
|
||||
};
|
||||
|
||||
export const StatusBadge: React.FC<{ status: ScannerMatchStatus }> = ({ status }) => (
|
||||
<span
|
||||
className={`inline-flex rounded border px-1.5 py-0.5 text-[11px] font-bold uppercase tracking-wide ${STATUS_TONES[status]}`}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Quadrat mit der Trefferquote - Farbe nach Güte. */
|
||||
export const ScoreBox: React.FC<{ score: number }> = ({ score }) => {
|
||||
const tone =
|
||||
score >= 75
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: score >= 50
|
||||
? 'border-amber-500 bg-amber-50 text-amber-700'
|
||||
: 'border-ink-300 bg-ink-50 text-ink-600';
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-9 w-11 items-center justify-center rounded-md border-2 text-xs font-bold tabular-nums ${tone}`}
|
||||
>
|
||||
{score}%
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const formatDateTime = (value: string | null) => {
|
||||
if (!value) return '–';
|
||||
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
|
||||
if (Number.isNaN(date.getTime())) return '–';
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
/** "vor 3 Tagen" - fürs Alter einer Anzeige genauer als ein Datum. */
|
||||
export const formatAge = (value: string | null) => {
|
||||
if (!value) return 'unbekannt';
|
||||
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
|
||||
const days = Math.floor((Date.now() - date.getTime()) / 86_400_000);
|
||||
if (Number.isNaN(days)) return 'unbekannt';
|
||||
if (days <= 0) return 'heute';
|
||||
if (days === 1) return 'gestern';
|
||||
return `vor ${days} Tagen`;
|
||||
};
|
||||
|
||||
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
/** Eingabe von Begriffen als Chips: Enter oder Komma fügt hinzu. */
|
||||
export const TermInput: React.FC<{
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
tone?: 'brand' | 'red';
|
||||
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
|
||||
const [draft, setDraft] = React.useState('');
|
||||
|
||||
const add = (text: string) => {
|
||||
const terms = text
|
||||
.split(',')
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean)
|
||||
.filter((term) => !value.some((existing) => existing.toLowerCase() === term.toLowerCase()));
|
||||
if (terms.length) onChange([...value, ...terms]);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const chip =
|
||||
tone === 'red' ? 'border-red-200 bg-red-50 text-red-700' : 'border-brand-200 bg-brand-50 text-brand-700';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[42px] flex-wrap items-center gap-1.5 rounded-lg border border-ink-200 bg-surface px-2 py-1.5">
|
||||
{value.map((term) => (
|
||||
<span key={term} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${chip}`}>
|
||||
{term}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value.filter((item) => item !== term))}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
aria-label={`${term} entfernen`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
const text = event.target.value;
|
||||
if (text.includes(',')) add(text);
|
||||
else setDraft(text);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
add(draft);
|
||||
} else if (event.key === 'Backspace' && !draft && value.length) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
onBlur={() => draft.trim() && add(draft)}
|
||||
placeholder={value.length ? '' : placeholder}
|
||||
className="min-w-[140px] flex-1 border-0 bg-transparent px-1 py-0.5 text-sm text-ink-900 placeholder:text-ink-400 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user