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). Alarm-Wort später ergänzt (Treffer sind eindeutig je Agent und Anzeige).
- Im Dashboard tragen Alarm-Treffer eine rote Glocke; Filter **nur Alarm**. - 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 ## Bekannte Unternehmen
Jede Anzeige wird mit `icon_companies` abgeglichen. Beide Namen werden 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 | | 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) | | PUT | `/agents/:id` | alle | ändern (gleicher Körper) |
| DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit | | 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) | | GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) |
| POST | `/alarms/ack` | alle | `{ ids: [] }` – quittiert für alle Benutzer | | POST | `/alarms/ack` | alle | `{ ids: [] }` – quittiert für alle Benutzer |
| PATCH | `/matches/:id` | alle | `{ status }` | | 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'); if (req.query.alarm === '1') where.push('m.alarm = 1');
const order = /*
req.query.sort === 'score' * Sortierung: `sort` = Spalte, `dir` = asc|desc (Klick auf die Spalten-
? 'm.score DESC, m.created_at DESC' * ueberschrift). Serverseitig, weil die Liste begrenzt ist - im Browser
: req.query.sort === 'published' * sortiert haette man nur den geladenen Ausschnitt geordnet.
? 'j.published_at IS NULL, j.published_at DESC' * Leere Werte (Anzeige ohne Datum, ohne Firma) stehen immer unten.
: req.query.sort === 'oldest' */
? 'j.published_at IS NULL, j.published_at ASC' const dir = req.query.dir === 'asc' ? 'ASC' : 'DESC';
: // bekannte Firmen zuerst, dann neueste const nullsLast = (column) => `${column} IS NULL, ${column} ${dir}`;
'known DESC, m.created_at DESC'; 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 limit = Math.min(Math.max(Number(req.query.limit) || 300, 1), 1000);
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; 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 [knownOnly, setKnownOnly] = useState(false);
const [showDiscarded, setShowDiscarded] = useState(false); const [showDiscarded, setShowDiscarded] = useState(false);
const [search, setSearch] = useState(''); 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 */ /** Alter der Anzeige: alle | bis 21 Tage | älter als 21 Tage */
const [age, setAge] = useState<'' | 'recent' | 'old'>(''); const [age, setAge] = useState<'' | 'recent' | 'old'>('');
const [alarmOnly, setAlarmOnly] = useState(false); const [alarmOnly, setAlarmOnly] = useState(false);
@@ -83,7 +83,8 @@ export const ScannerDashboard: React.FC = () => {
q: search.trim() || undefined, q: search.trim() || undefined,
age: age || undefined, age: age || undefined,
alarm: alarmOnly ? 1 : undefined, alarm: alarmOnly ? 1 : undefined,
sort, sort: sort.key,
dir: sort.dir,
}); });
setMatches(data.matches); setMatches(data.matches);
setTotal(data.total); setTotal(data.total);
@@ -290,11 +291,20 @@ export const ScannerDashboard: React.FC = () => {
/> />
</Filter> </Filter>
<Filter label="Sortierung" className="w-48"> <Filter label="Sortierung" className="w-48">
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className={inputClass}> <select
<option value="known">bekannte Firmen zuerst</option> value={presetOf(sort)}
<option value="score">beste Quote</option> onChange={(event) => {
<option value="published">neueste Anzeige</option> const preset = PRESETS[event.target.value];
<option value="oldest">älteste Anzeige</option> 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> </select>
</Filter> </Filter>
<Filter label="Suche" className="w-44"> <Filter label="Suche" className="w-44">
@@ -339,13 +349,14 @@ export const ScannerDashboard: React.FC = () => {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-ink-200 text-left text-[11px] font-semibold uppercase tracking-wider text-ink-500"> <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> <SortHeader label="Match" column="score" sort={sort} onSort={setSort} />
<th className="px-4 py-3">Agent</th> <SortHeader label="Agent" column="agent" sort={sort} onSort={setSort} />
<th className="px-4 py-3">Stelle</th> <SortHeader label="Stelle" column="title" sort={sort} onSort={setSort} />
<th className="px-4 py-3">Unternehmen</th> <SortHeader label="Unternehmen" column="company" sort={sort} onSort={setSort} />
<th className="px-4 py-3">Börse</th> <SortHeader label="Börse" column="source" sort={sort} onSort={setSort} />
<th className="px-4 py-3">Status</th> <SortHeader label="Veröffentlicht" column="published" sort={sort} onSort={setSort} />
<th className="px-4 py-3">Gefunden am</th> <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> <th className="px-4 py-3 text-right">Aktionen</th>
</tr> </tr>
</thead> </thead>
@@ -374,7 +385,6 @@ export const ScannerDashboard: React.FC = () => {
</button> </button>
<p className="mt-0.5 text-xs text-ink-500"> <p className="mt-0.5 text-xs text-ink-500">
{[match.location, match.country, match.employment_type].filter(Boolean).join(' · ') || '–'} {[match.location, match.country, match.employment_type].filter(Boolean).join(' · ') || '–'}
<span className="text-ink-400"> · {formatAge(match.published_at)}</span>
</p> </p>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -395,6 +405,12 @@ export const ScannerDashboard: React.FC = () => {
)} )}
</td> </td>
<td className="px-4 py-3 text-ink-600">{SOURCE_LABELS[match.source] ?? match.source}</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"> <td className="px-4 py-3">
<StatusBadge status={match.status} /> <StatusBadge status={match.status} />
</td> </td>
@@ -443,6 +459,55 @@ const Kpi: React.FC<{ value: React.ReactNode; label: string; icon: string }> = (
</div> </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 }) => ( const Filter: React.FC<{ label: string; className?: string; children: React.ReactNode }> = ({ label, className = '', children }) => (
<label className={`flex shrink-0 flex-col gap-1 ${className}`}> <label className={`flex shrink-0 flex-col gap-1 ${className}`}>
<span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span> <span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span>