fixes
This commit is contained in:
parent
208f7b63df
commit
fc2cce8732
15 changed files with 506 additions and 77 deletions
|
|
@ -46,7 +46,14 @@ import { getApiBaseUrl } from '../config/config';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: getApiBaseUrl(),
|
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
|
// Add a request interceptor to add the auth token, context headers, and log backend IP
|
||||||
|
|
|
||||||
|
|
@ -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(
|
export async function syncPositionsToAccounting(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
positionIds: string[]
|
positionIds: string[],
|
||||||
): Promise<{ total: number; success: number; errors: number; results: any[] }> {
|
opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void }
|
||||||
return await request({
|
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
|
||||||
|
const submission = await request({
|
||||||
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: { positionIds }
|
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(
|
export async function fetchSyncStatus(
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,53 @@
|
||||||
border-color: var(--primary-color, #f25843);
|
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 --- */
|
/* --- Sections Grid --- */
|
||||||
|
|
||||||
.sectionsGrid {
|
.sectionsGrid {
|
||||||
|
|
|
||||||
|
|
@ -667,6 +667,12 @@ const _Toolbar: React.FC<ToolbarProps> = ({
|
||||||
value={(filterState.filters[filter.key] as string) || ''}
|
value={(filterState.filters[filter.key] as string) || ''}
|
||||||
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
|
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
|
<select
|
||||||
className={styles.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
|
// MAIN COMPONENT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ const _DEFAULT_PRESET: PeriodPreset = { kind: 'ytd' };
|
||||||
|
|
||||||
function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string {
|
function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string {
|
||||||
if (!value) return placeholder;
|
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)}`;
|
const range = `${formatIsoDateDe(value.fromDate)} – ${formatIsoDateDe(value.toDate)}`;
|
||||||
switch (value.preset.kind) {
|
switch (value.preset.kind) {
|
||||||
case 'ytd': return `${t('Laufendes Jahr')} · ${range}`;
|
case 'ytd': return `${t('Laufendes Jahr')} · ${range}`;
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,19 @@ function _shiftBy(d: Date, amount: number, unit: PeriodUnit): Date {
|
||||||
// Preset resolver
|
// 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 } {
|
export function resolvePeriod(preset: PeriodPreset, prevValue?: PeriodValue | null): { fromDate: string; toDate: string } {
|
||||||
const today = todayDate();
|
const today = todayDate();
|
||||||
switch (preset.kind) {
|
switch (preset.kind) {
|
||||||
|
case 'allTime':
|
||||||
|
return { fromDate: ALL_TIME_FROM, toDate: ALL_TIME_TO };
|
||||||
case 'ytd':
|
case 'ytd':
|
||||||
return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) };
|
return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) };
|
||||||
case 'lastYear': {
|
case 'lastYear': {
|
||||||
|
|
@ -164,6 +174,20 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints
|
||||||
return true;
|
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
|
// Label formatting
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -174,6 +198,7 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints
|
||||||
*/
|
*/
|
||||||
export function presetLiteralKey(kind: PeriodPresetKind): string {
|
export function presetLiteralKey(kind: PeriodPresetKind): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
case 'allTime': return 'Alle';
|
||||||
case 'ytd': return 'Laufendes Jahr';
|
case 'ytd': return 'Laufendes Jahr';
|
||||||
case 'lastYear': return 'Letztes Jahr';
|
case 'lastYear': return 'Letztes Jahr';
|
||||||
case 'nextYear': return 'Nächstes Jahr';
|
case 'nextYear': return 'Nächstes Jahr';
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import PeriodPickerCalendar from './PeriodPickerCalendar';
|
import PeriodPickerCalendar from './PeriodPickerCalendar';
|
||||||
import {
|
import {
|
||||||
|
clampIsoDate,
|
||||||
fromIsoDate,
|
fromIsoDate,
|
||||||
isPresetDisabled,
|
isPresetDisabled,
|
||||||
presetLiteralKey,
|
presetLiteralKey,
|
||||||
|
|
@ -27,6 +28,7 @@ import type {
|
||||||
import styles from './PeriodPicker.module.css';
|
import styles from './PeriodPicker.module.css';
|
||||||
|
|
||||||
const PRESETS_ORDER: PeriodPresetKind[] = [
|
const PRESETS_ORDER: PeriodPresetKind[] = [
|
||||||
|
'allTime',
|
||||||
'ytd',
|
'ytd',
|
||||||
'lastYear',
|
'lastYear',
|
||||||
'nextYear',
|
'nextYear',
|
||||||
|
|
@ -41,6 +43,7 @@ const PRESETS_ORDER: PeriodPresetKind[] = [
|
||||||
|
|
||||||
function _presetLabel(kind: PeriodPresetKind, t: (k: string) => string): string {
|
function _presetLabel(kind: PeriodPresetKind, t: (k: string) => string): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
|
case 'allTime': return t('Alle');
|
||||||
case 'ytd': return t('Laufendes Jahr');
|
case 'ytd': return t('Laufendes Jahr');
|
||||||
case 'lastYear': return t('Letztes Jahr');
|
case 'lastYear': return t('Letztes Jahr');
|
||||||
case 'nextYear': return t('Nächstes Jahr');
|
case 'nextYear': return t('Nächstes Jahr');
|
||||||
|
|
@ -107,13 +110,21 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
||||||
const _selectPreset = useCallback((kind: PeriodPresetKind) => {
|
const _selectPreset = useCallback((kind: PeriodPresetKind) => {
|
||||||
if (isPresetDisabled(kind, constraints)) return;
|
if (isPresetDisabled(kind, constraints)) return;
|
||||||
if (kind === 'custom') {
|
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 = {
|
const next: PeriodValue = {
|
||||||
preset: { kind: 'custom' },
|
preset: { kind: 'custom' },
|
||||||
fromDate: draft.fromDate,
|
fromDate: seedFrom,
|
||||||
toDate: draft.toDate,
|
toDate: seedTo,
|
||||||
};
|
};
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
setRangePick({ from: fromIsoDate(next.fromDate), to: fromIsoDate(next.toDate) });
|
setRangePick({ from: fromIsoDate(next.fromDate), to: fromIsoDate(next.toDate) });
|
||||||
|
const anchor = fromIsoDate(seedFrom);
|
||||||
|
if (anchor) setCalAnchor(startOfMonth(anchor));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const preset: PeriodPreset = { kind } as PeriodPreset;
|
const preset: PeriodPreset = { kind } as PeriodPreset;
|
||||||
|
|
@ -152,15 +163,32 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
||||||
}, [rangePick]);
|
}, [rangePick]);
|
||||||
|
|
||||||
const _onFooterFromChange = useCallback((iso: string) => {
|
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 }));
|
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) => {
|
const _onFooterToChange = useCallback((iso: string) => {
|
||||||
|
if (!iso) return;
|
||||||
|
const d = fromIsoDate(iso);
|
||||||
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, toDate: 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
|
// Keyboard: Esc cancels, Enter applies
|
||||||
const popRef = useRef<HTMLDivElement>(null);
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -269,18 +297,20 @@ const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className={styles.footerInput}
|
className={styles.footerInput}
|
||||||
value={draft.fromDate}
|
value={draft.preset.kind === 'allTime' ? '' : draft.fromDate}
|
||||||
min={constraints.minDate}
|
min={footerMin}
|
||||||
max={constraints.maxDate}
|
max={footerMax}
|
||||||
|
disabled={draft.preset.kind === 'allTime'}
|
||||||
onChange={(e) => _onFooterFromChange(e.target.value)}
|
onChange={(e) => _onFooterFromChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className={styles.footerLabel}>{t('Bis')}</span>
|
<span className={styles.footerLabel}>{t('Bis')}</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className={styles.footerInput}
|
className={styles.footerInput}
|
||||||
value={draft.toDate}
|
value={draft.preset.kind === 'allTime' ? '' : draft.toDate}
|
||||||
min={constraints.minDate}
|
min={footerMin}
|
||||||
max={constraints.maxDate}
|
max={footerMax}
|
||||||
|
disabled={draft.preset.kind === 'allTime'}
|
||||||
onChange={(e) => _onFooterToChange(e.target.value)}
|
onChange={(e) => _onFooterToChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className={styles.spacer} />
|
<span className={styles.spacer} />
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
export type PeriodUnit = 'day' | 'week' | 'month' | 'year';
|
export type PeriodUnit = 'day' | 'week' | 'month' | 'year';
|
||||||
|
|
||||||
export type PeriodPresetKind =
|
export type PeriodPresetKind =
|
||||||
|
| 'allTime'
|
||||||
| 'ytd'
|
| 'ytd'
|
||||||
| 'lastYear'
|
| 'lastYear'
|
||||||
| 'nextYear'
|
| 'nextYear'
|
||||||
|
|
@ -23,6 +24,7 @@ export type PeriodPresetKind =
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
export type PeriodPreset =
|
export type PeriodPreset =
|
||||||
|
| { kind: 'allTime' }
|
||||||
| { kind: 'ytd' }
|
| { kind: 'ytd' }
|
||||||
| { kind: 'lastYear' }
|
| { kind: 'lastYear' }
|
||||||
| { kind: 'nextYear' }
|
| { kind: 'nextYear' }
|
||||||
|
|
|
||||||
|
|
@ -156,3 +156,110 @@
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,25 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
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 api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
import demoStyles from './AdminDemoConfigPage.module.css';
|
import demoStyles from './AdminDemoConfigPage.module.css';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
|
||||||
|
interface _DemoCredential {
|
||||||
|
role?: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface _DemoConfig {
|
interface _DemoConfig {
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
credentials?: _DemoCredential[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface _ActionResult {
|
interface _ActionResult {
|
||||||
|
|
@ -24,6 +32,7 @@ interface _ActionResult {
|
||||||
action: 'load' | 'remove';
|
action: 'load' | 'remove';
|
||||||
status: 'ok' | 'error';
|
status: 'ok' | 'error';
|
||||||
summary?: Record<string, unknown>;
|
summary?: Record<string, unknown>;
|
||||||
|
credentials?: _DemoCredential[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +68,18 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
setLastResult(null);
|
setLastResult(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/api/admin/demo-config/${code}/load`);
|
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) {
|
} catch (err: any) {
|
||||||
setLastResult({ code, action: 'load', status: 'error', error: err.response?.data?.detail || String(err) });
|
setLastResult({ code, action: 'load', status: 'error', error: err.response?.data?.detail || String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -110,6 +130,9 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
) : (
|
) : (
|
||||||
<span>{lastResult.error}</span>
|
<span>{lastResult.error}</span>
|
||||||
)}
|
)}
|
||||||
|
{lastResult.status === 'ok' && lastResult.action === 'load' && lastResult.credentials && lastResult.credentials.length > 0 && (
|
||||||
|
<_CredentialsBox credentials={lastResult.credentials} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -126,6 +149,9 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
<h3 className={demoStyles.cardTitle}>{cfg.label}</h3>
|
<h3 className={demoStyles.cardTitle}>{cfg.label}</h3>
|
||||||
<p className={demoStyles.cardDescription}>{cfg.description}</p>
|
<p className={demoStyles.cardDescription}>{cfg.description}</p>
|
||||||
<span className={demoStyles.cardCode}>{cfg.code}</span>
|
<span className={demoStyles.cardCode}>{cfg.code}</span>
|
||||||
|
{cfg.credentials && cfg.credentials.length > 0 && (
|
||||||
|
<_CredentialsBox credentials={cfg.credentials} compact />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={demoStyles.cardActions}>
|
<div className={demoStyles.cardActions}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -158,7 +184,10 @@ export const AdminDemoConfigPage: React.FC = () => {
|
||||||
const _SummaryDisplay: React.FC<{ summary?: Record<string, unknown> }> = ({ summary }) => {
|
const _SummaryDisplay: React.FC<{ summary?: Record<string, unknown> }> = ({ summary }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
if (!summary) return null;
|
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>;
|
if (sections.length === 0) return <span>{t('Abgeschlossen (keine Änderungen)')}</span>;
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -170,3 +199,73 @@ const _SummaryDisplay: React.FC<{ summary?: Record<string, unknown> }> = ({ summ
|
||||||
</span>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
function _isAbortError(e: unknown): boolean {
|
||||||
if (axios.isCancel(e)) return true;
|
if (axios.isCancel(e)) return true;
|
||||||
|
|
@ -88,55 +94,6 @@ function _isAbortError(e: unknown): boolean {
|
||||||
return false;
|
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
|
// Progress overlay component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -327,9 +284,30 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
const [addCode, setAddCode] = useState('');
|
const [addCode, setAddCode] = useState('');
|
||||||
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [isoCatalog, setIsoCatalog] = useState<IsoCatalogResponse>({ priorityCodes: [], choices: [] });
|
||||||
const busyRef = useRef(false);
|
const busyRef = useRef(false);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
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) => {
|
const _endProgressSoon = useCallback((ms: number) => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
|
@ -418,17 +396,18 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
|
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
|
||||||
|
|
||||||
const addChoices = useMemo(() => {
|
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) => {
|
available.sort((a, b) => {
|
||||||
const aPrio = _PRIORITY_CODES.indexOf(a.value);
|
const aPrio = priority.indexOf(a.value);
|
||||||
const bPrio = _PRIORITY_CODES.indexOf(b.value);
|
const bPrio = priority.indexOf(b.value);
|
||||||
if (aPrio !== -1 && bPrio !== -1) return aPrio - bPrio;
|
if (aPrio !== -1 && bPrio !== -1) return aPrio - bPrio;
|
||||||
if (aPrio !== -1) return -1;
|
if (aPrio !== -1) return -1;
|
||||||
if (bPrio !== -1) return 1;
|
if (bPrio !== -1) return 1;
|
||||||
return a.label.localeCompare(b.label);
|
return a.label.localeCompare(b.label);
|
||||||
});
|
});
|
||||||
return available;
|
return available;
|
||||||
}, [existingCodes]);
|
}, [existingCodes, isoCatalog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) {
|
if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) {
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,7 @@ export const RedmineStatsView: React.FC = () => {
|
||||||
direction: 'past',
|
direction: 'past',
|
||||||
defaultPresetKind: 'thisQuarter',
|
defaultPresetKind: 'thisQuarter',
|
||||||
enabledPresets: [
|
enabledPresets: [
|
||||||
|
'allTime',
|
||||||
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||||
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||||
],
|
],
|
||||||
|
|
@ -332,8 +333,15 @@ export const RedmineStatsView: React.FC = () => {
|
||||||
|
|
||||||
const _handleFilterChange = useCallback((filterState: ReportFilterState) => {
|
const _handleFilterChange = useCallback((filterState: ReportFilterState) => {
|
||||||
if (filterState.periodValue) {
|
if (filterState.periodValue) {
|
||||||
setDateFrom(filterState.periodValue.fromDate);
|
// "Alle" = no date filter. Drop the sentinel range so the backend
|
||||||
setDateTo(filterState.periodValue.toDate);
|
// 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) {
|
} else if (filterState.dateRange) {
|
||||||
setDateFrom(toIsoDate(filterState.dateRange.from));
|
setDateFrom(toIsoDate(filterState.dateRange.from));
|
||||||
setDateTo(toIsoDate(filterState.dateRange.to));
|
setDateTo(toIsoDate(filterState.dateRange.to));
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* TrusteeDocumentsView
|
* TrusteeDocumentsView
|
||||||
*
|
*
|
||||||
* Dokument-Verwaltung für eine Trustee-Instanz.
|
* Dokument-Verwaltung für eine Trustee-Instanz.
|
||||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
* 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';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* TrusteePositionsView
|
* TrusteePositionsView
|
||||||
*
|
*
|
||||||
* Positions-Verwaltung für eine Trustee-Instanz.
|
* Positions-Verwaltung für eine Trustee-Instanz.
|
||||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
* 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';
|
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Trustee Views Export
|
* 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 { TrusteeDashboardView } from './TrusteeDashboardView';
|
||||||
export { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
|
||||||
export { TrusteePositionsView } from './TrusteePositionsView';
|
|
||||||
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
||||||
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
||||||
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue