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({
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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))) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// "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));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
*
|
||||
* 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';
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
*
|
||||
* 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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue