diff --git a/docs/12-scanner.md b/docs/12-scanner.md index 2a7dfec..0a3b98e 100644 --- a/docs/12-scanner.md +++ b/docs/12-scanner.md @@ -132,6 +132,16 @@ sequenceDiagram Alarm-Wort später ergänzt (Treffer sind eindeutig je Agent und Anzeige). - Im Dashboard tragen Alarm-Treffer eine rote Glocke; Filter **nur Alarm**. +## Sortieren + +Jede Spaltenüberschrift der Trefferliste sortiert per Klick, ein zweiter Klick +dreht die Richtung (▲/▼). Texte beginnen mit A–Z, Quote und Daten mit dem +höchsten bzw. neuesten Wert. Sortiert wird **auf dem Server** – die Liste lädt +höchstens 300 Treffer, im Browser wäre nur dieser Ausschnitt geordnet. Leere +Werte (Anzeige ohne Datum oder Firma) stehen immer unten. Das Feld +*Sortierung* bleibt als Schnellwahl und zeigt „nach Spalte“, wenn eine andere +Spalte gewählt ist. + ## Bekannte Unternehmen Jede Anzeige wird mit `icon_companies` abgeglichen. Beide Namen werden @@ -255,7 +265,7 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`. | POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht | | PUT | `/agents/:id` | alle | ändern (gleicher Körper) | | DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit | -| GET | `/matches` | alle | Filter `hours` (gefunden), `age` (`recent`/`old`, Grenze 21 Tage ab Veröffentlichung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`published`/`oldest`), `limit` | +| GET | `/matches` | alle | Filter `hours` (gefunden), `age` (`recent`/`old`, Grenze 21 Tage ab Veröffentlichung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`agent`/`title`/`company`/`source`/`published`/`status`/`created`; `oldest` = `published` aufsteigend) mit `dir` (`asc`/`desc`), `limit` | | GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) | | POST | `/alarms/ack` | alle | `{ ids: [] }` – quittiert für alle Benutzer | | PATCH | `/matches/:id` | alle | `{ status }` | diff --git a/server/src/routes/scanner.js b/server/src/routes/scanner.js index f3340c8..0cfa5e2 100644 --- a/server/src/routes/scanner.js +++ b/server/src/routes/scanner.js @@ -253,15 +253,28 @@ router.get('/matches', async (req, res, next) => { } if (req.query.alarm === '1') where.push('m.alarm = 1'); - const order = - req.query.sort === 'score' - ? 'm.score DESC, m.created_at DESC' - : req.query.sort === 'published' - ? 'j.published_at IS NULL, j.published_at DESC' - : req.query.sort === 'oldest' - ? 'j.published_at IS NULL, j.published_at ASC' - : // bekannte Firmen zuerst, dann neueste - 'known DESC, m.created_at DESC'; + /* + * Sortierung: `sort` = Spalte, `dir` = asc|desc (Klick auf die Spalten- + * ueberschrift). Serverseitig, weil die Liste begrenzt ist - im Browser + * sortiert haette man nur den geladenen Ausschnitt geordnet. + * Leere Werte (Anzeige ohne Datum, ohne Firma) stehen immer unten. + */ + const dir = req.query.dir === 'asc' ? 'ASC' : 'DESC'; + const nullsLast = (column) => `${column} IS NULL, ${column} ${dir}`; + const SORTS = { + score: `m.score ${dir}`, + agent: `a.name ${dir}`, + title: `j.title ${dir}`, + company: nullsLast('j.company'), + source: `j.source ${dir}`, + status: `FIELD(m.status, 'neu', 'gesehen', 'in_bearbeitung', 'verworfen') ${dir}`, + published: nullsLast('j.published_at'), + created: `m.created_at ${dir}`, + // Vorgabe: bekannte Firmen zuerst, dann neueste Funde + known: `known ${dir}, m.created_at DESC`, + }; + const sortKey = req.query.sort === 'oldest' ? 'published' : String(req.query.sort ?? 'known'); + const order = `${SORTS[sortKey] ?? SORTS.known}, m.id DESC`; const limit = Math.min(Math.max(Number(req.query.limit) || 300, 1), 1000); const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; diff --git a/src/components/scanner/ScannerDashboard.tsx b/src/components/scanner/ScannerDashboard.tsx index b4a236e..4637e9f 100644 --- a/src/components/scanner/ScannerDashboard.tsx +++ b/src/components/scanner/ScannerDashboard.tsx @@ -64,7 +64,7 @@ export const ScannerDashboard: React.FC = () => { const [knownOnly, setKnownOnly] = useState(false); const [showDiscarded, setShowDiscarded] = useState(false); const [search, setSearch] = useState(''); - const [sort, setSort] = useState<'known' | 'score' | 'published' | 'oldest'>('known'); + const [sort, setSort] = useState({ key: 'known', dir: 'desc' }); /** Alter der Anzeige: alle | bis 21 Tage | älter als 21 Tage */ const [age, setAge] = useState<'' | 'recent' | 'old'>(''); const [alarmOnly, setAlarmOnly] = useState(false); @@ -83,7 +83,8 @@ export const ScannerDashboard: React.FC = () => { q: search.trim() || undefined, age: age || undefined, alarm: alarmOnly ? 1 : undefined, - sort, + sort: sort.key, + dir: sort.dir, }); setMatches(data.matches); setTotal(data.total); @@ -290,11 +291,20 @@ export const ScannerDashboard: React.FC = () => { /> - { + const preset = PRESETS[event.target.value]; + if (preset) setSort(preset.sort); + }} + className={inputClass} + > + {Object.entries(PRESETS).map(([value, preset]) => ( + + ))} + {presetOf(sort) === '' && } @@ -339,13 +349,14 @@ export const ScannerDashboard: React.FC = () => { - - - - - - - + + + + + + + + @@ -374,7 +385,6 @@ export const ScannerDashboard: React.FC = () => {

{[match.location, match.country, match.employment_type].filter(Boolean).join(' · ') || '–'} - · {formatAge(match.published_at)}

+ @@ -443,6 +459,55 @@ const Kpi: React.FC<{ value: React.ReactNode; label: string; icon: string }> = ( ); +/* ---------------------------------------------------------- Sortierung */ + +type SortKey = 'known' | 'score' | 'agent' | 'title' | 'company' | 'source' | 'published' | 'status' | 'created'; +type SortState = { key: SortKey; dir: 'asc' | 'desc' }; + +/** Schnellwahl im Feld "Sortierung" - dieselben Werte wie ein Spaltenklick. */ +const PRESETS: Record = { + known: { label: 'bekannte Firmen zuerst', sort: { key: 'known', dir: 'desc' } }, + score: { label: 'beste Quote', sort: { key: 'score', dir: 'desc' } }, + published: { label: 'neueste Anzeige', sort: { key: 'published', dir: 'desc' } }, + oldest: { label: 'älteste Anzeige', sort: { key: 'published', dir: 'asc' } }, +}; + +const presetOf = (sort: SortState) => + Object.entries(PRESETS).find(([, preset]) => preset.sort.key === sort.key && preset.sort.dir === sort.dir)?.[0] ?? ''; + +/** Texte beginnen beim ersten Klick mit A-Z, Zahlen und Daten mit dem größten/neuesten Wert. */ +const TEXT_COLUMNS: SortKey[] = ['agent', 'title', 'company', 'source', 'status']; + +const SortHeader: React.FC<{ + label: string; + column: SortKey; + sort: SortState; + onSort: (sort: SortState) => void; +}> = ({ label, column, sort, onSort }) => { + const active = sort.key === column; + const next = (): SortState => + active + ? { key: column, dir: sort.dir === 'asc' ? 'desc' : 'asc' } + : { key: column, dir: TEXT_COLUMNS.includes(column) ? 'asc' : 'desc' }; + return ( + + ); +}; + const Filter: React.FC<{ label: string; className?: string; children: React.ReactNode }> = ({ label, className = '', children }) => (
MatchAgentStelleUnternehmenBörseStatusGefunden amAktionen
@@ -395,6 +405,12 @@ export const ScannerDashboard: React.FC = () => { )} {SOURCE_LABELS[match.source] ?? match.source} + {formatAge(match.published_at)} + + +