Scanner: Trefferliste per Klick auf die Spaltenüberschrift sortieren

- sort + dir serverseitig für Quote, Agent, Stelle, Unternehmen, Börse,
  Veröffentlicht, Status, Gefunden; leere Werte immer unten
- neue Spalte Veröffentlicht (Alter der Anzeige, Datum im Tooltip)
- Feld Sortierung bleibt als Schnellwahl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 14:18:22 +02:00
co-authored by Claude Opus 5.5
parent 1ad774fdd2
commit 0aa8f17b28
3 changed files with 113 additions and 25 deletions
+11 -1
View File
@@ -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 }` |
+22 -9
View File
@@ -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 ')}` : '';
+80 -15
View File
@@ -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<SortState>({ 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 = () => {
/>
</Filter>
<Filter label="Sortierung" className="w-48">
<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>
<option value="oldest">älteste Anzeige</option>
<select
value={presetOf(sort)}
onChange={(event) => {
const preset = PRESETS[event.target.value];
if (preset) setSort(preset.sort);
}}
className={inputClass}
>
{Object.entries(PRESETS).map(([value, preset]) => (
<option key={value} value={value}>
{preset.label}
</option>
))}
{presetOf(sort) === '' && <option value="">nach Spalte</option>}
</select>
</Filter>
<Filter label="Suche" className="w-44">
@@ -339,13 +349,14 @@ export const ScannerDashboard: React.FC = () => {
<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>
<SortHeader label="Match" column="score" sort={sort} onSort={setSort} />
<SortHeader label="Agent" column="agent" sort={sort} onSort={setSort} />
<SortHeader label="Stelle" column="title" sort={sort} onSort={setSort} />
<SortHeader label="Unternehmen" column="company" sort={sort} onSort={setSort} />
<SortHeader label="Börse" column="source" sort={sort} onSort={setSort} />
<SortHeader label="Veröffentlicht" column="published" sort={sort} onSort={setSort} />
<SortHeader label="Status" column="status" sort={sort} onSort={setSort} />
<SortHeader label="Gefunden am" column="created" sort={sort} onSort={setSort} />
<th className="px-4 py-3 text-right">Aktionen</th>
</tr>
</thead>
@@ -374,7 +385,6 @@ export const ScannerDashboard: React.FC = () => {
</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">
@@ -395,6 +405,12 @@ export const ScannerDashboard: React.FC = () => {
)}
</td>
<td className="px-4 py-3 text-ink-600">{SOURCE_LABELS[match.source] ?? match.source}</td>
<td
className="whitespace-nowrap px-4 py-3 text-ink-500"
title={match.published_at ? formatDateTime(match.published_at) : 'Die Börse nennt kein Datum'}
>
{formatAge(match.published_at)}
</td>
<td className="px-4 py-3">
<StatusBadge status={match.status} />
</td>
@@ -443,6 +459,55 @@ const Kpi: React.FC<{ value: React.ReactNode; label: string; icon: string }> = (
</div>
);
/* ---------------------------------------------------------- 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<string, { label: string; sort: SortState }> = {
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 (
<th className="px-4 py-3" aria-sort={active ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}>
<button
type="button"
onClick={() => onSort(next())}
title="Nach dieser Spalte sortieren"
className={`inline-flex items-center gap-1 uppercase tracking-wider hover:text-ink-900 ${
active ? 'text-brand-700' : ''
}`}
>
{label}
<span className={`text-[10px] ${active ? '' : 'opacity-0'}`} aria-hidden="true">
{active && sort.dir === 'asc' ? '▲' : '▼'}
</span>
</button>
</th>
);
};
const Filter: React.FC<{ label: string; className?: string; children: React.ReactNode }> = ({ label, className = '', children }) => (
<label className={`flex shrink-0 flex-col gap-1 ${className}`}>
<span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span>