feat: mail phase 3 - sorting (date/from/subject, asc/desc)

- Add sort_by and sort_order query params to list_mails endpoint
- Add deleted_at IS NULL filter to exclude soft-deleted mails
- Frontend: sort state in Mail.tsx, sort header bar in MailList
- Sort dropdown (Datum/Absender/Betreff) + asc/desc toggle
- fetchMails API accepts optional sortBy/sortOrder params
- i18n: 6 new sort keys (de/en)
This commit is contained in:
Agent Zero
2026-07-15 20:17:27 +02:00
parent ed1eec87dc
commit 4e100e9d33
6 changed files with 101 additions and 9 deletions
+50
View File
@@ -22,6 +22,9 @@ export interface MailListProps {
selectedMailIds: Set<string>;
onToggleSelect: (mailId: string) => void;
onSelectAll: () => void;
sortBy?: 'date' | 'from' | 'subject';
sortOrder?: 'asc' | 'desc';
onSortChange?: (sortBy: 'date' | 'from' | 'subject', sortOrder: 'asc' | 'desc') => void;
}
function formatDate(dateStr: string): string {
@@ -45,11 +48,24 @@ export function MailList({
selectedMailIds,
onToggleSelect,
onSelectAll,
sortBy = 'date',
sortOrder = 'desc',
onSortChange,
}: MailListProps) {
const { t } = useTranslation();
const totalPages = Math.ceil(total / pageSize);
const allSelected = mails.length > 0 && mails.every((m) => selectedMailIds.has(m.id));
const handleSortFieldChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newSortBy = e.target.value as 'date' | 'from' | 'subject';
onSortChange?.(newSortBy, sortOrder);
};
const handleSortOrderToggle = () => {
const newOrder = sortOrder === 'asc' ? 'desc' : 'asc';
onSortChange?.(sortBy, newOrder);
};
if (loading) {
return (
<div className="flex items-center justify-center py-12" data-testid="mail-list-loading">
@@ -90,6 +106,40 @@ export function MailList({
</span>
)}
</div>
{/* Sort header */}
{onSortChange && (
<div className="flex items-center gap-2 px-3 py-1.5 md:px-4 border-b border-secondary-200 bg-secondary-50" data-testid="mail-sort-bar">
<label htmlFor="mail-sort-by" className="text-xs text-secondary-600">
{t('mail.sortBy')}:
</label>
<select
id="mail-sort-by"
value={sortBy}
onChange={handleSortFieldChange}
className="text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={t('mail.sortBy')}
data-testid="mail-sort-by"
>
<option value="date">{t('mail.sortDate')}</option>
<option value="from">{t('mail.sortFrom')}</option>
<option value="subject">{t('mail.sortSubject')}</option>
</select>
<button
onClick={handleSortOrderToggle}
className="inline-flex items-center gap-1 text-xs rounded border border-secondary-300 bg-white px-1.5 py-0.5 text-secondary-700 hover:bg-secondary-100 focus:outline-none focus:ring-1 focus:ring-primary-500"
aria-label={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
title={sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}
data-testid="mail-sort-order"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
{sortOrder === 'asc'
? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />}
</svg>
<span>{sortOrder === 'asc' ? t('mail.sortAsc') : t('mail.sortDesc')}</span>
</button>
</div>
)}
<ul className="divide-y divide-secondary-100" role="list">
{mails.map((mail) => (
<li key={mail.id} role="listitem" className={clsx(selectedMailIds.has(mail.id) && 'bg-primary-50')}>