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
165 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|