dorfteich/apps/web/src/pages/QuotaManager.tsx
Claude Fable 5 8719b0ee1e #168: Formulare — Fehler-Verdrahtung und Namenslücken
Der Field-Baustein verdrahtet Hinweis/Fehler jetzt per aria-describedby
und aria-invalid mit dem Eingabefeld (cloneElement auf das einzelne
Kind; Fragmente bleiben unangetastet) — Screenreader nennen den Fehler
damit auch beim Feld-Fokus. Quota-Typ-Select mit Namen; die leeren
Aktions-/Erledigt-Spaltenköpfe in API-Tokens, Feed-Tokens, Sitzungen
und der Aufgabenübersicht (NodeView UND Server-Renderpfad) tragen
visually-hidden-Beschriftungen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:26:54 +02:00

165 lines
5.3 KiB
TypeScript

import type { QuotaLineView, QuotaSubject, QuotaSubjectView } from '@dorfteich/shared';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError, apiDelete, apiGet, apiPut } from '../lib/api';
/**
* Site-Admin quota override management (issue #58): look up a user or pond, see
* every quota's effective value on the ladder, and set or clear a per-subject
* override — which the QuotaService picks up at the next consumption check. The
* usage column flags subjects currently over their effective limit.
*/
export function QuotaManager(): React.JSX.Element {
const { t } = useTranslation('quotas');
const [type, setType] = useState<QuotaSubject>('user');
const [q, setQ] = useState('');
const [subject, setSubject] = useState<QuotaSubjectView | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const apply = (view: QuotaSubjectView): void => {
setSubject(view);
setDrafts(
Object.fromEntries(
view.lines.map((l) => [l.key, l.override === null ? '' : String(l.override)]),
),
);
};
const find = async (): Promise<void> => {
setError(null);
setSubject(null);
try {
const hit = await apiGet<{ id: string }>(
`/admin/quotas/lookup?type=${type}&q=${encodeURIComponent(q.trim())}`,
);
apply(await apiGet<QuotaSubjectView>(`/admin/quotas/${type}/${hit.id}`));
} catch (err) {
setError(err instanceof ApiError && err.status === 404 ? t('overrides.notFound') : 'error');
}
};
const set = async (key: string): Promise<void> => {
if (!subject) return;
const value = Number(drafts[key]);
if (!Number.isFinite(value) || value < 0) return;
apply(
await apiPut<QuotaSubjectView>(`/admin/quotas/${subject.type}/${subject.id}/${key}`, {
value,
}),
);
};
const clear = async (key: string): Promise<void> => {
if (!subject) return;
apply(await apiDelete<QuotaSubjectView>(`/admin/quotas/${subject.type}/${subject.id}/${key}`));
};
return (
<section className="settings-section quota-manager">
<h2>{t('overrides.title')}</h2>
<div className="quota-manager__lookup">
<select
className="quota-manager__type"
value={type}
aria-label={t('overrides.typeLabel')}
onChange={(e) => setType(e.target.value as QuotaSubject)}
>
<option value="user">{t('overrides.user')}</option>
<option value="pond">{t('overrides.pond')}</option>
</select>
<input
className="quota-manager__query"
value={q}
placeholder={t('overrides.query')}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && void find()}
/>
<button type="button" className="button" onClick={() => void find()}>
{t('overrides.find')}
</button>
</div>
{error && (
<p className="form-banner form-banner--error" role="alert">
{error === 'error' ? t('overrides.notFound') : error}
</p>
)}
{subject && (
<>
<p className="quota-manager__subject">{subject.label}</p>
<table className="table quota-manager__table">
<thead>
<tr>
<th>{t('overrides.key')}</th>
<th>{t('overrides.default')}</th>
<th>{t('overrides.override')}</th>
<th>{t('overrides.effective')}</th>
<th>{t('overrides.usage')}</th>
</tr>
</thead>
<tbody>
{subject.lines.map((line) => (
<QuotaRow
key={line.key}
line={line}
draft={drafts[line.key] ?? ''}
onDraft={(v) => setDrafts((d) => ({ ...d, [line.key]: v }))}
onSet={() => void set(line.key)}
onClear={() => void clear(line.key)}
/>
))}
</tbody>
</table>
</>
)}
</section>
);
}
function QuotaRow({
line,
draft,
onDraft,
onSet,
onClear,
}: {
line: QuotaLineView;
draft: string;
onDraft: (v: string) => void;
onSet: () => void;
onClear: () => void;
}): React.JSX.Element {
const { t } = useTranslation('quotas');
const overQuota = line.usage !== null && line.usage > line.effective;
return (
<tr className={`quota-row${overQuota ? ' quota-row--over' : ''}`} data-key={line.key}>
<td>{t(`keys.${line.key}`)}</td>
<td>{line.instanceDefault}</td>
<td className="quota-row__override">
<input
type="number"
min={0}
className="quota-row__input"
value={draft}
onChange={(e) => onDraft(e.target.value)}
/>
<button type="button" className="linklike" onClick={onSet}>
{t('overrides.set')}
</button>
{line.override !== null && (
<button type="button" className="linklike" onClick={onClear}>
{t('overrides.clear')}
</button>
)}
</td>
<td className="quota-row__effective">{line.effective}</td>
<td className="quota-row__usage">
{line.usage ?? '—'}
{overQuota && <span className="quota-row__flag"> · {t('overrides.overQuota')}</span>}
</td>
</tr>
);
}