This commit is contained in:
ValueOn AG 2026-04-23 23:09:54 +02:00
parent 208f7b63df
commit fc2cce8732
15 changed files with 506 additions and 77 deletions

View file

@ -46,7 +46,14 @@ import { getApiBaseUrl } from '../config/config';
const api = axios.create({
baseURL: getApiBaseUrl(),
withCredentials: true
withCredentials: true,
// FastAPI expects repeat-style array query params (``?ids=1&ids=2``).
// Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI
// silently drops -- e.g. ``trackerIds`` filters on the Redmine stats
// endpoint never reach the route. Setting ``indexes: null`` switches
// the URLSearchParams visitor to repeat format. Applies globally so
// every endpoint with array query params gets it for free.
paramsSerializer: { indexes: null },
});
// Add a request interceptor to add the auth token, context headers, and log backend IP

View file

@ -853,16 +853,46 @@ export async function fetchChartOfAccounts(
});
}
/**
* Submits a background job that pushes positions to the accounting system and
* polls `/api/jobs/{jobId}` until the job reaches a terminal status. Returns
* the same `{ total, success, skipped, errors, results }` payload that the
* legacy synchronous endpoint used to return -- but does NOT block the user
* while the (potentially long) external accounting calls run in the worker.
*/
export async function syncPositionsToAccounting(
request: ApiRequestFunction,
instanceId: string,
positionIds: string[]
): Promise<{ total: number; success: number; errors: number; results: any[] }> {
return await request({
positionIds: string[],
opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void }
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
const submission = await request({
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
method: 'post',
data: { positionIds }
});
const jobId: string | undefined = submission?.jobId;
if (!jobId) {
throw new Error('Background job could not be started (missing jobId).');
}
const pollMs = opts?.pollMs ?? 1500;
const TERMINAL = new Set(['SUCCESS', 'ERROR', 'CANCELLED']);
while (true) {
const job = await request({ url: `/api/jobs/${jobId}`, method: 'get' });
if (opts?.onProgress) {
opts.onProgress(Number(job?.progress ?? 0), job?.progressMessage ?? null);
}
if (job?.status && TERMINAL.has(job.status)) {
if (job.status === 'SUCCESS' && job.result) {
return job.result;
}
throw new Error(job?.errorMessage || 'Sync-Job fehlgeschlagen');
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
}
export async function fetchSyncStatus(

View file

@ -109,6 +109,53 @@
border-color: var(--primary-color, #f25843);
}
/* --- Multiselect chip group --- */
.chipGroup {
display: inline-flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--border-color, #333);
border-radius: 999px;
background: var(--bg-secondary, #2a2a2a);
color: var(--text-secondary, #888);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.chip:hover {
border-color: var(--primary-color, #f25843);
color: var(--text-primary, #e0e0e0);
}
.chipActive {
background: var(--primary-color, #4A6FA5);
border-color: var(--primary-color, #4A6FA5);
color: #fff;
}
.chipActive:hover {
color: #fff;
filter: brightness(0.95);
}
.chipMeta {
font-size: 0.7rem;
color: var(--text-secondary, #888);
margin-left: 0.25rem;
}
/* --- Sections Grid --- */
.sectionsGrid {

View file

@ -667,6 +667,12 @@ const _Toolbar: React.FC<ToolbarProps> = ({
value={(filterState.filters[filter.key] as string) || ''}
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
/>
) : filter.type === 'multiselect' ? (
<_MultiselectChips
filter={filter}
value={filterState.filters[filter.key]}
onChange={(next) => _handleFilterChange(filter.key, next)}
/>
) : (
<select
className={styles.select}
@ -685,6 +691,73 @@ const _Toolbar: React.FC<ToolbarProps> = ({
);
};
// =============================================================================
// MULTISELECT CHIPS
// Renders ``multiselect`` filters as inline toggle chips so the user can:
// - see at a glance which values are active
// - toggle individual values on/off
// - reset to "all" with the leading "Alle"-chip
// Emits the selection upstream as a ``string[]`` matching ``ReportFilterState``.
// =============================================================================
interface _MultiselectChipsProps {
filter: ReportFilterConfig;
value: string | string[] | undefined;
onChange: (next: string[]) => void;
}
const _MultiselectChips: React.FC<_MultiselectChipsProps> = ({ filter, value, onChange }) => {
const { t } = useLanguage();
const selected: Set<string> = useMemo(() => {
if (Array.isArray(value)) return new Set(value.map(String));
if (typeof value === 'string' && value !== '') return new Set([value]);
return new Set<string>();
}, [value]);
const _toggle = (optValue: string) => {
const next = new Set(selected);
if (next.has(optValue)) next.delete(optValue); else next.add(optValue);
onChange(Array.from(next));
};
const _reset = () => onChange([]);
const allLabel = filter.placeholder || t('Alle');
const totalActive = selected.size;
return (
<div className={styles.chipGroup}>
<button
type="button"
className={`${styles.chip} ${totalActive === 0 ? styles.chipActive : ''}`}
onClick={_reset}
title={allLabel}
>
{allLabel}
</button>
{filter.options?.map(opt => {
const active = selected.has(opt.value);
return (
<button
key={opt.value}
type="button"
className={`${styles.chip} ${active ? styles.chipActive : ''}`}
onClick={() => _toggle(opt.value)}
title={opt.label}
>
{opt.label}
</button>
);
})}
{totalActive > 0 && (
<span className={styles.chipMeta}>
{t('{n} aktiv', { n: String(totalActive) })}
</span>
)}
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================

View file

@ -34,6 +34,9 @@ const _DEFAULT_PRESET: PeriodPreset = { kind: 'ytd' };
function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string {
if (!value) return placeholder;
// "Alle" intentionally skips the range suffix: the sentinel dates
// (1970-2999) would be noise in the trigger.
if (value.preset.kind === 'allTime') return t('Alle');
const range = `${formatIsoDateDe(value.fromDate)} ${formatIsoDateDe(value.toDate)}`;
switch (value.preset.kind) {
case 'ytd': return `${t('Laufendes Jahr')} · ${range}`;

View file

@ -84,9 +84,19 @@ function _shiftBy(d: Date, amount: number, unit: PeriodUnit): Date {
// Preset resolver
// ---------------------------------------------------------------------------
// Sentinel bounds used when the user picked ``Alle`` (no date filter). We keep
// the values *inside* ``PeriodValue`` so downstream code that reads
// ``fromDate``/``toDate`` doesn't break; callers that want to forward "no
// filter" to the backend should check ``preset.kind === 'allTime'`` and drop
// the dates explicitly before building the request.
export const ALL_TIME_FROM = '1970-01-01';
export const ALL_TIME_TO = '2999-12-31';
export function resolvePeriod(preset: PeriodPreset, prevValue?: PeriodValue | null): { fromDate: string; toDate: string } {
const today = todayDate();
switch (preset.kind) {
case 'allTime':
return { fromDate: ALL_TIME_FROM, toDate: ALL_TIME_TO };
case 'ytd':
return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) };
case 'lastYear': {
@ -164,6 +174,20 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints
return true;
}
// Clamp an ISO date to the direction/min/max window defined by ``cfg``. Used
// for ``<input type="date">`` ``min``/``max`` attributes so the browser
// refuses invalid years instead of us silently falling back to the default
// preset afterwards.
export function clampIsoDate(iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined {
const today = toIsoDate(todayDate());
let lo: string | undefined = cfg.minDate;
let hi: string | undefined = cfg.maxDate;
if (cfg.direction === 'past') hi = hi && hi < today ? hi : today;
if (cfg.direction === 'future') lo = lo && lo > today ? lo : today;
if (side === 'min') return lo;
return hi;
}
// ---------------------------------------------------------------------------
// Label formatting
// ---------------------------------------------------------------------------
@ -174,6 +198,7 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints
*/
export function presetLiteralKey(kind: PeriodPresetKind): string {
switch (kind) {
case 'allTime': return 'Alle';
case 'ytd': return 'Laufendes Jahr';
case 'lastYear': return 'Letztes Jahr';
case 'nextYear': return 'Nächstes Jahr';

View file

@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerCalendar from './PeriodPickerCalendar';
import {
clampIsoDate,
fromIsoDate,
isPresetDisabled,
presetLiteralKey,
@ -27,6 +28,7 @@ import type {
import styles from './PeriodPicker.module.css';
const PRESETS_ORDER: PeriodPresetKind[] = [
'allTime',
'ytd',
'lastYear',
'nextYear',
@ -41,6 +43,7 @@ const PRESETS_ORDER: PeriodPresetKind[] = [
function _presetLabel(kind: PeriodPresetKind, t: (k: string) => string): string {
switch (kind) {
case 'allTime': return t('Alle');
case 'ytd': return t('Laufendes Jahr');
case 'lastYear': return t('Letztes Jahr');
case 'nextYear': return t('Nächstes Jahr');
@ -107,13 +110,21 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
const _selectPreset = useCallback((kind: PeriodPresetKind) => {
if (isPresetDisabled(kind, constraints)) return;
if (kind === 'custom') {
// Switching from ``allTime`` back to custom: don't carry the 1970-2999
// sentinel. Seed with today/today so the user gets a sensible starting
// point and the calendar has a real anchor.
const isFromAllTime = draft.preset.kind === 'allTime';
const seedFrom = isFromAllTime ? toIsoDate(todayDate()) : draft.fromDate;
const seedTo = isFromAllTime ? toIsoDate(todayDate()) : draft.toDate;
const next: PeriodValue = {
preset: { kind: 'custom' },
fromDate: draft.fromDate,
toDate: draft.toDate,
fromDate: seedFrom,
toDate: seedTo,
};
setDraft(next);
setRangePick({ from: fromIsoDate(next.fromDate), to: fromIsoDate(next.toDate) });
const anchor = fromIsoDate(seedFrom);
if (anchor) setCalAnchor(startOfMonth(anchor));
return;
}
const preset: PeriodPreset = { kind } as PeriodPreset;
@ -152,15 +163,32 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
}, [rangePick]);
const _onFooterFromChange = useCallback((iso: string) => {
// Empty string = user cleared the input; ignore so ``draft`` keeps a valid ISO.
if (!iso) return;
const d = fromIsoDate(iso);
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, fromDate: iso }));
setRangePick((prev) => ({ from: fromIsoDate(iso), to: prev.to }));
setRangePick((prev) => ({ from: d, to: prev.to }));
// Jump the calendar to the typed month so the user immediately sees the
// selection move. Without this, the calendar stays on the current month
// and it *looks* like the input was ignored.
if (d) setCalAnchor(startOfMonth(d));
}, []);
const _onFooterToChange = useCallback((iso: string) => {
if (!iso) return;
const d = fromIsoDate(iso);
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, toDate: iso }));
setRangePick((prev) => ({ from: prev.from, to: fromIsoDate(iso) }));
setRangePick((prev) => ({ from: prev.from, to: d }));
if (d) setCalAnchor(startOfMonth(d));
}, []);
// ``min``/``max`` on the native date inputs — prevents the user from typing
// a date that would be silently reverted by the parent's
// ``isValueAllowed`` fallback (which would replace it with ``defaultPreset``
// and lose the custom year).
const footerMin = clampIsoDate(undefined, constraints, 'min');
const footerMax = clampIsoDate(undefined, constraints, 'max');
// Keyboard: Esc cancels, Enter applies
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -269,18 +297,20 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
<input
type="date"
className={styles.footerInput}
value={draft.fromDate}
min={constraints.minDate}
max={constraints.maxDate}
value={draft.preset.kind === 'allTime' ? '' : draft.fromDate}
min={footerMin}
max={footerMax}
disabled={draft.preset.kind === 'allTime'}
onChange={(e) => _onFooterFromChange(e.target.value)}
/>
<span className={styles.footerLabel}>{t('Bis')}</span>
<input
type="date"
className={styles.footerInput}
value={draft.toDate}
min={constraints.minDate}
max={constraints.maxDate}
value={draft.preset.kind === 'allTime' ? '' : draft.toDate}
min={footerMin}
max={footerMax}
disabled={draft.preset.kind === 'allTime'}
onChange={(e) => _onFooterToChange(e.target.value)}
/>
<span className={styles.spacer} />

View file

@ -9,6 +9,7 @@
export type PeriodUnit = 'day' | 'week' | 'month' | 'year';
export type PeriodPresetKind =
| 'allTime'
| 'ytd'
| 'lastYear'
| 'nextYear'
@ -23,6 +24,7 @@ export type PeriodPresetKind =
| 'custom';
export type PeriodPreset =
| { kind: 'allTime' }
| { kind: 'ytd' }
| { kind: 'lastYear' }
| { kind: 'nextYear' }

View file

@ -156,3 +156,110 @@
.spin {
animation: spin 1s linear infinite;
}
.credentialsBox {
margin-top: 0.75rem;
padding: 0.75rem 0.85rem;
border-radius: var(--object-radius-small, 6px);
background: var(--bg-secondary, #fff);
border: 1px dashed var(--border-color, #cbd5e1);
color: var(--text-primary);
}
.credentialsBoxCompact {
margin-top: 0.6rem;
padding: 0.5rem 0.65rem;
border-radius: var(--object-radius-small, 6px);
background: var(--bg-tertiary, #f7f7f8);
border: 1px dashed var(--border-color, #cbd5e1);
color: var(--text-primary);
font-size: 0.78rem;
}
:global(.dark-theme) .credentialsBox,
:global(.dark-theme) .credentialsBoxCompact {
background: var(--bg-tertiary, #2a2a3a);
border-color: var(--border-color, #3d3d4d);
}
.credentialsHeader {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
font-size: 0.78rem;
color: var(--text-secondary);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.credentialsRow {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.credentialsRow + .credentialsRow {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e2e8f0);
}
.credentialsRole {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.credentialsField {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
}
.credentialsField code {
font-family: var(--font-mono, monospace);
background: var(--bg-tertiary, #f7f7f8);
padding: 1px 6px;
border-radius: 4px;
font-size: 0.8rem;
color: var(--text-primary);
word-break: break-all;
}
:global(.dark-theme) .credentialsField code {
background: var(--bg-secondary, #1e1e2e);
}
.credentialsLabel {
min-width: 60px;
color: var(--text-secondary);
font-size: 0.75rem;
}
.copyButton {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border-color, #cbd5e1);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.7rem;
}
.copyButton:hover:not(:disabled) {
background: var(--bg-secondary, #f7f7f8);
color: var(--text-primary);
}
.copyButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}

View file

@ -6,17 +6,25 @@
*/
import React, { useState, useEffect, useCallback } from 'react';
import { FaPlay, FaTrash, FaSync, FaCubes } from 'react-icons/fa';
import { FaPlay, FaTrash, FaSync, FaCubes, FaCopy, FaKey } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import demoStyles from './AdminDemoConfigPage.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { useConfirm } from '../../hooks/useConfirm';
interface _DemoCredential {
role?: string;
username?: string;
email?: string;
password?: string;
}
interface _DemoConfig {
code: string;
label: string;
description: string;
credentials?: _DemoCredential[];
}
interface _ActionResult {
@ -24,6 +32,7 @@ interface _ActionResult {
action: 'load' | 'remove';
status: 'ok' | 'error';
summary?: Record<string, unknown>;
credentials?: _DemoCredential[];
error?: string;
}
@ -59,7 +68,18 @@ export const AdminDemoConfigPage: React.FC = () => {
setLastResult(null);
try {
const response = await api.post(`/api/admin/demo-config/${code}/load`);
setLastResult({ code, action: 'load', status: 'ok', summary: response.data.summary });
const summary = (response.data?.summary || {}) as Record<string, unknown>;
const credsFromSummary = Array.isArray(summary.credentials)
? (summary.credentials as _DemoCredential[])
: undefined;
const credsFromConfig = configs.find((c) => c.code === code)?.credentials;
setLastResult({
code,
action: 'load',
status: 'ok',
summary,
credentials: credsFromSummary ?? credsFromConfig,
});
} catch (err: any) {
setLastResult({ code, action: 'load', status: 'error', error: err.response?.data?.detail || String(err) });
} finally {
@ -110,6 +130,9 @@ export const AdminDemoConfigPage: React.FC = () => {
) : (
<span>{lastResult.error}</span>
)}
{lastResult.status === 'ok' && lastResult.action === 'load' && lastResult.credentials && lastResult.credentials.length > 0 && (
<_CredentialsBox credentials={lastResult.credentials} />
)}
</div>
)}
@ -126,6 +149,9 @@ export const AdminDemoConfigPage: React.FC = () => {
<h3 className={demoStyles.cardTitle}>{cfg.label}</h3>
<p className={demoStyles.cardDescription}>{cfg.description}</p>
<span className={demoStyles.cardCode}>{cfg.code}</span>
{cfg.credentials && cfg.credentials.length > 0 && (
<_CredentialsBox credentials={cfg.credentials} compact />
)}
</div>
<div className={demoStyles.cardActions}>
<button
@ -158,7 +184,10 @@ export const AdminDemoConfigPage: React.FC = () => {
const _SummaryDisplay: React.FC<{ summary?: Record<string, unknown> }> = ({ summary }) => {
const { t } = useLanguage();
if (!summary) return null;
const sections = Object.entries(summary).filter(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
// Skip the credentials block here -- it gets its own copyable widget below.
const sections = Object.entries(summary)
.filter(([key]) => key !== 'credentials')
.filter(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
if (sections.length === 0) return <span>{t('Abgeschlossen (keine Änderungen)')}</span>;
return (
<span>
@ -170,3 +199,73 @@ const _SummaryDisplay: React.FC<{ summary?: Record<string, unknown> }> = ({ summ
</span>
);
};
const _CredentialsBox: React.FC<{ credentials: _DemoCredential[]; compact?: boolean }> = ({ credentials, compact }) => {
const { t } = useLanguage();
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const _copy = async (key: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedKey(key);
window.setTimeout(() => setCopiedKey((prev) => (prev === key ? null : prev)), 1500);
} catch {
// ignore clipboard failures (no permission, http, ...)
}
};
return (
<div className={compact ? demoStyles.credentialsBoxCompact : demoStyles.credentialsBox}>
<div className={demoStyles.credentialsHeader}>
<FaKey />
<span>{t('Login-Daten')}</span>
</div>
{credentials.map((cred, idx) => {
// Login uses the USERNAME (the email is just informational metadata
// about the demo user -- the auth flow keys off `username`).
const loginValue = cred.username || '';
const pwd = cred.password || '';
const rowKey = `${idx}-${loginValue}`;
return (
<div key={rowKey} className={demoStyles.credentialsRow}>
{cred.role && <div className={demoStyles.credentialsRole}>{cred.role}</div>}
<div className={demoStyles.credentialsField}>
<span className={demoStyles.credentialsLabel}>{t('Login')}:</span>
<code>{loginValue}</code>
<button
type="button"
className={demoStyles.copyButton}
onClick={() => _copy(`${rowKey}-login`, loginValue)}
disabled={!loginValue}
title={t('Login kopieren')}
>
<FaCopy />
{copiedKey === `${rowKey}-login` ? ` ${t('kopiert')}` : ''}
</button>
</div>
<div className={demoStyles.credentialsField}>
<span className={demoStyles.credentialsLabel}>{t('Passwort')}:</span>
<code>{pwd}</code>
<button
type="button"
className={demoStyles.copyButton}
onClick={() => _copy(`${rowKey}-pwd`, pwd)}
disabled={!pwd}
title={t('Passwort kopieren')}
>
<FaCopy />
{copiedKey === `${rowKey}-pwd` ? ` ${t('kopiert')}` : ''}
</button>
</div>
{cred.email && (
<div className={demoStyles.credentialsField}>
<span className={demoStyles.credentialsLabel}>{t('E-Mail')}:</span>
<code>{cred.email}</code>
</div>
)}
</div>
);
})}
</div>
);
};

View file

@ -77,7 +77,13 @@ function _getColumns(t: (key: string) => string): ColumnConfig[] {
];
}
const _PRIORITY_CODES = ['de', 'gsw', 'en', 'fr', 'it'];
// ISO 639 catalog (codes + native labels + priority order) is provided by the
// gateway via GET /api/i18n/iso-choices. We must NOT keep a local copy here --
// any divergence between frontend and backend caused subtle bugs (e.g. user
// could create a language code that the AI translation prompt did not know how
// to label). The catalog is fetched once on mount and held in component state.
type IsoChoice = { value: string; label: string };
type IsoCatalogResponse = { priorityCodes: string[]; choices: IsoChoice[] };
function _isAbortError(e: unknown): boolean {
if (axios.isCancel(e)) return true;
@ -88,55 +94,6 @@ function _isAbortError(e: unknown): boolean {
return false;
}
const _isoChoices: { value: string; label: string }[] = [
{ value: 'de', label: 'de — Deutsch' },
{ value: 'gsw', label: 'gsw — Schweizerdeutsch' },
{ value: 'en', label: 'en — English' },
{ value: 'fr', label: 'fr — Français' }, { value: 'it', label: 'it — Italiano' },
{ value: 'es', label: 'es — Español' }, { value: 'pt', label: 'pt — Português' },
{ value: 'nl', label: 'nl — Nederlands' }, { value: 'pl', label: 'pl — Polski' },
{ value: 'cs', label: 'cs — Čeština' }, { value: 'sk', label: 'sk — Slovenčina' },
{ value: 'sv', label: 'sv — Svenska' }, { value: 'no', label: 'no — Norsk' },
{ value: 'da', label: 'da — Dansk' }, { value: 'fi', label: 'fi — Suomi' },
{ value: 'hu', label: 'hu — Magyar' }, { value: 'ro', label: 'ro — Română' },
{ value: 'bg', label: 'bg — Български' }, { value: 'hr', label: 'hr — Hrvatski' },
{ value: 'sl', label: 'sl — Slovenščina' }, { value: 'et', label: 'et — Eesti' },
{ value: 'lv', label: 'lv — Latviešu' }, { value: 'lt', label: 'lt — Lietuvių' },
{ value: 'el', label: 'el — Ελληνικά' }, { value: 'tr', label: 'tr — Türkçe' },
{ value: 'ru', label: 'ru — Русский' }, { value: 'uk', label: 'uk — Українська' },
{ value: 'ar', label: 'ar — العربية' }, { value: 'he', label: 'he — עברית' },
{ value: 'zh', label: 'zh — 中文' }, { value: 'ja', label: 'ja — 日本語' },
{ value: 'ko', label: 'ko — 한국어' }, { value: 'hi', label: 'hi — हिन्दी' },
{ value: 'th', label: 'th — ไทย' }, { value: 'vi', label: 'vi — Tiếng Việt' },
{ value: 'id', label: 'id — Bahasa Indonesia' }, { value: 'ms', label: 'ms — Bahasa Melayu' },
{ value: 'tl', label: 'tl — Filipino' }, { value: 'sw', label: 'sw — Kiswahili' },
{ value: 'af', label: 'af — Afrikaans' }, { value: 'sq', label: 'sq — Shqip' },
{ value: 'am', label: 'am — አማርኛ' }, { value: 'hy', label: 'hy — Հայերեն' },
{ value: 'az', label: 'az — Azərbaycan' }, { value: 'eu', label: 'eu — Euskara' },
{ value: 'be', label: 'be — Беларуская' }, { value: 'bn', label: 'bn — বাংলা' },
{ value: 'bs', label: 'bs — Bosanski' }, { value: 'ca', label: 'ca — Català' },
{ value: 'cy', label: 'cy — Cymraeg' }, { value: 'eo', label: 'eo — Esperanto' },
{ value: 'fa', label: 'fa — فارسی' }, { value: 'ga', label: 'ga — Gaeilge' },
{ value: 'gl', label: 'gl — Galego' }, { value: 'gu', label: 'gu — ગુજરાતી' },
{ value: 'ha', label: 'ha — Hausa' }, { value: 'is', label: 'is — Íslenska' },
{ value: 'jv', label: 'jv — Basa Jawa' }, { value: 'ka', label: 'ka — ქართული' },
{ value: 'kk', label: 'kk — Қазақ' }, { value: 'km', label: 'km — ខ្មែរ' },
{ value: 'kn', label: 'kn — ಕನ್ನಡ' }, { value: 'ku', label: 'ku — Kurdî' },
{ value: 'ky', label: 'ky — Кыргызча' }, { value: 'la', label: 'la — Latina' },
{ value: 'lb', label: 'lb — Lëtzebuergesch' }, { value: 'lo', label: 'lo — ລາວ' },
{ value: 'mk', label: 'mk — Македонски' }, { value: 'ml', label: 'ml — മലയാളം' },
{ value: 'mn', label: 'mn — Монгол' }, { value: 'mr', label: 'mr — मराठी' },
{ value: 'mt', label: 'mt — Malti' }, { value: 'my', label: 'my — မြန်မာ' },
{ value: 'ne', label: 'ne — नेपाली' }, { value: 'or', label: 'or — ଓଡ଼ିଆ' },
{ value: 'pa', label: 'pa — ਪੰਜਾਬੀ' }, { value: 'ps', label: 'ps — پښتو' },
{ value: 'si', label: 'si — සිංහල' }, { value: 'so', label: 'so — Soomaali' },
{ value: 'sr', label: 'sr — Српски' }, { value: 'su', label: 'su — Basa Sunda' },
{ value: 'ta', label: 'ta — தமிழ்' }, { value: 'te', label: 'te — తెలుగు' },
{ value: 'tg', label: 'tg — Тоҷикӣ' }, { value: 'tk', label: 'tk — Türkmen' },
{ value: 'ur', label: 'ur — اردو' }, { value: 'uz', label: 'uz — Oʻzbek' },
{ value: 'yo', label: 'yo — Yorùbá' }, { value: 'zu', label: 'zu — isiZulu' },
];
// ---------------------------------------------------------------------------
// Progress overlay component
// ---------------------------------------------------------------------------
@ -327,9 +284,30 @@ export const AdminLanguagesPage: React.FC = () => {
const [addCode, setAddCode] = useState('');
const [progress, setProgress] = useState<ProgressInfo | null>(null);
const [search, setSearch] = useState('');
const [isoCatalog, setIsoCatalog] = useState<IsoCatalogResponse>({ priorityCodes: [], choices: [] });
const busyRef = useRef(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await api.get('/api/i18n/iso-choices');
if (cancelled) return;
const data = res.data as IsoCatalogResponse;
setIsoCatalog({
priorityCodes: Array.isArray(data?.priorityCodes) ? data.priorityCodes : [],
choices: Array.isArray(data?.choices) ? data.choices : [],
});
} catch (e) {
console.error('Failed to load ISO language catalog from /api/i18n/iso-choices:', e);
}
})();
return () => {
cancelled = true;
};
}, []);
const _endProgressSoon = useCallback((ms: number) => {
window.setTimeout(() => {
setProgress(null);
@ -418,17 +396,18 @@ export const AdminLanguagesPage: React.FC = () => {
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
const addChoices = useMemo(() => {
const available = _isoChoices.filter((c) => !existingCodes.has(c.value));
const available = isoCatalog.choices.filter((c) => !existingCodes.has(c.value));
const priority = isoCatalog.priorityCodes;
available.sort((a, b) => {
const aPrio = _PRIORITY_CODES.indexOf(a.value);
const bPrio = _PRIORITY_CODES.indexOf(b.value);
const aPrio = priority.indexOf(a.value);
const bPrio = priority.indexOf(b.value);
if (aPrio !== -1 && bPrio !== -1) return aPrio - bPrio;
if (aPrio !== -1) return -1;
if (bPrio !== -1) return 1;
return a.label.localeCompare(b.label);
});
return available;
}, [existingCodes]);
}, [existingCodes, isoCatalog]);
useEffect(() => {
if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) {

View file

@ -273,6 +273,7 @@ export const RedmineStatsView: React.FC = () => {
direction: 'past',
defaultPresetKind: 'thisQuarter',
enabledPresets: [
'allTime',
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
],
@ -332,8 +333,15 @@ export const RedmineStatsView: React.FC = () => {
const _handleFilterChange = useCallback((filterState: ReportFilterState) => {
if (filterState.periodValue) {
setDateFrom(filterState.periodValue.fromDate);
setDateTo(filterState.periodValue.toDate);
// "Alle" = no date filter. Drop the sentinel range so the backend
// aggregates over the full history instead of clamping to 1970--2999.
if (filterState.periodValue.preset.kind === 'allTime') {
setDateFrom(undefined);
setDateTo(undefined);
} else {
setDateFrom(filterState.periodValue.fromDate);
setDateTo(filterState.periodValue.toDate);
}
} else if (filterState.dateRange) {
setDateFrom(toIsoDate(filterState.dateRange.from));
setDateTo(toIsoDate(filterState.dateRange.to));

View file

@ -1,8 +1,15 @@
/**
* TrusteeDocumentsView
*
*
* Dokument-Verwaltung für eine Trustee-Instanz.
* Verwendet FormGeneratorTable für konsistentes UI.
*
* NOTE: Mounted only as a tab inside `TrusteeDataTablesView` (Tab `documents`
* unter `/mandates/{m}/trustee/{i}/data-tables?tab=documents`). Es gibt keine
* eigenständige Top-Level-Route mehr (`/trustee/{i}/documents` wurde entfernt
* -- siehe `wiki/c-work/4-done/2026-04-trustee-cleanup-positions-documents.md`).
* Direkt-Import durch `TrusteeDataTablesView`; kein Re-Export über
* `views/trustee/index.ts`.
*/
import React, { useState, useMemo, useEffect } from 'react';

View file

@ -1,8 +1,15 @@
/**
* TrusteePositionsView
*
*
* Positions-Verwaltung für eine Trustee-Instanz.
* Verwendet FormGeneratorTable für konsistentes UI.
*
* NOTE: Mounted only as a tab inside `TrusteeDataTablesView` (Tab `positions`
* unter `/mandates/{m}/trustee/{i}/data-tables?tab=positions`). Es gibt keine
* eigenständige Top-Level-Route mehr (`/trustee/{i}/positions` wurde entfernt
* -- siehe `wiki/c-work/4-done/2026-04-trustee-cleanup-positions-documents.md`).
* Direkt-Import durch `TrusteeDataTablesView`; kein Re-Export über
* `views/trustee/index.ts`.
*/
import React, { useState, useMemo, useEffect, useCallback } from 'react';

View file

@ -1,10 +1,15 @@
/**
* Trustee Views Export
*
* NOTE: `TrusteePositionsView` und `TrusteeDocumentsView` werden hier
* ABSICHTLICH NICHT mehr re-exportiert. Beide Komponenten sind nur noch
* Tab-Bodies innerhalb von `TrusteeDataTablesView` und werden dort per
* Direkt-Import (`./TrusteePositionsView`, `./TrusteeDocumentsView`) geladen.
* Externe Importe ueber dieses Index-File sind nicht mehr unterstuetzt --
* siehe `wiki/c-work/4-done/2026-04-trustee-cleanup-positions-documents.md`.
*/
export { TrusteeDashboardView } from './TrusteeDashboardView';
export { TrusteeDocumentsView } from './TrusteeDocumentsView';
export { TrusteePositionsView } from './TrusteePositionsView';
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
export { TrusteeScanUploadView } from './TrusteeScanUploadView';