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:
co-authored by
Claude Opus 5.5
parent
1ad774fdd2
commit
0aa8f17b28
+11
-1
@@ -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 }` |
|
||||||
|
|||||||
@@ -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 ')}` : '';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user