Merge pull request #49 from valueonag/feat/demo-system-readieness

Feat/demo system readieness
This commit is contained in:
Patrick Motsch 2026-04-21 00:55:25 +02:00 committed by GitHub
commit 3f4a98381d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3899 additions and 773 deletions

View file

@ -147,8 +147,7 @@ function App() {
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
<Route path="documents" element={<FeatureViewPage view="documents" />} />
<Route path="positions" element={<FeatureViewPage view="positions" />} />
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
<Route path="roles" element={<FeatureViewPage view="roles" />} />
<Route path="access" element={<FeatureViewPage view="access" />} />
<Route path="runs" element={<FeatureViewPage view="runs" />} />
@ -158,8 +157,7 @@ function App() {
<Route path="chat" element={<FeatureViewPage view="chat" />} />
<Route path="threads" element={<FeatureViewPage view="threads" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />

View file

@ -56,8 +56,12 @@ export interface BillingSettingsUpdate {
rechargeMaxPerMonth?: number;
}
export type BillingBucketSize = 'day' | 'month' | 'year';
export interface UsageReport {
period: string;
dateFrom: string;
dateTo: string;
bucketSize: BillingBucketSize;
totalCost: number;
transactionCount: number;
costByProvider: Record<string, number>;
@ -65,6 +69,12 @@ export interface UsageReport {
costByFeature: Record<string, number>;
}
export interface StatisticsRangeRequest {
dateFrom: string;
dateTo: string;
bucketSize: BillingBucketSize;
}
export interface AccountSummary {
id: string;
mandateId: string;
@ -141,24 +151,21 @@ export async function fetchTransactions(
}
/**
* Fetch usage statistics
* Endpoint: GET /api/billing/statistics/{period}
* Fetch usage statistics for an explicit date range.
* Endpoint: GET /api/billing/statistics
*/
export async function fetchStatistics(
request: ApiRequestFunction,
period: 'day' | 'month' | 'year',
year: number,
month?: number
range: StatisticsRangeRequest
): Promise<UsageReport> {
const params: Record<string, any> = { year };
if (month !== undefined) {
params.month = month;
}
return await request({
url: `/api/billing/statistics/${period}`,
url: '/api/billing/statistics',
method: 'get',
params
params: {
dateFrom: range.dateFrom,
dateTo: range.dateTo,
bucketSize: range.bucketSize,
},
});
}
@ -225,6 +232,19 @@ export async function addCreditAdmin(
});
}
/**
* Fetch the server-side allow-list of CHF top-up amounts
* Endpoint: GET /api/billing/checkout/amounts
*/
export async function fetchCheckoutAmounts(
request: ApiRequestFunction
): Promise<number[]> {
return await request({
url: '/api/billing/checkout/amounts',
method: 'get'
});
}
/**
* Create Stripe Checkout Session for credit top-up
* Endpoint: POST /api/billing/checkout/create/{mandateId}

View file

@ -875,6 +875,91 @@ export async function fetchSyncStatus(
});
}
// ============================================================================
// READ-ONLY DATA TABLE API (Daten-Tabellen page)
// ============================================================================
//
// Generic read-only endpoints for the consolidated data tables view.
// All entities are paginated, sortable, filterable via the Unified Filter API
// (mode=filterValues / mode=ids); no CRUD writes are exposed by these helpers.
export interface TrusteeDataAccount { id: string; [key: string]: any; }
export interface TrusteeDataJournalEntry { id: string; [key: string]: any; }
export interface TrusteeDataJournalLine { id: string; [key: string]: any; }
export interface TrusteeDataContact { id: string; [key: string]: any; }
export interface TrusteeDataAccountBalance { id: string; [key: string]: any; }
export interface TrusteeAccountingConfigRecord { id: string; [key: string]: any; }
export interface TrusteeAccountingSyncRecord { id: string; [key: string]: any; }
async function _fetchReadOnlyTable<T = any>(
request: ApiRequestFunction,
instanceId: string,
pathSegment: string,
params?: PaginationParams
): Promise<PaginatedResponse<T> | T[]> {
return await request({
url: `${_getTrusteeBaseUrl(instanceId)}/${pathSegment}`,
method: 'get',
params: _buildPaginationParams(params),
});
}
export async function fetchDataAccounts(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataAccount> | TrusteeDataAccount[]> {
return _fetchReadOnlyTable<TrusteeDataAccount>(request, instanceId, 'data/accounts', params);
}
export async function fetchDataJournalEntries(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataJournalEntry> | TrusteeDataJournalEntry[]> {
return _fetchReadOnlyTable<TrusteeDataJournalEntry>(request, instanceId, 'data/journal-entries', params);
}
export async function fetchDataJournalLines(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataJournalLine> | TrusteeDataJournalLine[]> {
return _fetchReadOnlyTable<TrusteeDataJournalLine>(request, instanceId, 'data/journal-lines', params);
}
export async function fetchDataContacts(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataContact> | TrusteeDataContact[]> {
return _fetchReadOnlyTable<TrusteeDataContact>(request, instanceId, 'data/contacts', params);
}
export async function fetchDataAccountBalances(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataAccountBalance> | TrusteeDataAccountBalance[]> {
return _fetchReadOnlyTable<TrusteeDataAccountBalance>(request, instanceId, 'data/account-balances', params);
}
export async function fetchAccountingConfigs(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeAccountingConfigRecord> | TrusteeAccountingConfigRecord[]> {
return _fetchReadOnlyTable<TrusteeAccountingConfigRecord>(request, instanceId, 'accounting/configs', params);
}
export async function fetchAccountingSyncs(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeAccountingSyncRecord> | TrusteeAccountingSyncRecord[]> {
return _fetchReadOnlyTable<TrusteeAccountingSyncRecord>(request, instanceId, 'accounting/syncs', params);
}
export async function exportAccountingData(
request: ApiRequestFunction,
instanceId: string

View file

@ -6,6 +6,13 @@ import {
} from 'recharts';
import styles from './FormGeneratorReport.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
PeriodPicker,
fromIsoDate,
toIsoDate,
type PeriodPreset,
type PeriodValue,
} from '../../PeriodPicker';
import type {
FormGeneratorReportProps,
@ -531,14 +538,36 @@ const _Toolbar: React.FC<ToolbarProps> = ({
});
};
const _handleDateRangeChange = (field: 'from' | 'to', dateStr: string) => {
const dateRange = filterState.dateRange || { from: new Date(), to: new Date() };
const _handlePeriodPickerChange = (next: PeriodValue) => {
const fromD = fromIsoDate(next.fromDate) || new Date();
const toD = fromIsoDate(next.toDate) || new Date();
onFilterStateChange({
...filterState,
dateRange: { ...dateRange, [field]: new Date(dateStr) }
dateRange: { from: fromD, to: toD },
periodValue: next,
});
};
// Prefer the preserved PeriodValue (carries the preset) so the round-trip
// back into PeriodPicker does not collapse to `custom`, which would clash
// with `direction: 'past'` for presets whose natural end is in the future
// (e.g. `thisMonth`, `thisQuarter`, `ytd`) and trigger an infinite fallback
// loop in PeriodPicker's constraint-correction effect.
const _periodPickerValue: PeriodValue | null = useMemo(() => {
if (filterState.periodValue) return filterState.periodValue;
const dr = filterState.dateRange;
if (!dr?.from || !dr?.to) return null;
return {
preset: { kind: 'custom' },
fromDate: toIsoDate(dr.from),
toDate: toIsoDate(dr.to),
};
}, [filterState.periodValue, filterState.dateRange]);
const _periodPickerDefault: PeriodPreset = useMemo(() => {
return { kind: dateRangeSelector?.defaultPresetKind || 'ytd' } as PeriodPreset;
}, [dateRangeSelector?.defaultPresetKind]);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
@ -605,22 +634,18 @@ const _Toolbar: React.FC<ToolbarProps> = ({
<div className={styles.toolbarSeparator} />
)}
{/* Date Range */}
{/* Date Range (rendered via shared PeriodPicker) */}
{hasDateRange && (
<div className={styles.toolbarGroup}>
<span className={styles.toolbarLabel}>{t('Von')}</span>
<input
type="date"
className={styles.dateInput}
value={filterState.dateRange?.from?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('from', e.target.value)}
/>
<span className={styles.toolbarLabel}>{t('Bis')}</span>
<input
type="date"
className={styles.dateInput}
value={filterState.dateRange?.to?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('to', e.target.value)}
<span className={styles.toolbarLabel}>{t('Zeitraum')}</span>
<PeriodPicker
value={_periodPickerValue}
onChange={_handlePeriodPickerChange}
direction={dateRangeSelector!.direction || 'any'}
defaultPreset={_periodPickerDefault}
enabledPresets={dateRangeSelector!.enabledPresets}
minDate={dateRangeSelector!.minDate}
maxDate={dateRangeSelector!.maxDate}
/>
</div>
)}

View file

@ -54,7 +54,7 @@ export interface ReportPeriodSelectorConfig {
defaultMonth?: number;
}
/** Date range selector configuration */
/** Date range selector configuration. Renders the shared PeriodPicker. */
export interface ReportDateRangeSelectorConfig {
/** Whether the date range selector is enabled */
enabled: boolean;
@ -62,6 +62,28 @@ export interface ReportDateRangeSelectorConfig {
defaultFrom?: Date;
/** Default to date */
defaultTo?: Date;
/**
* Allowed direction relative to today. Default: `'any'`. Set to `'past'`
* for historic reports (most cases), `'future'` for forecasts.
*/
direction?: 'past' | 'future' | 'any';
/**
* Default preset kind shown when neither `defaultFrom`/`defaultTo` nor a
* stored selection is available. Default: `'ytd'`.
*/
defaultPresetKind?:
| 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom';
/** Whitelist of preset kinds offered to the user. */
enabledPresets?: Array<
'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter'
| 'lastN' | 'nextN' | 'custom'
>;
/** Min/max boundaries (ISO `YYYY-MM-DD`). */
minDate?: string;
/** Min/max boundaries (ISO `YYYY-MM-DD`). */
maxDate?: string;
}
/** Combined filter state passed to the data callback */
@ -72,8 +94,15 @@ export interface ReportFilterState {
year?: number;
/** Selected month (1-12) */
month?: number;
/** Date range */
/** Date range (always synthesized from `periodValue` when the
* `dateRangeSelector` is enabled). */
dateRange?: ReportDateRange;
/**
* Full PeriodPicker value when the `dateRangeSelector` is enabled. Carries
* the original preset (e.g. `thisMonth`) so that the round-trip back into
* the picker preserves preset semantics and does not collapse to `custom`.
*/
periodValue?: import('../../PeriodPicker').PeriodValue;
/** Custom filter values: key -> value(s) */
filters: Record<string, string | string[]>;
}

View file

@ -0,0 +1,394 @@
/* PeriodPicker - styled with global theme variables (Light/Dark via :root). */
.wrapper {
position: relative;
display: inline-block;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-medium, 8px);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
min-width: 280px;
justify-content: space-between;
transition: border-color 0.15s ease;
}
.trigger:hover:not(:disabled) {
border-color: var(--primary-color, #4A6FA5);
}
.trigger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.trigger.open {
border-color: var(--primary-color, #4A6FA5);
}
.triggerIcon {
font-size: 1rem;
color: var(--text-secondary, #4A5568);
}
.triggerText {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.triggerChev {
color: var(--text-tertiary, #718096);
font-size: 0.7rem;
}
/* ---------- Popover ---------- */
.popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1000;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1A202C);
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-large, 10px);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.18);
width: 720px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 120px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.popover.alignRight {
left: auto;
right: 0;
}
.body {
display: grid;
grid-template-columns: 200px 240px 1fr;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
/* ---------- Column 1: presets ---------- */
.colPresets {
background: var(--bg-secondary, #F7FAFC);
padding: 0.625rem 0.5rem;
border-right: 1px solid var(--border-color, #E2E8F0);
display: flex;
flex-direction: column;
gap: 2px;
}
.presetBtn {
text-align: left;
padding: 0.5rem 0.75rem;
background: transparent;
color: var(--text-primary, #1A202C);
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.presetBtn:hover:not(:disabled) {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
}
.presetBtn.active {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.presetBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Column 2: last/next N ---------- */
.colLastN {
padding: 0.875rem 1rem;
border-right: 1px solid var(--border-color, #E2E8F0);
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.colTitle {
margin: 0;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary, #718096);
font-weight: 600;
}
.lastNRow {
display: flex;
gap: 0.375rem;
align-items: center;
flex-wrap: wrap;
}
.seg {
display: inline-flex;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
overflow: hidden;
}
.segBtn {
padding: 0.375rem 0.625rem;
background: var(--bg-input, #ffffff);
border: none;
cursor: pointer;
font: inherit;
font-size: 0.75rem;
color: var(--text-secondary, #4A5568);
}
.segBtn.on {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.segBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.numInput {
width: 64px;
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.unitSelect {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.applyN {
margin-top: 4px;
padding: 0.375rem 0.625rem;
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
color: var(--primary-color, #4A6FA5);
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.75rem;
font-weight: 600;
align-self: flex-start;
}
.applyN:hover:not(:disabled) {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.applyN:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Column 3: calendar ---------- */
.colCalendar {
padding: 0.75rem 0.875rem;
display: flex;
flex-direction: column;
min-width: 0;
}
.calNav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.calNavBtn {
background: transparent;
border: 1px solid transparent;
padding: 0.25rem 0.5rem;
cursor: pointer;
border-radius: var(--object-radius-small, 4px);
font-size: 0.875rem;
color: var(--text-secondary, #4A5568);
}
.calNavBtn:hover {
background: var(--bg-secondary, #F7FAFC);
color: var(--text-primary, #1A202C);
}
.calTitle {
font-size: 0.8125rem;
color: var(--text-secondary, #4A5568);
}
.calMonths {
display: flex;
flex-direction: column;
gap: 1rem;
}
.calMonth h5 {
margin: 0 0 0.375rem;
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1A202C);
}
.calGrid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 0.75rem;
}
.dowCell {
color: var(--text-tertiary, #718096);
text-align: center;
font-size: 0.625rem;
padding: 0.25rem 0;
text-transform: uppercase;
}
.dayCell {
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: var(--object-radius-small, 4px);
user-select: none;
color: var(--text-primary, #1A202C);
font-size: 0.75rem;
border: 1px solid transparent;
background: transparent;
font-family: inherit;
padding: 0;
}
.dayCell.muted {
color: var(--text-tertiary, #718096);
opacity: 0.55;
}
.dayCell.disabled {
color: var(--color-gray-disabled, #CBD5E0);
cursor: not-allowed;
text-decoration: line-through;
}
.dayCell:not(.disabled):hover {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
}
.dayCell.inRange {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
border-radius: 0;
}
.dayCell.rangeStart {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dayCell.rangeEnd {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.dayCell.rangeStart.rangeEnd {
border-radius: var(--object-radius-small, 4px);
}
.dayCell.today {
font-weight: 700;
outline: 1px dashed var(--primary-color, #4A6FA5);
outline-offset: -2px;
}
/* ---------- Footer ---------- */
.footer {
border-top: 1px solid var(--border-color, #E2E8F0);
padding: 0.75rem 1rem;
display: flex;
gap: 0.625rem;
align-items: center;
background: var(--bg-secondary, #F7FAFC);
flex-wrap: wrap;
}
.footerLabel {
font-size: 0.75rem;
color: var(--text-secondary, #4A5568);
margin: 0 0.25rem 0 0;
}
.footerInput {
padding: 0.3125rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.spacer {
flex: 1;
}
.btnGhost {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid var(--border-color, #E2E8F0);
color: var(--text-primary, #1A202C);
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.btnGhost:hover {
background: var(--bg-input, #ffffff);
}
.btnPrimary {
padding: 0.375rem 0.875rem;
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
}
.btnPrimary:hover:not(:disabled) {
background: var(--primary-color-dark, #3D5D8A);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- Mobile (single calendar column) ---------- */
@media (max-width: 600px) {
.popover {
width: calc(100vw - 32px);
}
.body {
grid-template-columns: 1fr;
}
.colPresets,
.colLastN {
border-right: none;
border-bottom: 1px solid var(--border-color, #E2E8F0);
}
.colPresets {
flex-direction: row;
flex-wrap: wrap;
}
.presetBtn {
flex: 1 1 auto;
min-width: 45%;
text-align: center;
}
}

View file

@ -0,0 +1,179 @@
/**
* PeriodPicker - public component (Trigger + Popover).
*
* Carries a semantic value `{ preset, fromDate, toDate }`. Presets are
* re-resolved on every render so dynamic ranges (`ytd`, `last12Months`, )
* stay fresh when the user revisits the page.
*
* Outside-click is detected via `mousedown` (not `click`): inner elements
* are re-rendered on selection and would otherwise be detached from the DOM
* when the click event reaches the document, breaking `closest()`.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerPopover from './PeriodPickerPopover';
import {
formatIsoDateDe,
isPresetDisabled,
isValueAllowed,
resolvePeriod,
} from './PeriodPickerLogic';
import type {
PeriodPickerProps,
PeriodPreset,
PeriodValue,
} from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
// Re-export public types so callers can import everything from one place.
export type { PeriodPickerProps, PeriodPreset, PeriodValue, PeriodDirection, PeriodPresetKind, PeriodUnit } from './PeriodPickerTypes';
export { resolvePeriod, isPresetDisabled, isValueAllowed } from './PeriodPickerLogic';
const _DEFAULT_PRESET: PeriodPreset = { kind: 'ytd' };
function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string {
if (!value) return placeholder;
const range = `${formatIsoDateDe(value.fromDate)} ${formatIsoDateDe(value.toDate)}`;
switch (value.preset.kind) {
case 'ytd': return `${t('Laufendes Jahr')} · ${range}`;
case 'lastYear': return `${t('Letztes Jahr')} · ${range}`;
case 'nextYear': return `${t('Nächstes Jahr')} · ${range}`;
case 'last12Months': return `${t('Letzte 12 Monate')} · ${range}`;
case 'next12Months': return `${t('Nächste 12 Monate')} · ${range}`;
case 'thisMonth': return `${t('Dieser Monat')} · ${range}`;
case 'lastMonth': return `${t('Letzter Monat')} · ${range}`;
case 'thisQuarter': return `${t('Dieses Quartal')} · ${range}`;
case 'lastQuarter': return `${t('Letztes Quartal')} · ${range}`;
case 'lastN': {
const unitLabel = _unitLabelShort(value.preset.unit, t);
return `${t('Letzte')} ${value.preset.amount} ${unitLabel} · ${range}`;
}
case 'nextN': {
const unitLabel = _unitLabelShort(value.preset.unit, t);
return `${t('Nächste')} ${value.preset.amount} ${unitLabel} · ${range}`;
}
case 'custom':
default:
return range;
}
}
function _unitLabelShort(unit: 'day' | 'week' | 'month' | 'year', t: (k: string) => string): string {
switch (unit) {
case 'day': return t('Tage');
case 'week': return t('Wochen');
case 'month': return t('Monate');
case 'year': return t('Jahre');
}
}
export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
const {
value,
onChange,
direction = 'any',
minDate,
maxDate,
enabledPresets,
defaultPreset = _DEFAULT_PRESET,
placeholder,
disabled = false,
className,
} = props;
const { t } = useLanguage();
const constraints = useMemo(
() => ({ direction, minDate, maxDate, enabledPresets }),
[direction, minDate, maxDate, enabledPresets],
);
// Re-resolve semantic presets on every render so values stay fresh.
const resolvedValue: PeriodValue | null = useMemo(() => {
if (!value) return null;
if (value.preset.kind === 'custom') return value;
const r = resolvePeriod(value.preset, value);
if (r.fromDate === value.fromDate && r.toDate === value.toDate) return value;
return { preset: value.preset, fromDate: r.fromDate, toDate: r.toDate };
}, [value]);
const _resolvedTrigger = useMemo(
() => _formatTriggerLabel(resolvedValue, t, placeholder || t('Zeitraum wählen')),
[resolvedValue, t, placeholder],
);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
// Outside click via mousedown (see file header).
useEffect(() => {
if (!open) return;
const _onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (wrapRef.current && wrapRef.current.contains(target)) return;
setOpen(false);
};
window.addEventListener('mousedown', _onDown);
return () => window.removeEventListener('mousedown', _onDown);
}, [open]);
const _initialDraft: PeriodValue = useMemo(() => {
if (resolvedValue) return resolvedValue;
const preset = isPresetDisabled(defaultPreset.kind, constraints)
? ({ kind: 'custom' } as PeriodPreset)
: defaultPreset;
const r = resolvePeriod(preset);
return { preset, fromDate: r.fromDate, toDate: r.toDate };
}, [resolvedValue, defaultPreset, constraints]);
const _handleApply = useCallback((next: PeriodValue) => {
onChange(next);
setOpen(false);
}, [onChange]);
const _handleCancel = useCallback(() => setOpen(false), []);
// If parent-passed value violates constraints, fall back silently to the
// default preset so the trigger never shows a forbidden range.
useEffect(() => {
if (resolvedValue && !isValueAllowed(resolvedValue, constraints)) {
const fallbackPreset = isPresetDisabled(defaultPreset.kind, constraints)
? ({ kind: 'custom' } as PeriodPreset)
: defaultPreset;
const r = resolvePeriod(fallbackPreset, resolvedValue);
onChange({ preset: fallbackPreset, fromDate: r.fromDate, toDate: r.toDate });
}
}, [resolvedValue, constraints, defaultPreset, onChange]);
const triggerCls = [styles.trigger];
if (open) triggerCls.push(styles.open);
return (
<div ref={wrapRef} className={`${styles.wrapper}${className ? ` ${className}` : ''}`}>
<button
type="button"
className={triggerCls.join(' ')}
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className={styles.triggerIcon} aria-hidden>📅</span>
<span className={styles.triggerText}>{_resolvedTrigger}</span>
<span className={styles.triggerChev} aria-hidden></span>
</button>
{open && (
<PeriodPickerPopover
initialValue={_initialDraft}
constraints={constraints}
onApply={_handleApply}
onCancel={_handleCancel}
/>
)}
</div>
);
};
export default PeriodPicker;

View file

@ -0,0 +1,132 @@
/**
* PeriodPicker - dual-month range calendar (vertically stacked).
*
* Pure presentation; receives a `range` and emits `onPickDate`. Constraint
* checks (min/max/direction) are delegated to `isDateDisabled`.
*/
import React, { useMemo } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import {
addMonthsToDate,
buildMonthCells,
isDateDisabled,
_isSameDay,
} from './PeriodPickerLogic';
import type { PeriodConstraints } from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
interface CalendarRange {
from: Date | null;
to: Date | null;
}
interface PeriodPickerCalendarProps {
anchor: Date;
onAnchorChange: (next: Date) => void;
range: CalendarRange;
onPickDate: (d: Date) => void;
constraints: PeriodConstraints;
}
function _monthLabel(d: Date, t: (k: string) => string): string {
switch (d.getMonth()) {
case 0: return `${t('Januar')} ${d.getFullYear()}`;
case 1: return `${t('Februar')} ${d.getFullYear()}`;
case 2: return `${t('März')} ${d.getFullYear()}`;
case 3: return `${t('April')} ${d.getFullYear()}`;
case 4: return `${t('Mai')} ${d.getFullYear()}`;
case 5: return `${t('Juni')} ${d.getFullYear()}`;
case 6: return `${t('Juli')} ${d.getFullYear()}`;
case 7: return `${t('August')} ${d.getFullYear()}`;
case 8: return `${t('September')} ${d.getFullYear()}`;
case 9: return `${t('Oktober')} ${d.getFullYear()}`;
case 10: return `${t('November')} ${d.getFullYear()}`;
case 11: return `${t('Dezember')} ${d.getFullYear()}`;
default: return `${d.getFullYear()}`;
}
}
function _dayOfWeekLabel(idx: number, t: (k: string) => string): string {
switch (idx) {
case 0: return t('Mo');
case 1: return t('Di');
case 2: return t('Mi');
case 3: return t('Do');
case 4: return t('Fr');
case 5: return t('Sa');
case 6: return t('So');
default: return '';
}
}
const PeriodPickerCalendar: React.FC<PeriodPickerCalendarProps> = (props) => {
const { anchor, onAnchorChange, range, onPickDate, constraints } = props;
const { t } = useLanguage();
const monthsToShow = useMemo(() => [anchor, addMonthsToDate(anchor, 1)], [anchor]);
return (
<div className={styles.colCalendar}>
<div className={styles.calNav}>
<button
type="button"
className={styles.calNavBtn}
onClick={() => onAnchorChange(addMonthsToDate(anchor, -1))}
aria-label={t('Vorheriger Monat')}
>
</button>
<span className={styles.calTitle}>
{`${_monthLabel(monthsToShow[0], t)} ${_monthLabel(monthsToShow[1], t)}`}
</span>
<button
type="button"
className={styles.calNavBtn}
onClick={() => onAnchorChange(addMonthsToDate(anchor, 1))}
aria-label={t('Nächster Monat')}
>
</button>
</div>
<div className={styles.calMonths}>
{monthsToShow.map((monthAnchor) => (
<div key={`${monthAnchor.getFullYear()}-${monthAnchor.getMonth()}`} className={styles.calMonth}>
<h5>{_monthLabel(monthAnchor, t)}</h5>
<div className={styles.calGrid} role="grid">
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<div key={`dow-${i}`} className={styles.dowCell}>{_dayOfWeekLabel(i, t)}</div>
))}
{buildMonthCells(monthAnchor).map((cell) => {
const disabled = isDateDisabled(cell.date, constraints);
const cls: string[] = [styles.dayCell];
if (!cell.inMonth) cls.push(styles.muted);
if (disabled) cls.push(styles.disabled);
if (cell.isToday) cls.push(styles.today);
if (range.from && range.to && cell.date >= range.from && cell.date <= range.to) {
cls.push(styles.inRange);
}
if (range.from && _isSameDay(cell.date, range.from)) cls.push(styles.rangeStart);
if (range.to && _isSameDay(cell.date, range.to)) cls.push(styles.rangeEnd);
return (
<button
type="button"
key={cell.iso}
className={cls.join(' ')}
disabled={disabled}
onClick={() => onPickDate(cell.date)}
>
{cell.date.getDate()}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
export default PeriodPickerCalendar;

View file

@ -0,0 +1,241 @@
/**
* PeriodPicker - pure logic helpers.
*
* No React, no DOM. All date math is local-date based (no timezone shifting).
* Use ISO `YYYY-MM-DD` strings as the wire format.
*/
import type {
PeriodConstraints,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
} from './PeriodPickerTypes';
// ---------------------------------------------------------------------------
// Date primitives
// ---------------------------------------------------------------------------
const _pad = (n: number): string => String(n).padStart(2, '0');
export function toIsoDate(d: Date): string {
return `${d.getFullYear()}-${_pad(d.getMonth() + 1)}-${_pad(d.getDate())}`;
}
export function fromIsoDate(s: string | null | undefined): Date | null {
if (!s) return null;
const parts = s.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
return new Date(parts[0], parts[1] - 1, parts[2]);
}
export function daysInRange(fromIso: string, toIso: string): number {
const from = fromIsoDate(fromIso);
const to = fromIsoDate(toIso);
if (!from || !to) return 0;
const ms = to.getTime() - from.getTime();
return Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24)) + 1);
}
export function todayDate(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function _addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function _addMonths(d: Date, n: number): Date {
const r = new Date(d);
r.setMonth(r.getMonth() + n);
return r;
}
function _addYears(d: Date, n: number): Date {
const r = new Date(d);
r.setFullYear(r.getFullYear() + n);
return r;
}
function _startOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), 1); }
function _endOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth() + 1, 0); }
function _startOfYear(d: Date): Date { return new Date(d.getFullYear(), 0, 1); }
function _endOfYear(d: Date): Date { return new Date(d.getFullYear(), 11, 31); }
function _startOfQuarter(d: Date): Date {
return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
}
function _endOfQuarter(d: Date): Date {
const s = _startOfQuarter(d);
return new Date(s.getFullYear(), s.getMonth() + 3, 0);
}
function _shiftBy(d: Date, amount: number, unit: PeriodUnit): Date {
switch (unit) {
case 'day': return _addDays(d, amount);
case 'week': return _addDays(d, amount * 7);
case 'month': return _addMonths(d, amount);
case 'year': return _addYears(d, amount);
}
}
// ---------------------------------------------------------------------------
// Preset resolver
// ---------------------------------------------------------------------------
export function resolvePeriod(preset: PeriodPreset, prevValue?: PeriodValue | null): { fromDate: string; toDate: string } {
const today = todayDate();
switch (preset.kind) {
case 'ytd':
return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) };
case 'lastYear': {
const ly = _addYears(today, -1);
return { fromDate: toIsoDate(_startOfYear(ly)), toDate: toIsoDate(_endOfYear(ly)) };
}
case 'nextYear': {
const ny = _addYears(today, 1);
return { fromDate: toIsoDate(_startOfYear(ny)), toDate: toIsoDate(_endOfYear(ny)) };
}
case 'last12Months':
return { fromDate: toIsoDate(_addMonths(today, -12)), toDate: toIsoDate(today) };
case 'next12Months':
return { fromDate: toIsoDate(today), toDate: toIsoDate(_addMonths(today, 12)) };
case 'thisMonth':
return { fromDate: toIsoDate(_startOfMonth(today)), toDate: toIsoDate(_endOfMonth(today)) };
case 'lastMonth': {
const lm = _addMonths(today, -1);
return { fromDate: toIsoDate(_startOfMonth(lm)), toDate: toIsoDate(_endOfMonth(lm)) };
}
case 'thisQuarter':
return { fromDate: toIsoDate(_startOfQuarter(today)), toDate: toIsoDate(_endOfQuarter(today)) };
case 'lastQuarter': {
const lq = _addMonths(_startOfQuarter(today), -3);
return { fromDate: toIsoDate(_startOfQuarter(lq)), toDate: toIsoDate(_endOfQuarter(lq)) };
}
case 'lastN':
return { fromDate: toIsoDate(_shiftBy(today, -preset.amount, preset.unit)), toDate: toIsoDate(today) };
case 'nextN':
return { fromDate: toIsoDate(today), toDate: toIsoDate(_shiftBy(today, preset.amount, preset.unit)) };
case 'custom':
// Custom holds whatever was last picked; rely on the previous value if available,
// otherwise default to a single-day range at today to give the calendar an anchor.
return {
fromDate: prevValue?.fromDate || toIsoDate(today),
toDate: prevValue?.toDate || toIsoDate(today),
};
}
}
// ---------------------------------------------------------------------------
// Constraints
// ---------------------------------------------------------------------------
export function isDateDisabled(d: Date, cfg: PeriodConstraints): boolean {
const min = fromIsoDate(cfg.minDate);
const max = fromIsoDate(cfg.maxDate);
if (min && d < min) return true;
if (max && d > max) return true;
if (cfg.direction === 'past' && d > todayDate()) return true;
if (cfg.direction === 'future' && d < todayDate()) return true;
return false;
}
const _FUTURE_PRESETS: PeriodPresetKind[] = ['nextYear', 'next12Months', 'nextN'];
const _PAST_PRESETS: PeriodPresetKind[] = ['lastYear', 'last12Months', 'lastN', 'lastMonth', 'lastQuarter'];
export function isPresetDisabled(kind: PeriodPresetKind, cfg: PeriodConstraints): boolean {
if (cfg.enabledPresets && !cfg.enabledPresets.includes(kind)) return true;
if (cfg.direction === 'past' && _FUTURE_PRESETS.includes(kind)) return true;
if (cfg.direction === 'future' && _PAST_PRESETS.includes(kind)) return true;
return false;
}
export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints): boolean {
if (!value) return false;
if (isPresetDisabled(value.preset.kind, cfg)) return false;
if (value.preset.kind === 'custom') {
const f = fromIsoDate(value.fromDate);
const tt = fromIsoDate(value.toDate);
if (!f || !tt) return false;
if (isDateDisabled(f, cfg)) return false;
if (isDateDisabled(tt, cfg)) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Label formatting
// ---------------------------------------------------------------------------
/**
* Returns the human label for a preset kind. Caller wraps with `t()` because
* `t()` only accepts string literals (no variables).
*/
export function presetLiteralKey(kind: PeriodPresetKind): string {
switch (kind) {
case 'ytd': return 'Laufendes Jahr';
case 'lastYear': return 'Letztes Jahr';
case 'nextYear': return 'Nächstes Jahr';
case 'last12Months': return 'Letzte 12 Monate';
case 'next12Months': return 'Nächste 12 Monate';
case 'thisMonth': return 'Dieser Monat';
case 'lastMonth': return 'Letzter Monat';
case 'thisQuarter': return 'Dieses Quartal';
case 'lastQuarter': return 'Letztes Quartal';
case 'lastN': return 'Letzte N';
case 'nextN': return 'Nächste N';
case 'custom': return 'Benutzerdefiniert';
}
}
export function formatIsoDateDe(iso: string): string {
const d = fromIsoDate(iso);
if (!d) return iso;
return `${_pad(d.getDate())}.${_pad(d.getMonth() + 1)}.${d.getFullYear()}`;
}
// ---------------------------------------------------------------------------
// Calendar grid helper
// ---------------------------------------------------------------------------
export interface CalendarCell {
date: Date;
iso: string;
inMonth: boolean;
isToday: boolean;
}
/**
* Returns 6x7 = 42 cells starting on Monday for the given month anchor.
*/
export function buildMonthCells(anchor: Date): CalendarCell[] {
const start = _startOfMonth(anchor);
const leading = (start.getDay() + 6) % 7; // Monday-first
const today = todayDate();
const cells: CalendarCell[] = [];
for (let i = 0; i < 42; i++) {
const d = _addDays(start, i - leading);
cells.push({
date: d,
iso: toIsoDate(d),
inMonth: d.getMonth() === anchor.getMonth(),
isToday: _isSameDay(d, today),
});
}
return cells;
}
export function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
export function addMonthsToDate(d: Date, n: number): Date {
return _addMonths(d, n);
}
export function startOfMonth(d: Date): Date {
return _startOfMonth(d);
}

View file

@ -0,0 +1,303 @@
/**
* PeriodPicker - popover body (3 columns + footer).
*
* Receives the working `draft` value plus constraints, and delegates the
* actual commit to the parent via `onApply` / `onCancel`.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerCalendar from './PeriodPickerCalendar';
import {
fromIsoDate,
isPresetDisabled,
presetLiteralKey,
resolvePeriod,
startOfMonth,
toIsoDate,
todayDate,
} from './PeriodPickerLogic';
import type {
PeriodConstraints,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
} from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
const PRESETS_ORDER: PeriodPresetKind[] = [
'ytd',
'lastYear',
'nextYear',
'last12Months',
'next12Months',
'thisMonth',
'lastMonth',
'thisQuarter',
'lastQuarter',
'custom',
];
function _presetLabel(kind: PeriodPresetKind, t: (k: string) => string): string {
switch (kind) {
case 'ytd': return t('Laufendes Jahr');
case 'lastYear': return t('Letztes Jahr');
case 'nextYear': return t('Nächstes Jahr');
case 'last12Months': return t('Letzte 12 Monate');
case 'next12Months': return t('Nächste 12 Monate');
case 'thisMonth': return t('Dieser Monat');
case 'lastMonth': return t('Letzter Monat');
case 'thisQuarter': return t('Dieses Quartal');
case 'lastQuarter': return t('Letztes Quartal');
case 'lastN': return t('Letzte N');
case 'nextN': return t('Nächste N');
case 'custom': return t('Benutzerdefiniert');
}
// Make TS exhaustive checks happy.
return presetLiteralKey(kind);
}
function _unitLabel(unit: PeriodUnit, t: (k: string) => string): string {
switch (unit) {
case 'day': return t('Tage');
case 'week': return t('Wochen');
case 'month': return t('Monate');
case 'year': return t('Jahre');
}
}
interface PeriodPickerPopoverProps {
initialValue: PeriodValue;
constraints: PeriodConstraints;
onApply: (next: PeriodValue) => void;
onCancel: () => void;
}
interface RangePick {
from: Date | null;
to: Date | null;
}
const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
const { initialValue, constraints, onApply, onCancel } = props;
const { t } = useLanguage();
const [draft, setDraft] = useState<PeriodValue>(initialValue);
const [rangePick, setRangePick] = useState<RangePick>(() => ({
from: fromIsoDate(initialValue.fromDate),
to: fromIsoDate(initialValue.toDate),
}));
const [calAnchor, setCalAnchor] = useState<Date>(() => {
const f = fromIsoDate(initialValue.fromDate) || todayDate();
return startOfMonth(f);
});
// "Letzte N / Nächste N" controls
const [lastNDirection, setLastNDirection] = useState<'last' | 'next'>(
constraints.direction === 'future' ? 'next' : 'last',
);
const [lastNAmount, setLastNAmount] = useState<number>(7);
const [lastNUnit, setLastNUnit] = useState<PeriodUnit>('day');
const _commit = useCallback((value: PeriodValue) => {
onApply(value);
}, [onApply]);
const _selectPreset = useCallback((kind: PeriodPresetKind) => {
if (isPresetDisabled(kind, constraints)) return;
if (kind === 'custom') {
const next: PeriodValue = {
preset: { kind: 'custom' },
fromDate: draft.fromDate,
toDate: draft.toDate,
};
setDraft(next);
setRangePick({ from: fromIsoDate(next.fromDate), to: fromIsoDate(next.toDate) });
return;
}
const preset: PeriodPreset = { kind } as PeriodPreset;
const resolved = resolvePeriod(preset, draft);
_commit({ preset, fromDate: resolved.fromDate, toDate: resolved.toDate });
}, [constraints, draft, _commit]);
const _applyLastN = useCallback(() => {
const amount = Math.max(1, Math.floor(lastNAmount) || 1);
const preset: PeriodPreset = lastNDirection === 'last'
? { kind: 'lastN', amount, unit: lastNUnit }
: { kind: 'nextN', amount, unit: lastNUnit };
if (isPresetDisabled(preset.kind, constraints)) return;
const resolved = resolvePeriod(preset, draft);
_commit({ preset, fromDate: resolved.fromDate, toDate: resolved.toDate });
}, [lastNDirection, lastNAmount, lastNUnit, draft, constraints, _commit]);
const _onPickDate = useCallback((d: Date) => {
const { from, to } = rangePick;
let next: RangePick;
if (!from || (from && to)) {
next = { from: d, to: null };
} else if (d < from) {
next = { from: d, to: from };
} else {
next = { from, to: d };
}
setRangePick(next);
if (next.from && next.to) {
setDraft({
preset: { kind: 'custom' },
fromDate: toIsoDate(next.from),
toDate: toIsoDate(next.to),
});
}
}, [rangePick]);
const _onFooterFromChange = useCallback((iso: string) => {
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, fromDate: iso }));
setRangePick((prev) => ({ from: fromIsoDate(iso), to: prev.to }));
}, []);
const _onFooterToChange = useCallback((iso: string) => {
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, toDate: iso }));
setRangePick((prev) => ({ from: prev.from, to: fromIsoDate(iso) }));
}, []);
// Keyboard: Esc cancels, Enter applies
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const _onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
if (e.key === 'Enter') { e.preventDefault(); onApply(draft); }
};
window.addEventListener('keydown', _onKey);
return () => window.removeEventListener('keydown', _onKey);
}, [draft, onApply, onCancel]);
return (
<div ref={popRef} className={styles.popover}>
<div className={styles.body}>
{/* Column 1: Presets */}
<div className={styles.colPresets}>
{PRESETS_ORDER.map((kind) => {
const disabled = isPresetDisabled(kind, constraints);
const cls = [styles.presetBtn];
if (draft.preset.kind === kind) cls.push(styles.active);
return (
<button
key={kind}
type="button"
className={cls.join(' ')}
disabled={disabled}
onClick={() => _selectPreset(kind)}
>
{_presetLabel(kind, t)}
</button>
);
})}
</div>
{/* Column 2: Letzte/Nächste N */}
<div className={styles.colLastN}>
<h4 className={styles.colTitle}>{t('Letzte oder Nächste N')}</h4>
<div className={styles.lastNRow}>
<div className={styles.seg}>
<button
type="button"
className={`${styles.segBtn} ${lastNDirection === 'last' ? styles.on : ''}`}
disabled={isPresetDisabled('lastN', constraints)}
onClick={() => setLastNDirection('last')}
>
{t('Letzte')}
</button>
<button
type="button"
className={`${styles.segBtn} ${lastNDirection === 'next' ? styles.on : ''}`}
disabled={isPresetDisabled('nextN', constraints)}
onClick={() => setLastNDirection('next')}
>
{t('Nächste')}
</button>
</div>
</div>
<div className={styles.lastNRow}>
<input
type="number"
min={1}
className={styles.numInput}
value={lastNAmount}
onChange={(e) => setLastNAmount(parseInt(e.target.value, 10) || 1)}
/>
<select
className={styles.unitSelect}
value={lastNUnit}
onChange={(e) => setLastNUnit(e.target.value as PeriodUnit)}
>
<option value="day">{_unitLabel('day', t)}</option>
<option value="week">{_unitLabel('week', t)}</option>
<option value="month">{_unitLabel('month', t)}</option>
<option value="year">{_unitLabel('year', t)}</option>
</select>
</div>
<button
type="button"
className={styles.applyN}
onClick={_applyLastN}
disabled={
(lastNDirection === 'last' && isPresetDisabled('lastN', constraints))
|| (lastNDirection === 'next' && isPresetDisabled('nextN', constraints))
}
>
{t('Übernehmen')}
</button>
</div>
{/* Column 3: Calendar */}
<PeriodPickerCalendar
anchor={calAnchor}
onAnchorChange={setCalAnchor}
range={rangePick}
onPickDate={_onPickDate}
constraints={constraints}
/>
</div>
{/* Footer */}
<div className={styles.footer}>
<span className={styles.footerLabel}>{t('Von')}</span>
<input
type="date"
className={styles.footerInput}
value={draft.fromDate}
min={constraints.minDate}
max={constraints.maxDate}
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}
onChange={(e) => _onFooterToChange(e.target.value)}
/>
<span className={styles.spacer} />
<button type="button" className={styles.btnGhost} onClick={onCancel}>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={() => onApply(draft)}
disabled={!draft.fromDate || !draft.toDate || draft.fromDate > draft.toDate}
>
{t('Übernehmen')}
</button>
</div>
</div>
);
};
export default PeriodPickerPopover;

View file

@ -0,0 +1,68 @@
/**
* PeriodPicker - shared type definitions.
*
* The component carries a *semantic* preset alongside the resolved date pair.
* Semantic presets (e.g. `ytd`, `last12Months`) are re-resolved on every render
* via `resolvePeriod` so dashboards stay fresh when revisited.
*/
export type PeriodUnit = 'day' | 'week' | 'month' | 'year';
export type PeriodPresetKind =
| 'ytd'
| 'lastYear'
| 'nextYear'
| 'last12Months'
| 'next12Months'
| 'thisMonth'
| 'lastMonth'
| 'thisQuarter'
| 'lastQuarter'
| 'lastN'
| 'nextN'
| 'custom';
export type PeriodPreset =
| { kind: 'ytd' }
| { kind: 'lastYear' }
| { kind: 'nextYear' }
| { kind: 'last12Months' }
| { kind: 'next12Months' }
| { kind: 'thisMonth' }
| { kind: 'lastMonth' }
| { kind: 'thisQuarter' }
| { kind: 'lastQuarter' }
| { kind: 'lastN'; amount: number; unit: PeriodUnit }
| { kind: 'nextN'; amount: number; unit: PeriodUnit }
| { kind: 'custom' };
export interface PeriodValue {
preset: PeriodPreset;
/** ISO `YYYY-MM-DD` (no time, no timezone). */
fromDate: string;
/** ISO `YYYY-MM-DD` (no time, no timezone). */
toDate: string;
}
export type PeriodDirection = 'past' | 'future' | 'any';
export interface PeriodConstraints {
direction?: PeriodDirection;
/** ISO `YYYY-MM-DD`. */
minDate?: string;
/** ISO `YYYY-MM-DD`. */
maxDate?: string;
/** Whitelist of allowed preset kinds; if omitted, all are allowed. */
enabledPresets?: PeriodPresetKind[];
}
export interface PeriodPickerProps extends PeriodConstraints {
value: PeriodValue | null;
onChange: (next: PeriodValue) => void;
/** Used as initial value if `value` is null. */
defaultPreset?: PeriodPreset;
placeholder?: string;
disabled?: boolean;
/** Optional inline className for the trigger button wrapper. */
className?: string;
}

View file

@ -0,0 +1,21 @@
export { PeriodPicker, default } from './PeriodPicker';
export type {
PeriodDirection,
PeriodPickerProps,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
PeriodConstraints,
} from './PeriodPickerTypes';
export {
daysInRange,
formatIsoDateDe,
fromIsoDate,
isPresetDisabled,
isValueAllowed,
presetLiteralKey,
resolvePeriod,
toIsoDate,
todayDate,
} from './PeriodPickerLogic';

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,8 @@ import {
type CreditAddRequest,
type CheckoutCreateRequest,
type MandateUserSummary,
type StatisticsRangeRequest,
type BillingBucketSize,
} from '../api/billingApi';
// Re-export types
@ -41,6 +43,8 @@ export type {
AccountSummary,
CreditAddRequest,
MandateUserSummary,
StatisticsRangeRequest,
BillingBucketSize,
};
export type { TransactionType, ReferenceType } from '../api/billingApi';
@ -91,14 +95,9 @@ export function useBilling() {
}
}, [request]);
// Fetch statistics
const loadStatistics = useCallback(async (
period: 'day' | 'month' | 'year',
year: number,
month?: number
) => {
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
try {
const data = await fetchStatistics(request, period, year, month);
const data = await fetchStatistics(request, range);
setStatistics(data);
return data;
} catch (err) {

116
src/hooks/usePeriod.ts Normal file
View file

@ -0,0 +1,116 @@
/**
* usePeriod - state hook for the PeriodPicker.
*
* Persists the selection in the URL (`useSearchParams`) so navigation, page
* reloads and `PageManager` state preservation all keep the chosen range.
*
* URL keys (configurable via `paramKey` -> `${paramKey}Preset|From|To`):
* - `periodPreset` = preset.kind
* - `periodFrom` = ISO YYYY-MM-DD
* - `periodTo` = ISO YYYY-MM-DD
* - `periodAmount` / `periodUnit` (only for `lastN` / `nextN`)
*/
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
resolvePeriod,
type PeriodPreset,
type PeriodPresetKind,
type PeriodUnit,
type PeriodValue,
} from '../components/PeriodPicker';
const _PRESET_KINDS: PeriodPresetKind[] = [
'ytd', 'lastYear', 'nextYear', 'last12Months', 'next12Months',
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', 'lastN', 'nextN', 'custom',
];
const _UNITS: PeriodUnit[] = ['day', 'week', 'month', 'year'];
interface UsePeriodOptions {
/** URL prefix for params (default: "period"). Use a unique value per page if multiple pickers coexist. */
paramKey?: string;
/** Initial preset if URL is empty. Default: { kind: 'ytd' }. */
defaultPreset?: PeriodPreset;
}
interface UsePeriodReturn {
value: PeriodValue;
setValue: (next: PeriodValue) => void;
reset: () => void;
}
function _parsePresetFromParams(params: URLSearchParams, key: string): PeriodPreset | null {
const kind = params.get(`${key}Preset`) as PeriodPresetKind | null;
if (!kind || !_PRESET_KINDS.includes(kind)) return null;
if (kind === 'lastN' || kind === 'nextN') {
const amount = parseInt(params.get(`${key}Amount`) || '', 10);
const unit = params.get(`${key}Unit`) as PeriodUnit | null;
if (!amount || amount < 1 || !unit || !_UNITS.includes(unit)) return null;
return { kind, amount, unit };
}
return { kind } as PeriodPreset;
}
export function usePeriod(options: UsePeriodOptions = {}): UsePeriodReturn {
const paramKey = options.paramKey || 'period';
const defaultPreset: PeriodPreset = options.defaultPreset || { kind: 'ytd' };
const [searchParams, setSearchParams] = useSearchParams();
const value: PeriodValue = useMemo(() => {
const parsedPreset = _parsePresetFromParams(searchParams, paramKey);
if (parsedPreset) {
if (parsedPreset.kind === 'custom') {
const fromDate = searchParams.get(`${paramKey}From`) || '';
const toDate = searchParams.get(`${paramKey}To`) || '';
if (fromDate && toDate) return { preset: parsedPreset, fromDate, toDate };
} else {
const r = resolvePeriod(parsedPreset);
return { preset: parsedPreset, fromDate: r.fromDate, toDate: r.toDate };
}
}
const r = resolvePeriod(defaultPreset);
return { preset: defaultPreset, fromDate: r.fromDate, toDate: r.toDate };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, paramKey]);
const setValue = useCallback((next: PeriodValue) => {
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set(`${paramKey}Preset`, next.preset.kind);
if (next.preset.kind === 'lastN' || next.preset.kind === 'nextN') {
params.set(`${paramKey}Amount`, String(next.preset.amount));
params.set(`${paramKey}Unit`, next.preset.unit);
params.delete(`${paramKey}From`);
params.delete(`${paramKey}To`);
} else if (next.preset.kind === 'custom') {
params.set(`${paramKey}From`, next.fromDate);
params.set(`${paramKey}To`, next.toDate);
params.delete(`${paramKey}Amount`);
params.delete(`${paramKey}Unit`);
} else {
params.delete(`${paramKey}From`);
params.delete(`${paramKey}To`);
params.delete(`${paramKey}Amount`);
params.delete(`${paramKey}Unit`);
}
return params;
}, { replace: true });
}, [setSearchParams, paramKey]);
const reset = useCallback(() => {
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.delete(`${paramKey}Preset`);
params.delete(`${paramKey}From`);
params.delete(`${paramKey}To`);
params.delete(`${paramKey}Amount`);
params.delete(`${paramKey}Unit`);
return params;
}, { replace: true });
}, [setSearchParams, paramKey]);
return { value, setValue, reset };
}

View file

@ -55,6 +55,21 @@ import {
createPosition as createPositionApi,
updatePosition as updatePositionApi,
deletePosition as deletePositionApi,
// Read-only data table API (Daten-Tabellen page)
fetchDataAccounts as fetchDataAccountsApi,
fetchDataJournalEntries as fetchDataJournalEntriesApi,
fetchDataJournalLines as fetchDataJournalLinesApi,
fetchDataContacts as fetchDataContactsApi,
fetchDataAccountBalances as fetchDataAccountBalancesApi,
fetchAccountingConfigs as fetchAccountingConfigsApi,
fetchAccountingSyncs as fetchAccountingSyncsApi,
type TrusteeDataAccount,
type TrusteeDataJournalEntry,
type TrusteeDataJournalLine,
type TrusteeDataContact,
type TrusteeDataAccountBalance,
type TrusteeAccountingConfigRecord,
type TrusteeAccountingSyncRecord,
} from '../api/trusteeApi';
export type {
@ -569,3 +584,61 @@ export const useTrusteePositionOperations = _createTrusteeOperationsHook(positio
export { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from './useTrusteePositionDocuments';
export type { TrusteePositionDocument } from '../api/trusteeApi';
// ============================================================================
// READ-ONLY DATA TABLE HOOKS (Daten-Tabellen page)
// ============================================================================
//
// These hooks expose synced/operational tables in read-only form. Mutations
// would be overwritten by the next accounting sync, so create/update/delete
// are intentionally not implemented (`_readOnlyMutator` raises if called).
function _readOnlyMutator(): never {
throw new Error('Read-only entity: mutations are not supported via this hook.');
}
function _buildReadOnlyConfig<T extends { id: string }>(
entityName: string,
fetchAll: TrusteeEntityConfig<T>['fetchAll']
): TrusteeEntityConfig<T> {
return {
entityName,
fetchAll,
fetchById: async () => null,
create: _readOnlyMutator as any,
update: _readOnlyMutator as any,
deleteItem: _readOnlyMutator as any,
};
}
export const useTrusteeDataAccounts = _createTrusteeEntityHook(
_buildReadOnlyConfig<TrusteeDataAccount>('TrusteeDataAccount', fetchDataAccountsApi)
);
export const useTrusteeDataJournalEntries = _createTrusteeEntityHook(
_buildReadOnlyConfig<TrusteeDataJournalEntry>('TrusteeDataJournalEntry', fetchDataJournalEntriesApi)
);
export const useTrusteeDataJournalLines = _createTrusteeEntityHook(
_buildReadOnlyConfig<TrusteeDataJournalLine>('TrusteeDataJournalLine', fetchDataJournalLinesApi)
);
export const useTrusteeDataContacts = _createTrusteeEntityHook(
_buildReadOnlyConfig<TrusteeDataContact>('TrusteeDataContact', fetchDataContactsApi)
);
export const useTrusteeDataAccountBalances = _createTrusteeEntityHook(
_buildReadOnlyConfig<TrusteeDataAccountBalance>('TrusteeDataAccountBalance', fetchDataAccountBalancesApi)
);
export const useTrusteeAccountingConfigs = _createTrusteeEntityHook(
_buildReadOnlyConfig<TrusteeAccountingConfigRecord>('TrusteeAccountingConfig', fetchAccountingConfigsApi)
);
export const useTrusteeAccountingSyncs = _createTrusteeEntityHook(
_buildReadOnlyConfig<TrusteeAccountingSyncRecord>('TrusteeAccountingSync', fetchAccountingSyncsApi)
);
export type {
TrusteeDataAccount,
TrusteeDataJournalEntry,
TrusteeDataJournalLine,
TrusteeDataContact,
TrusteeDataAccountBalance,
TrusteeAccountingConfigRecord,
TrusteeAccountingSyncRecord,
};

View file

@ -18,9 +18,16 @@ import { useLanguage } from '../providers/language/LanguageContext';
import { useUserMandates } from '../hooks/useUserMandates';
import { useConfirm } from '../hooks/useConfirm';
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
import {
PeriodPicker,
resolvePeriod,
type PeriodValue,
} from '../components/PeriodPicker';
import styles from './ComplianceAuditPage.module.css';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
const _DEFAULT_STATS_PRESET = { kind: 'lastN' as const, amount: 30, unit: 'day' as const };
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
const _CATEGORY_COLORS: Record<string, string> = {
@ -153,7 +160,10 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Tab C state ──
const [stats, setStats] = useState<AuditStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [statsRange, setStatsRange] = useState(30);
const [statsPeriod, setStatsPeriod] = useState<PeriodValue>(() => {
const r = resolvePeriod(_DEFAULT_STATS_PRESET);
return { preset: _DEFAULT_STATS_PRESET, fromDate: r.fromDate, toDate: r.toDate };
});
// ── Tab D: Neutralization Mappings state ──
const [neutEntries, setNeutEntries] = useState<any[]>([]);
@ -254,12 +264,12 @@ export const ComplianceAuditPage: React.FC = () => {
// ── Tab C loader ──
const _loadStats = useCallback(async (days = 30) => {
const _loadStats = useCallback(async (range: { dateFrom: string; dateTo: string }) => {
if (!selectedMandateId) return;
setStatsLoading(true);
try {
const { data } = await api.get('/api/audit/stats', {
params: { timeRange: days },
params: { dateFrom: range.dateFrom, dateTo: range.dateTo },
headers: _mandateHeaders(),
});
setStats(data ?? null);
@ -341,7 +351,7 @@ export const ComplianceAuditPage: React.FC = () => {
if (!selectedMandateId) return;
if (activeTab === 'ai-log') void _loadAiLog();
else if (activeTab === 'audit-log') void _loadAuditLog();
else if (activeTab === 'stats') void _loadStats(statsRange);
else if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate });
else if (activeTab === 'neutralization') void _loadNeutMappings();
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
@ -641,15 +651,20 @@ export const ComplianceAuditPage: React.FC = () => {
{activeTab === 'stats' && (
<div className={styles.tabContentScrollable}>
<div className={styles.statsControls}>
{[7, 30, 90].map(d => (
<button
key={d}
className={`${styles.rangeBtn} ${statsRange === d ? styles.rangeBtnActive : ''}`}
onClick={() => { setStatsRange(d); void _loadStats(d); }}
>
{t('{n} Tage', { n: String(d) })}
</button>
))}
<PeriodPicker
value={statsPeriod}
onChange={(next) => {
setStatsPeriod(next);
void _loadStats({ dateFrom: next.fromDate, dateTo: next.toDate });
}}
direction="past"
defaultPreset={_DEFAULT_STATS_PRESET}
enabledPresets={[
'lastN', 'last12Months', 'lastYear',
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
'ytd', 'custom',
]}
/>
</div>
{statsLoading ? (

View file

@ -10,15 +10,15 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
// Trustee Views
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
// Note: TrusteePositionsView/TrusteeDocumentsView are no longer mounted directly here -
// they live as tabs inside TrusteeDataTablesView (and that file imports them).
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
import { TrusteeScanUploadView } from './views/trustee/TrusteeScanUploadView';
import { TrusteeImportProcessView } from './views/trustee/TrusteeImportProcessView';
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView';
// Chatbot Views
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
@ -121,11 +121,9 @@ type ViewComponent = React.FC;
const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
trustee: {
dashboard: TrusteeDashboardView,
documents: TrusteeDocumentsView,
positions: TrusteePositionsView,
'data-tables': TrusteeDataTablesView,
'instance-roles': TrusteeInstanceRolesView,
'expense-import': TrusteeExpenseImportView,
'scan-upload': TrusteeScanUploadView,
'import-process': TrusteeImportProcessView,
settings: TrusteeAccountingSettingsView,
analyse: TrusteeAnalyseView,
abschluss: TrusteeAbschlussView,

View file

@ -10,7 +10,7 @@
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle } from 'react-icons/fa';
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
@ -44,6 +44,10 @@ interface OrphanEntry {
targetTable: string;
targetColumn: string;
orphanCount: number;
sourceRowCount?: number;
targetRowCount?: number;
targetEmpty?: boolean;
wouldDeleteAll?: boolean;
}
interface CleanResult {
@ -52,6 +56,7 @@ interface CleanResult {
column: string;
deleted: number;
error?: string;
skipped?: string;
}
interface PaginationParams {
@ -367,6 +372,7 @@ const OrphansTab: React.FC = () => {
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
const [loading, setLoading] = useState(false);
const [cleaning, setCleaning] = useState<string | null>(null);
const [downloading, setDownloading] = useState<string | null>(null);
const [cleaningAll, setCleaningAll] = useState(false);
const [onlyProblems, setOnlyProblems] = useState(true);
const [dbFilter, setDbFilter] = useState<string>('');
@ -404,46 +410,131 @@ const OrphansTab: React.FC = () => {
const totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]);
const _cleanOne = async (o: OrphanEntry) => {
const ok = await confirm(
t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn }),
{ title: t('Orphans bereinigen'), variant: 'danger' },
);
if (!ok) return;
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
setCleaning(key);
const _postCleanOne = async (o: OrphanEntry, force: boolean): Promise<number | 'refused'> => {
try {
const res = await api.post('/api/admin/database-health/orphans/clean', {
db: o.sourceDb,
table: o.sourceTable,
column: o.sourceColumn,
force,
});
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: res.data.deleted }));
return res.data.deleted as number;
} catch (err: any) {
const status = err?.response?.status;
const detail = err?.response?.data?.detail;
if (status === 409 && detail?.refused) {
return 'refused';
}
const reason = typeof detail === 'string' ? detail : (detail?.reason || t('Fehler beim Bereinigen'));
throw new Error(reason);
}
};
const _downloadOne = async (o: OrphanEntry) => {
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
setDownloading(key);
try {
const res = await api.get('/api/admin/database-health/orphans/list', {
params: {
db: o.sourceDb,
table: o.sourceTable,
column: o.sourceColumn,
limit: 5000,
},
});
const payload = {
sourceDb: o.sourceDb,
sourceTable: o.sourceTable,
sourceColumn: o.sourceColumn,
targetDb: o.targetDb,
targetTable: o.targetTable,
targetColumn: o.targetColumn,
scannedOrphanCount: o.orphanCount,
downloadedAt: new Date().toISOString(),
...res.data,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const ts = new Date().toISOString().replace(/[:.]/g, '-');
a.href = url;
a.download = `orphans_${o.sourceDb}_${o.sourceTable}_${o.sourceColumn}_${ts}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.showSuccess(t('{count} verwaiste Datensätze heruntergeladen', { count: res.data?.count ?? 0 }));
} catch (err: any) {
const detail = err?.response?.data?.detail;
const reason = typeof detail === 'string' ? detail : (detail?.reason || t('Fehler beim Download'));
toast.showError(reason);
} finally {
setDownloading(null);
}
};
const _cleanOne = async (o: OrphanEntry) => {
const baseMsg = t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn });
const warning = (o.targetEmpty || o.wouldDeleteAll)
? '\n\n' + t('WARNUNG: Target-Tabelle {target} ist leer oder die Bereinigung würde alle Source-Zeilen löschen. Das ist meist eine Fehlkonfiguration!', { target: `${o.targetDb}.${o.targetTable}` })
: '';
const ok = await confirm(baseMsg + warning, { title: t('Orphans bereinigen'), variant: 'danger' });
if (!ok) return;
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
setCleaning(key);
try {
let result = await _postCleanOne(o, false);
if (result === 'refused') {
const forceOk = await confirm(
t('Sicherheits-Check ausgelöst (leere Target-Tabelle oder >50% der Source würden gelöscht). Trotzdem mit force=true bereinigen?'),
{ title: t('Bereinigung erzwingen?'), variant: 'danger' },
);
if (!forceOk) {
toast.showInfo(t('Bereinigung abgebrochen'));
return;
}
result = await _postCleanOne(o, true);
}
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: result as number }));
_fetchOrphans();
} catch (err: any) {
toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
toast.showError(err?.message || t('Fehler beim Bereinigen'));
} finally {
setCleaning(null);
}
};
const _cleanAll = async () => {
const _cleanAll = async (force: boolean = false) => {
const ok = await confirm(
t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', {
count: totalOrphans,
relations: allOrphans.filter(o => o.orphanCount > 0).length,
}),
}) + (force ? '\n\n' + t('FORCE-Modus: Sicherheits-Checks werden ignoriert!') : ''),
{ title: t('Alle Orphans bereinigen'), variant: 'danger' },
);
if (!ok) return;
setCleaningAll(true);
try {
const res = await api.post('/api/admin/database-health/orphans/clean-all');
const res = await api.post('/api/admin/database-health/orphans/clean-all', { force });
const results: CleanResult[] = res.data.results || [];
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
const errors = results.filter(r => r.error);
const skipped = results.filter(r => r.skipped);
if (skipped.length > 0 && !force) {
const retryOk = await confirm(
t('{deleted} gelöscht. {skipped} Bereinigungen wurden vom Sicherheits-Check abgelehnt (leere Target-Tabelle oder >50% Löschung). Mit force=true erneut versuchen?', { deleted: totalDeleted, skipped: skipped.length }),
{ title: t('Force benötigt'), variant: 'danger' },
);
if (retryOk) {
setCleaningAll(false);
await _cleanAll(true);
return;
}
}
if (errors.length > 0) {
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted: totalDeleted, errors: errors.length }));
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler, {skipped} übersprungen', { deleted: totalDeleted, errors: errors.length, skipped: skipped.length }));
} else if (skipped.length > 0) {
toast.showWarning(t('{deleted} gelöscht, {skipped} übersprungen', { deleted: totalDeleted, skipped: skipped.length }));
} else {
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted }));
}
@ -550,7 +641,7 @@ const OrphansTab: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
</button>
{totalOrphans > 0 && (
<button className={styles.dangerButton} onClick={_cleanAll} disabled={cleaningAll || loading}>
<button className={styles.dangerButton} onClick={() => _cleanAll(false)} disabled={cleaningAll || loading}>
<FaBroom className={cleaningAll ? 'spinning' : ''} /> {t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
</button>
)}
@ -579,6 +670,14 @@ const OrphansTab: React.FC = () => {
pageSize={50}
selectable={false}
customActions={[
{
id: 'download',
icon: <FaDownload />,
onClick: (row: OrphanEntry) => _downloadOne(row),
visible: (row: OrphanEntry) => row.orphanCount > 0,
loading: (row: OrphanEntry) => downloading === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}`,
title: t('Orphan-Liste herunterladen (JSON)'),
},
{
id: 'clean',
icon: <FaTrashAlt />,

View file

@ -10,7 +10,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
import type { CheckoutCreateRequest } from '../../api/billingApi';
import { fetchCheckoutAmounts, type CheckoutCreateRequest } from '../../api/billingApi';
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
import { useCurrentUser } from '../../hooks/useUsers';
import { SubscriptionTab } from './SubscriptionTab';
@ -369,6 +369,28 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
const [amount, setAmount] = useState('');
const [busy, setBusy] = useState(false);
const [localMsg, setLocalMsg] = useState<string | null>(null);
const [allowedAmounts, setAllowedAmounts] = useState<number[]>([]);
useEffect(() => {
let cancelled = false;
const _request = async (opts: any) => {
const res = await api.request({ url: opts.url, method: opts.method, data: opts.data, params: opts.params });
return res.data;
};
fetchCheckoutAmounts(_request)
.then(list => {
if (cancelled) return;
const sorted = [...list].sort((a, b) => a - b);
setAllowedAmounts(sorted);
if (sorted.length > 0) {
setAmount(String(sorted[0]));
}
})
.catch(() => {
if (!cancelled) setAllowedAmounts([]);
});
return () => { cancelled = true; };
}, []);
const _handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -377,6 +399,10 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
setLocalMsg('Betrag muss positiv sein');
return;
}
if (allowedAmounts.length > 0 && !allowedAmounts.includes(n)) {
setLocalMsg(t('Ungültiger Betrag. Erlaubt: {list} CHF', { list: allowedAmounts.join(', ') }));
return;
}
setBusy(true);
setLocalMsg(null);
try {
@ -412,22 +438,34 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label>{t('Betrag (CHF)')}</label>
<input
type="number"
className={styles.input}
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder={t('z. B. 50 oder -20')}
min="0.01"
step="0.01"
required
/>
{allowedAmounts.length > 0 ? (
<select
className={styles.input}
value={amount}
onChange={e => setAmount(e.target.value)}
required
>
{allowedAmounts.map(a => (
<option key={a} value={String(a)}>
{new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(a)}
</option>
))}
</select>
) : (
<input
type="text"
className={styles.input}
value={t('Lade erlaubte Beträge…')}
readOnly
disabled
/>
)}
</div>
</div>
<button
type="submit"
className={`${styles.button} ${styles.buttonPrimary}`}
disabled={busy || !amount}
disabled={busy || !amount || allowedAmounts.length === 0}
>
{busy ? t('Weiterleitung…') : t('Mit Stripe bezahlen')}
</button>

View file

@ -5,11 +5,26 @@
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
import { useBilling, type BillingBalance, type UsageReport, type BillingBucketSize } from '../../hooks/useBilling';
import { BillingNav } from './BillingNav';
import styles from './Billing.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import {
PeriodPicker,
resolvePeriod,
daysInRange,
type PeriodValue,
} from '../../components/PeriodPicker';
const _DEFAULT_BILLING_PRESET = { kind: 'thisMonth' as const };
function _suggestBucketSize(value: PeriodValue): BillingBucketSize {
const days = daysInRange(value.fromDate, value.toDate);
if (days <= 62) return 'day';
if (days <= 24 * 31) return 'month';
return 'year';
}
// ============================================================================
// BALANCE CARD COMPONENT
@ -154,37 +169,41 @@ const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }
export const BillingDashboard: React.FC = () => {
const { t } = useLanguage();
const {
balances,
statistics,
loading,
loadStatistics
const {
balances,
statistics,
loading,
loadStatistics
} = useBilling();
const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month');
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
// Load statistics when period changes
useEffect(() => {
if (selectedPeriod === 'month') {
loadStatistics('month', selectedYear);
} else {
loadStatistics('year', selectedYear);
const [period, setPeriod] = useState<PeriodValue>(() => {
const r = resolvePeriod(_DEFAULT_BILLING_PRESET);
return { preset: _DEFAULT_BILLING_PRESET, fromDate: r.fromDate, toDate: r.toDate };
});
// Frontend-Heuristik fuer Default; user kann uebersteuern.
const [bucketSize, setBucketSize] = useState<BillingBucketSize>(() => _suggestBucketSize(period));
const [bucketUserOverridden, setBucketUserOverridden] = useState(false);
const _handlePeriodChange = (next: PeriodValue) => {
setPeriod(next);
if (!bucketUserOverridden) {
setBucketSize(_suggestBucketSize(next));
}
}, [selectedPeriod, selectedYear, loadStatistics]);
// Available years (current and last 2 years)
const availableYears = useMemo(() => {
const current = new Date().getFullYear();
return [current, current - 1, current - 2];
}, []);
// Available months
const availableMonths = Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: String(i + 1),
}));
};
useEffect(() => {
void loadStatistics({
dateFrom: period.fromDate,
dateTo: period.toDate,
bucketSize,
});
}, [period.fromDate, period.toDate, bucketSize, loadStatistics]);
const _bucketLabel = useMemo(() => ({
day: t('Tag'),
month: t('Monat'),
year: t('Jahr'),
} as Record<BillingBucketSize, string>), [t]);
return (
<div className={styles.billingDashboard}>
@ -216,34 +235,30 @@ export const BillingDashboard: React.FC = () => {
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>{t('Nutzungsstatistik')}</h2>
<div className={styles.periodSelector}>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value as 'month' | 'year')}
<PeriodPicker
value={period}
onChange={_handlePeriodChange}
direction="past"
defaultPreset={_DEFAULT_BILLING_PRESET}
enabledPresets={[
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
]}
/>
<label className={styles.bucketLabel}>{t('Gruppierung')}</label>
<select
value={bucketSize}
onChange={(e) => {
setBucketSize(e.target.value as BillingBucketSize);
setBucketUserOverridden(true);
}}
className={styles.select}
aria-label={t('Gruppierung')}
>
<option value="month">{t('Monat')}</option>
<option value="year">{t('Jahr')}</option>
<option value="day">{_bucketLabel.day}</option>
<option value="month">{_bucketLabel.month}</option>
<option value="year">{_bucketLabel.year}</option>
</select>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(Number(e.target.value))}
className={styles.select}
>
{availableYears.map((year) => (
<option key={year} value={year}>{year}</option>
))}
</select>
{selectedPeriod === 'month' && (
<select
value={selectedMonth}
onChange={(e) => setSelectedMonth(Number(e.target.value))}
className={styles.select}
>
{availableMonths.map((month) => (
<option key={month.value} value={month.value}>{month.label}</option>
))}
</select>
)}
</div>
</div>
<StatisticsChart statistics={statistics} loading={loading} />

View file

@ -11,14 +11,34 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
import type { ReportSection, ReportFilterState, ReportChartDataPoint, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport';
import api from '../../api';
import { useBilling } from '../../hooks/useBilling';
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
import { UserTransaction } from '../../api/billingApi';
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
import { useLanguage } from '../../providers/language/LanguageContext';
import {
daysInRange,
resolvePeriod,
toIsoDate,
type PeriodValue,
} from '../../components/PeriodPicker';
import styles from './Billing.module.css';
const _DEFAULT_STATS_PRESET = { kind: 'thisMonth' as const };
function _suggestBucketSize(fromIso: string, toIso: string): BillingBucketSize {
const days = daysInRange(fromIso, toIso);
if (days <= 62) return 'day';
if (days <= 24 * 31) return 'month';
return 'year';
}
function _initialStatsPeriod(): PeriodValue {
const r = resolvePeriod(_DEFAULT_STATS_PRESET);
return { preset: _DEFAULT_STATS_PRESET, fromDate: r.fromDate, toDate: r.toDate };
}
type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
// ============================================================================
@ -336,14 +356,24 @@ export const BillingDataView: React.FC = () => {
return params;
}, [selectedScope, onlyMyData]);
// Load aggregated statistics from the view/statistics route
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
const [statsPeriod, setStatsPeriod] = useState<PeriodValue>(() => _initialStatsPeriod());
const [statsBucketSize, setStatsBucketSize] = useState<BillingBucketSize>(() => {
const init = _initialStatsPeriod();
return _suggestBucketSize(init.fromDate, init.toDate);
});
const [bucketUserOverridden, setBucketUserOverridden] = useState(false);
const _loadViewStatistics = useCallback(async (
range: { dateFrom: string; dateTo: string; bucketSize: BillingBucketSize },
) => {
try {
setStatsLoading(true);
const params: Record<string, string | number> = { period, year, ..._scopeParams };
if (period === 'day' && month) {
params.month = month;
}
const params: Record<string, string | number> = {
dateFrom: range.dateFrom,
dateTo: range.dateTo,
bucketSize: range.bucketSize,
..._scopeParams,
};
const response = await api.get('/api/billing/view/statistics', { params });
setViewStats(response.data);
} catch (err: any) {
@ -354,13 +384,26 @@ export const BillingDataView: React.FC = () => {
}
}, [_scopeParams]);
// Handle filter changes from FormGeneratorReport (user changes period/year/month)
// Handle PeriodPicker change coming back from the shared `dateRangeSelector`
// of `FormGeneratorReport`. Prefer the full `periodValue` so we keep the
// original preset (e.g. `thisMonth`) instead of collapsing to `custom`.
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
const period = filterState.period || 'month';
const year = filterState.year || new Date().getFullYear();
const month = filterState.month;
_loadViewStatistics(period, year, month);
}, [_loadViewStatistics]);
let next: PeriodValue | null = null;
if (filterState.periodValue) {
next = filterState.periodValue;
} else if (filterState.dateRange) {
next = {
preset: { kind: 'custom' },
fromDate: toIsoDate(filterState.dateRange.from),
toDate: toIsoDate(filterState.dateRange.to),
};
}
if (!next) return;
setStatsPeriod(next);
if (!bucketUserOverridden) {
setStatsBucketSize(_suggestBucketSize(next.fromDate, next.toDate));
}
}, [bucketUserOverridden]);
// Load storage volume for all accessible mandates
const _loadStorageData = useCallback(async () => {
@ -398,13 +441,17 @@ export const BillingDataView: React.FC = () => {
}
}, [balances, selectedScope, onlyMyData]);
// Initial data load
// Initial / reactive load: any change to period / bucketSize / scope reloads.
useEffect(() => {
if (activeTab === 'overview' || activeTab === 'diagrams') {
_loadViewStatistics('month', new Date().getFullYear());
void _loadViewStatistics({
dateFrom: statsPeriod.fromDate,
dateTo: statsPeriod.toDate,
bucketSize: statsBucketSize,
});
_loadStorageData();
}
}, [activeTab, _loadViewStatistics, _loadStorageData]);
}, [activeTab, statsPeriod.fromDate, statsPeriod.toDate, statsBucketSize, _loadViewStatistics, _loadStorageData]);
// Load transactions with pagination support + scope filter
const _loadTransactions = useCallback(async (paginationParams?: any) => {
@ -499,14 +546,15 @@ export const BillingDataView: React.FC = () => {
return _buildDiagramSections(viewStats, chartMode, t);
}, [viewStats, chartMode, t]);
// Period selector config (shared between overview and statistics)
const periodSelectorConfig = useMemo(() => ({
periods: ['month' as const, 'day' as const],
defaultPeriod: 'month' as const,
showYear: true,
showMonth: true,
defaultYear: new Date().getFullYear(),
defaultMonth: new Date().getMonth() + 1
// Date-range selector config: use shared PeriodPicker via FormGeneratorReport.
const dateRangeSelectorConfig = useMemo<ReportDateRangeSelectorConfig>(() => ({
enabled: true,
direction: 'past',
defaultPresetKind: 'thisMonth',
enabledPresets: [
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
],
}), []);
// Build scope options from balances (mandates the user has access to)
@ -627,8 +675,24 @@ export const BillingDataView: React.FC = () => {
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<label style={{ fontSize: '13px', opacity: 0.7 }}>{t('Gruppierung')}</label>
<select
className={styles.select || ''}
value={statsBucketSize}
onChange={(e) => {
setStatsBucketSize(e.target.value as BillingBucketSize);
setBucketUserOverridden(true);
}}
aria-label={t('Gruppierung')}
>
<option value="day">{t('Tag')}</option>
<option value="month">{t('Monat')}</option>
<option value="year">{t('Jahr')}</option>
</select>
</div>
<FormGeneratorReport
periodSelector={periodSelectorConfig}
dateRangeSelector={dateRangeSelectorConfig}
onFilterChange={_handleStatsFilterChange}
loading={statsLoading}
sections={diagramSections}

View file

@ -14,6 +14,13 @@ import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
import styles from './TrusteeViews.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
PeriodPicker,
resolvePeriod,
type PeriodValue,
} from '../../../components/PeriodPicker';
const _DEFAULT_PERIOD_PRESET = { kind: 'lastYear' as const };
// ---------------------------------------------------------------------------
// Tab definitions
@ -21,18 +28,23 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
interface TabDef {
id: string;
templateTag: string;
templateTag: string | null;
icon: string;
color: string;
comingSoon?: boolean;
}
const _TABS: TabDef[] = [
{ id: 'year-end', templateTag: 'template:trustee-year-end-check', icon: '\u2705', color: '#795548' },
{ id: 'vat', templateTag: null, icon: '\uD83E\uDDFE', color: '#9E9E9E', comingSoon: true },
{ id: 'reporting', templateTag: null, icon: '\uD83D\uDCEC', color: '#9E9E9E', comingSoon: true },
];
function _tabLabel(tabId: string, t: (k: string) => string): string {
switch (tabId) {
case 'year-end': return t('Jahresabschluss prüfen');
case 'vat': return t('MWST-Abrechnung');
case 'reporting': return t('Reporting Behörden');
default: return tabId;
}
}
@ -40,6 +52,8 @@ function _tabLabel(tabId: string, t: (k: string) => string): string {
function _tabDescription(tabId: string, t: (k: string) => string): string {
switch (tabId) {
case 'year-end': return t('Automatische Prüfungen für den Jahresabschluss: Saldovalidierung, Vorjahresvergleich, gesetzliche Checks.');
case 'vat': return t('Vierteljährliche MWST-Abrechnung vorbereiten und validieren.');
case 'reporting': return t('Meldungen an Behörden vorbereiten (z. B. Lohnausweise, Sozialversicherungen).');
default: return '';
}
}
@ -81,6 +95,11 @@ export const TrusteeAbschlussView: React.FC = () => {
const pollTimerRef = useRef<number | null>(null);
const isPollingRef = useRef(false);
const [period, setPeriod] = useState<PeriodValue>(() => {
const r = resolvePeriod(_DEFAULT_PERIOD_PRESET);
return { preset: _DEFAULT_PERIOD_PRESET, fromDate: r.fromDate, toDate: r.toDate };
});
useEffect(() => {
if (!instanceId) return;
const _load = async () => {
@ -104,8 +123,9 @@ export const TrusteeAbschlussView: React.FC = () => {
const _findWorkflow = useCallback((tab: string): WorkflowSummary | undefined => {
const tabDef = _TABS.find((tabItem) => tabItem.id === tab);
if (!tabDef) return undefined;
return workflows.find((w) => w.tags.includes(tabDef.templateTag));
if (!tabDef || !tabDef.templateTag) return undefined;
const templateTag = tabDef.templateTag;
return workflows.find((w) => w.tags.includes(templateTag));
}, [workflows]);
const _stopPolling = useCallback(() => {
@ -180,7 +200,10 @@ export const TrusteeAbschlussView: React.FC = () => {
setRunError(null);
setRunSummary(t('Workflow wird gestartet…'));
try {
const res = await api.post(`/api/workflows/${instanceId}/execute`, { workflowId: wf.id });
const res = await api.post(`/api/workflows/${instanceId}/execute`, {
workflowId: wf.id,
payload: { dateFrom: period.fromDate, dateTo: period.toDate },
});
const rid = res?.data?.runId;
if (rid) {
setRunId(rid);
@ -199,10 +222,11 @@ export const TrusteeAbschlussView: React.FC = () => {
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
}
}, [activeTab, instanceId, _findWorkflow, showError, showSuccess, t]);
}, [activeTab, instanceId, _findWorkflow, period, showError, showSuccess, t]);
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
const currentWorkflow = _findWorkflow(activeTab);
const isComingSoon = !!currentTab.comingSoon;
return (
<div className={styles.listView}>
@ -242,7 +266,14 @@ export const TrusteeAbschlussView: React.FC = () => {
{_tabDescription(activeTab, t)}
</p>
{workflowsLoading ? (
{isComingSoon ? (
<div className={styles.infoBox}>
<p>
<strong>{t('In Kürze verfügbar.')}</strong>{' '}
{t('Diese Funktion befindet sich in Vorbereitung.')}
</p>
</div>
) : workflowsLoading ? (
<p className={styles.loadingText}>{t('Workflows werden geladen…')}</p>
) : !currentWorkflow ? (
<div className={styles.infoBox}>
@ -260,6 +291,18 @@ export const TrusteeAbschlussView: React.FC = () => {
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
<label style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--text-secondary, #4A5568)' }}>
{t('Geschäftsjahr')}
</label>
<PeriodPicker
value={period}
onChange={setPeriod}
direction="past"
defaultPreset={_DEFAULT_PERIOD_PRESET}
/>
</div>
<button
className={styles.primaryButton}
onClick={_handleExecute}

View file

@ -7,6 +7,7 @@
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import { useToast } from '../../../contexts/ToastContext';
@ -23,13 +24,33 @@ import {
type AccountingConnectorInfo,
type AccountingConfig,
} from '../../../api/trusteeApi';
import { PeriodPicker, type PeriodValue } from '../../../components/PeriodPicker';
import styles from './TrusteeViews.module.css';
const _SETTINGS_TABS = [
{ id: 'settings', icon: '\u2699\uFE0F', color: '#2196F3' },
{ id: 'import-data', icon: '\u2B07\uFE0F', color: '#FF9800' },
];
function _settingsTabLabel(tabId: string, t: (k: string) => string): string {
switch (tabId) {
case 'settings': return t('Verbindungseinstellungen');
case 'import-data': return t('Buchhaltungsdaten importieren');
default: return tabId;
}
}
export const TrusteeAccountingSettingsView: React.FC = () => {
const { t } = useLanguage();
const { instanceId } = useCurrentInstance();
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get('tab') || _SETTINGS_TABS[0].id;
const _setActiveTab = useCallback((tab: string) => {
setSearchParams({ tab }, { replace: true });
}, [setSearchParams]);
const [connectors, setConnectors] = useState<AccountingConnectorInfo[]>([]);
const [existingConfig, setExistingConfig] = useState<AccountingConfig | null>(null);
@ -47,8 +68,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
const [importJobId, setImportJobId] = useState<string | null>(null);
const [clearingCache, setClearingCache] = useState(false);
const [exporting, setExporting] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [importPeriod, setImportPeriod] = useState<PeriodValue | null>(null);
const { confirm, ConfirmDialog } = useConfirm();
useEffect(() => {
@ -218,17 +238,44 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
<h3 className={styles.sectionTitle}>{t('Buchhaltungssystem-Anbindung')}</h3>
<p className={styles.sectionDescription}>
{t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}
</p>
{existingConfig?.configured && (
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
</div>
)}
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--border-color, #e0e0e0)', paddingBottom: 0 }}>
{_SETTINGS_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => _setActiveTab(tab.id)}
style={{
padding: '0.625rem 1rem',
border: 'none',
borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
background: 'transparent',
color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
fontWeight: activeTab === tab.id ? 600 : 400,
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.2s',
marginBottom: '-2px',
}}
>
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
{_settingsTabLabel(tab.id, t)}
</button>
))}
</div>
{existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
{activeTab === 'settings' && (
<>
<p className={styles.sectionDescription}>
{t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}
</p>
{existingConfig?.configured && (
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
</div>
)}
{existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
<div className={styles.stepContent}>
@ -366,12 +413,23 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
</div>
)}
{/* Step 4: Import Accounting Data */}
{existingConfig?.configured && (
<div className={styles.setupStep}>
<div className={styles.stepNumber}>4</div>
<div className={styles.stepContent}>
<h4>{t('Buchhaltungsdaten importieren')}</h4>
</>
)}
{activeTab === 'import-data' && (
<>
{!existingConfig?.configured && (
<div className={styles.infoBox}>
<p>
{t('Bevor Sie Daten importieren können, richten Sie zuerst die Verbindung zum Buchhaltungssystem im Tab «Verbindungseinstellungen» ein.')}
</p>
</div>
)}
{existingConfig?.configured && (
<div className={styles.setupStep}>
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
<div className={styles.stepContent}>
<h4 style={{ marginTop: 0 }}>{t('Buchhaltungsdaten importieren')}</h4>
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.75rem' }}>
{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}
</p>
@ -425,40 +483,16 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
);
})()}
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Von (optional)')}</label>
<input type="date" className={styles.folderSelect} value={dateFrom} onChange={e => setDateFrom(e.target.value)} style={{ width: '160px' }} />
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.25rem' }}>{t('Zeitraum (optional)')}</label>
<PeriodPicker
value={importPeriod}
onChange={setImportPeriod}
direction="past"
placeholder={t('Alle Daten')}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Bis (optional)')}</label>
<input type="date" className={styles.folderSelect} value={dateTo} onChange={e => setDateTo(e.target.value)} style={{ width: '160px' }} />
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{[
{ label: t('Laufendes Jahr'), from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) },
{
label: t('Letztes Jahr'),
from: `${new Date().getFullYear() - 1}-01-01`,
to: `${new Date().getFullYear() - 1}-12-31`,
},
{
label: t('Letzter Monat'),
from: (() => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); })(),
to: (() => { const d = new Date(); d.setDate(0); return d.toISOString().slice(0, 10); })(),
},
].map(s => (
<button
key={s.label}
type="button"
className={styles.secondaryButton}
style={{ fontSize: '0.75rem', padding: '0.25rem 0.6rem', minWidth: 0 }}
onClick={() => { setDateFrom(s.from); setDateTo(s.to); }}
>
{s.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
@ -472,8 +506,8 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
setImportJobId(null);
try {
const body: Record<string, string> = {};
if (dateFrom) body.dateFrom = dateFrom;
if (dateTo) body.dateTo = dateTo;
if (importPeriod?.fromDate) body.dateFrom = importPeriod.fromDate;
if (importPeriod?.toDate) body.dateTo = importPeriod.toDate;
const result = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
const newJobId: string | undefined = result?.jobId;
if (newJobId) {
@ -576,8 +610,10 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
)}
</div>
)}
</div>
</div>
</div>
</div>
)}
</>
)}
</div>
<ConfirmDialog />

View file

@ -7,7 +7,7 @@
* and results/status are shown inline with polling.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext';
@ -15,6 +15,13 @@ import api from '../../../api';
import styles from './TrusteeViews.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { FaUpload, FaTimes } from 'react-icons/fa';
import {
PeriodPicker,
resolvePeriod,
type PeriodDirection,
type PeriodPreset,
type PeriodValue,
} from '../../../components/PeriodPicker';
// ---------------------------------------------------------------------------
// Tab definitions
@ -54,6 +61,29 @@ function _tabDescription(tabId: string, t: (k: string) => string): string {
}
}
interface TabPeriodConfig {
defaultPreset: PeriodPreset;
direction: PeriodDirection;
}
function _periodConfigForTab(tabId: string): TabPeriodConfig {
switch (tabId) {
case 'forecast':
return { defaultPreset: { kind: 'next12Months' }, direction: 'future' };
case 'budget':
case 'kpi':
case 'cashflow':
default:
return { defaultPreset: { kind: 'ytd' }, direction: 'any' };
}
}
function _initialPeriodForTab(tabId: string): PeriodValue {
const cfg = _periodConfigForTab(tabId);
const r = resolvePeriod(cfg.defaultPreset);
return { preset: cfg.defaultPreset, fromDate: r.fromDate, toDate: r.toDate };
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@ -99,6 +129,18 @@ export const TrusteeAnalyseView: React.FC = () => {
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// One PeriodValue per tab, defaults derived from `_periodConfigForTab`.
const [periodByTab, setPeriodByTab] = useState<Record<string, PeriodValue>>(() => {
const initial: Record<string, PeriodValue> = {};
for (const tab of _TABS) initial[tab.id] = _initialPeriodForTab(tab.id);
return initial;
});
const tabPeriodConfig = useMemo(() => _periodConfigForTab(activeTab), [activeTab]);
const currentPeriod = periodByTab[activeTab] || _initialPeriodForTab(activeTab);
const _setCurrentPeriod = useCallback((next: PeriodValue) => {
setPeriodByTab((prev) => ({ ...prev, [activeTab]: next }));
}, [activeTab]);
// Load workflows for this instance once
useEffect(() => {
if (!instanceId) return;
@ -266,9 +308,14 @@ export const TrusteeAnalyseView: React.FC = () => {
setResultDocuments([]);
try {
const executeBody: Record<string, any> = { workflowId: wf.id };
const payload: Record<string, any> = {
dateFrom: currentPeriod.fromDate,
dateTo: currentPeriod.toDate,
};
if (activeTab === 'budget' && budgetFileId) {
executeBody.payload = { documentList: [budgetFileId] };
payload.documentList = [budgetFileId];
}
executeBody.payload = payload;
const res = await api.post(`/api/workflows/${instanceId}/execute`, executeBody);
const rid = res?.data?.runId;
if (rid) {
@ -291,7 +338,7 @@ export const TrusteeAnalyseView: React.FC = () => {
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
}
}, [activeTab, instanceId, _findWorkflow, budgetFileId, showError, showSuccess, t]);
}, [activeTab, instanceId, _findWorkflow, budgetFileId, currentPeriod, showError, showSuccess, t]);
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
const currentWorkflow = _findWorkflow(activeTab);
@ -396,6 +443,18 @@ export const TrusteeAnalyseView: React.FC = () => {
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
<label style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--text-secondary, #4A5568)' }}>
{t('Zeitraum')}
</label>
<PeriodPicker
value={currentPeriod}
onChange={_setCurrentPeriod}
direction={tabPeriodConfig.direction}
defaultPreset={tabPeriodConfig.defaultPreset}
/>
</div>
<button
className={styles.primaryButton}
onClick={_handleExecute}

View file

@ -28,7 +28,7 @@ export const TrusteeDashboardView: React.FC = () => {
const navigate = useNavigate();
const { mandateId } = useParams<{ mandateId: string }>();
const { instance, instanceId } = useCurrentInstance();
const { mandate, instance, instanceId } = useCurrentInstance();
const { items: positions, loading: posLoading } = useTrusteePositions();
const { items: documents, loading: docsLoading } = useTrusteeDocuments();
const { request } = useApiRequest();
@ -136,7 +136,7 @@ export const TrusteeDashboardView: React.FC = () => {
<div className={styles.statValueSmall}>
{instance?.userRoles?.length ? (
instance.userRoles.map((role: string, idx: number) => (
<div key={idx}>{role}</div>
<div key={idx}>{t(role)}</div>
))
) : '-'}
</div>
@ -162,15 +162,17 @@ export const TrusteeDashboardView: React.FC = () => {
<span className={styles.infoValue}>{instance?.instanceLabel}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.infoLabel}>{t('Mandant')}</span>
<span className={styles.infoValue}>{instance?.mandateName}</span>
<span className={styles.infoLabel}>{t('Mandant:')}</span>
<span className={styles.infoValue}>
{mandate?.label || instance?.mandateLabel || mandate?.name || instance?.mandateName || '-'}
</span>
</div>
{accountingConfig?.configured && (
<div className={styles.infoItem}>
<span className={styles.infoLabel}>{t('Buchhaltungssystem:')}</span>
<span className={styles.infoValue}>
{accountingConfig.displayLabel || accountingConfig.connectorType}
{accountingConfig.lastSyncStatus && ` (${accountingConfig.lastSyncStatus})`}
{accountingConfig.lastSyncStatus && ` (${t(accountingConfig.lastSyncStatus)})`}
</span>
</div>
)}

View file

@ -0,0 +1,271 @@
/**
* TrusteeDataTablesView
*
* Consolidated "Daten-Tabellen" page that exposes every Trustee table in
* its own tab using `FormGeneratorTable` (pagination, sort, filter, search).
*
* Architecture:
* - One generic body component (`TrusteeDataTab`) is reused for the simple
* CRUD tables (Organisation, Rolle, Zugriff, Vertrag) and the read-only
* sync tables (TrusteeData*, TrusteeAccounting*).
* - For "Position" and "Dokument" tabs we embed the existing specialised
* views (`TrusteePositionsView`, `TrusteeDocumentsView`) directly, because
* they already implement the full action set:
* - Positionen: edit / delete / sync-to-accounting / Beleg-Download (1-2)
* - Dokumente: edit / delete / Download
* That way RBAC, optimistic updates, batch sync and download stay in one
* place.
* - Each tab is a thin wrapper that calls its own hook so React hook rules
* are respected and inactive tabs perform zero data fetching (lazy-mount).
* - Tab state lives in `?tab=<key>` so deep links from QuickActions /
* notifications / docs stay stable.
*
* Layout / sizing: see `wiki/b-reference/frontend-nyla/formgenerator.md`
* ("Page Layout Chain"). Outer is `adminPage + adminPageFill`, active tab
* sits inside `tableContainer`, which provides the bounded height chain
* `FormGeneratorTable` requires.
*/
import React, { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
useTrusteeOrganisations,
useTrusteeOrganisationOperations,
useTrusteeRoles,
useTrusteeRoleOperations,
useTrusteeAccess,
useTrusteeAccessOperations,
useTrusteeContracts,
useTrusteeContractOperations,
useTrusteeDataAccounts,
useTrusteeDataJournalEntries,
useTrusteeDataJournalLines,
useTrusteeDataContacts,
useTrusteeDataAccountBalances,
useTrusteeAccountingConfigs,
useTrusteeAccountingSyncs,
} from '../../../hooks/useTrustee';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { TrusteeDataTab } from './dataTables/TrusteeDataTab';
import { TrusteePositionsView } from './TrusteePositionsView';
import { TrusteeDocumentsView } from './TrusteeDocumentsView';
import adminStyles from '../../admin/Admin.module.css';
// ---------------------------------------------------------------------------
// Tab definitions
// ---------------------------------------------------------------------------
interface TabDef {
id: string;
entityName: string;
label: string;
icon: string;
color: string;
readOnly: boolean;
Wrapper: React.FC<{ instanceId: string }>;
}
function _buildApiEndpoint(instanceId: string, suffix: string): string {
return `/api/trustee/${instanceId}/${suffix}`;
}
// ---------------------------------------------------------------------------
// Wrappers per-tab so inactive tabs do not fetch.
// ---------------------------------------------------------------------------
// Generic CRUD wrapper: data hook + operations hook → edit/delete actions.
function _makeCrudWrapper(
useDataHook: () => any,
useOpsHook: () => any,
suffix: string,
entityLabel: string,
): React.FC<{ instanceId: string }> {
const Wrapper: React.FC<{ instanceId: string }> = ({ instanceId }) => {
const data = useDataHook();
const ops = useOpsHook();
return (
<TrusteeDataTab
hookResult={data}
operationsHook={ops}
apiEndpoint={_buildApiEndpoint(instanceId, suffix)}
readOnly={false}
entityLabel={entityLabel}
/>
);
};
Wrapper.displayName = `TrusteeDataTabCrud(${suffix})`;
return Wrapper;
}
// Read-only wrapper: data hook only, no actions.
function _makeReadOnlyWrapper(
useDataHook: () => any,
suffix: string,
): React.FC<{ instanceId: string }> {
const Wrapper: React.FC<{ instanceId: string }> = ({ instanceId }) => {
const data = useDataHook();
return (
<TrusteeDataTab
hookResult={data}
apiEndpoint={_buildApiEndpoint(instanceId, suffix)}
readOnly={true}
/>
);
};
Wrapper.displayName = `TrusteeDataTabRO(${suffix})`;
return Wrapper;
}
// Specialised wrappers: reuse existing CRUD views with full action set.
const _PositionsWrapper: React.FC<{ instanceId: string }> = () => <TrusteePositionsView />;
_PositionsWrapper.displayName = 'TrusteeDataTabPositions';
const _DocumentsWrapper: React.FC<{ instanceId: string }> = () => <TrusteeDocumentsView />;
_DocumentsWrapper.displayName = 'TrusteeDataTabDocuments';
const _OrganisationsWrapper = _makeCrudWrapper(useTrusteeOrganisations, useTrusteeOrganisationOperations, 'organisations', 'Organisation');
const _RolesWrapper = _makeCrudWrapper(useTrusteeRoles, useTrusteeRoleOperations, 'roles', 'Rolle');
const _AccessWrapper = _makeCrudWrapper(useTrusteeAccess, useTrusteeAccessOperations, 'access', 'Zugriff');
const _ContractsWrapper = _makeCrudWrapper(useTrusteeContracts, useTrusteeContractOperations, 'contracts', 'Vertrag');
const _DataAccountsWrapper = _makeReadOnlyWrapper(useTrusteeDataAccounts, 'data/accounts');
const _DataJournalEntriesWrapper = _makeReadOnlyWrapper(useTrusteeDataJournalEntries, 'data/journal-entries');
const _DataJournalLinesWrapper = _makeReadOnlyWrapper(useTrusteeDataJournalLines, 'data/journal-lines');
const _DataContactsWrapper = _makeReadOnlyWrapper(useTrusteeDataContacts, 'data/contacts');
const _DataAccountBalancesWrapper = _makeReadOnlyWrapper(useTrusteeDataAccountBalances, 'data/account-balances');
const _AccountingConfigsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingConfigs, 'accounting/configs');
const _AccountingSyncsWrapper = _makeReadOnlyWrapper(useTrusteeAccountingSyncs, 'accounting/syncs');
function _buildTabs(t: (k: string) => string): TabDef[] {
return [
{ id: 'organisations', entityName: 'TrusteeOrganisation', label: t('Organisation'), icon: '\uD83C\uDFE2', color: '#1976d2', readOnly: false, Wrapper: _OrganisationsWrapper },
{ id: 'roles', entityName: 'TrusteeRole', label: t('Rolle'), icon: '\uD83D\uDC65', color: '#0277bd', readOnly: false, Wrapper: _RolesWrapper },
{ id: 'access', entityName: 'TrusteeAccess', label: t('Zugriff'), icon: '\uD83D\uDD11', color: '#0288d1', readOnly: false, Wrapper: _AccessWrapper },
{ id: 'contracts', entityName: 'TrusteeContract', label: t('Vertrag'), icon: '\uD83D\uDCDC', color: '#00796b', readOnly: false, Wrapper: _ContractsWrapper },
{ id: 'documents', entityName: 'TrusteeDocument', label: t('Dokument'), icon: '\uD83D\uDCC4', color: '#388e3c', readOnly: false, Wrapper: _DocumentsWrapper },
{ id: 'positions', entityName: 'TrusteePosition', label: t('Position'), icon: '\uD83D\uDCCA', color: '#43a047', readOnly: false, Wrapper: _PositionsWrapper },
{ id: 'accounts', entityName: 'TrusteeDataAccount', label: t('Konten (Sync)'), icon: '\uD83D\uDCD2', color: '#f57c00', readOnly: true, Wrapper: _DataAccountsWrapper },
{ id: 'journal-entries', entityName: 'TrusteeDataJournalEntry', label: t('Buchungen (Sync)'), icon: '\uD83D\uDCDD', color: '#ef6c00', readOnly: true, Wrapper: _DataJournalEntriesWrapper },
{ id: 'journal-lines', entityName: 'TrusteeDataJournalLine', label: t('Buchungszeilen (Sync)'), icon: '\uD83D\uDCC3', color: '#e65100', readOnly: true, Wrapper: _DataJournalLinesWrapper },
{ id: 'contacts', entityName: 'TrusteeDataContact', label: t('Kontakte (Sync)'), icon: '\uD83D\uDC64', color: '#c2185b', readOnly: true, Wrapper: _DataContactsWrapper },
{ id: 'account-balances', entityName: 'TrusteeDataAccountBalance', label: t('Kontosalden (Sync)'), icon: '\uD83D\uDCB0', color: '#ad1457', readOnly: true, Wrapper: _DataAccountBalancesWrapper },
{ id: 'accounting-configs', entityName: 'TrusteeAccountingConfig', label: t('Buchhaltungs-Konfiguration'), icon: '\u2699\uFE0F', color: '#5e35b1', readOnly: true, Wrapper: _AccountingConfigsWrapper },
{ id: 'accounting-syncs', entityName: 'TrusteeAccountingSync', label: t('Buchhaltungs-Synchronisation'), icon: '\uD83D\uDD04', color: '#3949ab', readOnly: true, Wrapper: _AccountingSyncsWrapper },
];
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const TrusteeDataTablesView: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const [searchParams, setSearchParams] = useSearchParams();
const tabs = useMemo(() => _buildTabs(t), [t]);
const visibleTabs = tabs;
const requestedTab = searchParams.get('tab');
const activeTab = useMemo(() => {
if (requestedTab && visibleTabs.some((tab) => tab.id === requestedTab)) {
return requestedTab;
}
return visibleTabs[0]?.id || tabs[0].id;
}, [requestedTab, visibleTabs, tabs]);
const _setActiveTab = useCallback((tabId: string) => {
setSearchParams({ tab: tabId }, { replace: true });
}, [setSearchParams]);
const currentTab = visibleTabs.find((tab) => tab.id === activeTab) || visibleTabs[0];
if (!instanceId) {
return (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<p>{t('Instanz wird geladen…')}</p>
</div>
);
}
if (!currentTab) {
return (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<div className={adminStyles.pageHeader}>
<div>
<h1 className={adminStyles.pageTitle}>{t('Daten-Tabellen')}</h1>
</div>
</div>
<p>{t('Du hast keine Berechtigung für')}</p>
</div>
);
}
const ActiveWrapper = currentTab.Wrapper;
return (
<div className={`${adminStyles.adminPage} ${adminStyles.adminPageFill}`}>
<div className={adminStyles.pageHeader}>
<div>
<h1 className={adminStyles.pageTitle}>{t('Daten-Tabellen')}</h1>
<p className={adminStyles.pageSubtitle}>
{t('Alle Datenbanktabellen dieser Trustee-Instanz auf einen Blick.')}
</p>
</div>
</div>
<div
style={{
display: 'flex',
gap: '0.25rem',
marginBottom: '1rem',
borderBottom: '2px solid var(--border-color, #e0e0e0)',
flexWrap: 'wrap',
flexShrink: 0,
}}
>
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => _setActiveTab(tab.id)}
style={{
padding: '0.625rem 1rem',
border: 'none',
borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
background: 'transparent',
color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
fontWeight: activeTab === tab.id ? 600 : 400,
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.2s',
marginBottom: '-2px',
whiteSpace: 'nowrap',
}}
>
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
{tab.label}
{tab.readOnly && (
<span
style={{
marginLeft: '0.375rem',
fontSize: '0.6875rem',
color: 'var(--text-secondary, #888)',
fontWeight: 400,
}}
title={t('Nur lesen Daten kommen aus dem Sync.')}
>
({t('read-only')})
</span>
)}
</button>
))}
</div>
<div className={adminStyles.tableContainer}>
<ActiveWrapper instanceId={instanceId} />
</div>
</div>
);
};
export default TrusteeDataTablesView;

View file

@ -131,7 +131,11 @@ function _extractWorkflowConfig(workflow: any): { connectionReference: string; s
};
}
export const TrusteeExpenseImportView: React.FC = () => {
interface TrusteeExpenseImportViewProps {
embedded?: boolean;
}
export const TrusteeExpenseImportView: React.FC<TrusteeExpenseImportViewProps> = ({ embedded = false }) => {
const { t } = useLanguage();
const { instanceId, mandateId } = useCurrentInstance();
const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections();
@ -464,10 +468,9 @@ export const TrusteeExpenseImportView: React.FC = () => {
}
};
return (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
<h3 className={styles.sectionTitle}>{t('Einrichtung des Ausgabenimports')}</h3>
const content = (
<>
{!embedded && <h3 className={styles.sectionTitle}>{t('Einrichtung des Ausgabenimports')}</h3>}
<p className={styles.sectionDescription}>
{t('Verbinden Sie Ihr Microsoft-Konto und wählen Sie einen SharePoint-Ordner mit Ausgaben-PDFs. Das System extrahiert automatisch täglich die Ausgabendaten und speichert sie als Positionen.')}
<span
@ -699,7 +702,17 @@ export const TrusteeExpenseImportView: React.FC = () => {
</div>
</div>
)}
</>
);
if (embedded) {
return <>{content}</>;
}
return (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
{content}
</div>
</div>
);

View file

@ -0,0 +1,115 @@
/**
* TrusteeImportProcessView
*
* Tab-based wrapper for the "Import & Verarbeitung" page. Consolidates the
* three import-related entry points under a single navigation item:
* - receipts : Belege verarbeiten (SharePoint -> Buchhaltung pipeline)
* - upload : Beleg hochladen (scan/manual upload)
* - sync : Daten einlesen (redirects to accounting settings,
* tab 'import-data' for the actual sync UI)
*
* The tab is controlled via the URL query parameter `?tab=...`, so
* QuickActions and deep links stay stable.
*/
import React, { useCallback, useEffect } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './TrusteeViews.module.css';
import { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
import { TrusteeScanUploadView } from './TrusteeScanUploadView';
interface TabDef {
id: string;
icon: string;
color: string;
}
const _TABS: TabDef[] = [
{ id: 'receipts', icon: '\uD83D\uDCC4', color: '#4CAF50' },
{ id: 'upload', icon: '\uD83D\uDCF7', color: '#607D8B' },
{ id: 'sync', icon: '\uD83D\uDD04', color: '#FF9800' },
];
function _tabLabel(tabId: string, t: (k: string) => string): string {
switch (tabId) {
case 'receipts': return t('Belege verarbeiten');
case 'upload': return t('Beleg hochladen');
case 'sync': return t('Daten einlesen');
default: return tabId;
}
}
function _tabDescription(tabId: string, t: (k: string) => string): string {
switch (tabId) {
case 'receipts': return t('Belege aus SharePoint importieren, klassifizieren und verbuchen.');
case 'upload': return t('Beleg scannen oder als Datei hochladen.');
case 'sync': return t('Buchhaltungsdaten aus dem externen System einlesen.');
default: return '';
}
}
export const TrusteeImportProcessView: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const { mandateId, featureCode, instanceId } = useParams<{ mandateId: string; featureCode: string; instanceId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get('tab') || _TABS[0].id;
const _setActiveTab = useCallback((tab: string) => {
setSearchParams({ tab }, { replace: true });
}, [setSearchParams]);
useEffect(() => {
if (activeTab !== 'sync') return;
if (!mandateId || !featureCode || !instanceId) return;
const target = `/mandates/${mandateId}/${featureCode}/${instanceId}/settings?tab=import-data`;
navigate(target, { replace: true });
}, [activeTab, mandateId, featureCode, instanceId, navigate]);
return (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
<h3 className={styles.sectionTitle}>{t('Import & Verarbeitung')}</h3>
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--border-color, #e0e0e0)', paddingBottom: 0 }}>
{_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => _setActiveTab(tab.id)}
style={{
padding: '0.625rem 1rem',
border: 'none',
borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
background: 'transparent',
color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
fontWeight: activeTab === tab.id ? 600 : 400,
fontSize: '0.875rem',
cursor: 'pointer',
transition: 'all 0.2s',
marginBottom: '-2px',
}}
>
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
{_tabLabel(tab.id, t)}
</button>
))}
</div>
<p className={styles.sectionDescription}>
{_tabDescription(activeTab, t)}
</p>
{activeTab === 'receipts' && <TrusteeExpenseImportView embedded />}
{activeTab === 'upload' && <TrusteeScanUploadView embedded />}
{activeTab === 'sync' && (
<div className={styles.infoBox}>
<p>{t('Weiterleitung zu den Buchhaltungs-Einstellungen…')}</p>
</div>
)}
</div>
</div>
);
};
export default TrusteeImportProcessView;

View file

@ -38,7 +38,11 @@ const _parseErrorDetail = (detail: unknown): string => {
return String(detail);
};
export const TrusteeScanUploadView: React.FC = () => {
interface TrusteeScanUploadViewProps {
embedded?: boolean;
}
export const TrusteeScanUploadView: React.FC<TrusteeScanUploadViewProps> = ({ embedded = false }) => {
const { t } = useLanguage();
const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast();
@ -262,12 +266,11 @@ export const TrusteeScanUploadView: React.FC = () => {
);
}
return (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
<h3 className={styles.sectionTitle}>{t('Scan-Upload')}</h3>
const content = (
<>
{!embedded && <h3 className={styles.sectionTitle}>{t('Scan-Upload')}</h3>}
<p className={styles.sectionDescription}>
Upload PDF or JPG documents (receipts, invoices). Then start the pipeline: extract data create positions sync to accounting.
{t('Laden Sie PDF- oder JPG-Dokumente (Belege, Rechnungen) hoch. Starten Sie dann die Pipeline: Daten extrahieren → Positionen erstellen → in Buchhaltung synchronisieren.')}
</p>
{error && <div className={styles.errorMessage}>{error}</div>}
{pipelineState !== 'idle' && (
@ -369,6 +372,17 @@ export const TrusteeScanUploadView: React.FC = () => {
</button>
</>
)}
</>
);
if (embedded) {
return <>{content}</>;
}
return (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
{content}
</div>
</div>
);

View file

@ -0,0 +1,309 @@
/**
* TrusteeDataTab
*
* Generic tab body that mounts a `FormGeneratorTable` for one Trustee data
* model. The actual data hook (created via the `_createTrusteeEntityHook`
* factory in `useTrustee.ts`) is provided by the parent
* `TrusteeDataTablesView` so this component stays purely presentational.
*
* Modes:
* - `readOnly: true` (default for sync tables, TrusteeData*, TrusteeAccounting*)
* no edit/delete/select UI.
* - `readOnly: false` + `operationsHook` supplied wires up edit/delete with
* a `FormGeneratorForm` modal and respects backend RBAC permissions
* (`permissions.update`, `permissions.delete`) returned by the entity hook.
*
* Layout chain: see `wiki/b-reference/frontend-nyla/formgenerator.md`
* ("Page Layout Chain"). The parent provides `tableContainer`; this component
* propagates `flex:1; min-height:0; flex-direction:column; width:100%`.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { FaSync } from 'react-icons/fa';
import { FormGeneratorTable } from '../../../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../../../components/FormGenerator/FormGeneratorForm';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useInstanceId } from '../../../../hooks/useCurrentInstance';
import adminStyles from '../../../admin/Admin.module.css';
export interface TrusteeDataTabProps {
/** Result of the entity hook factory call (see `useTrustee.ts`). */
hookResult: any;
/** Optional result of the matching operations hook (handleDelete/Update/Create). */
operationsHook?: any;
/** Backend endpoint that backs the table (Unified Filter API enabled). */
apiEndpoint: string;
/** Read-only mode hides edit/delete/select UI. */
readOnly?: boolean;
/** Extra column keys to hide on top of the default system fields. */
hiddenColumns?: string[];
/** Optional initial sort applied on first load. */
initialSort?: Array<{ key: string; direction: 'asc' | 'desc' }>;
/** Default page size for this tab (Sync-Tabellen können > 25 wollen). */
pageSize?: number;
/** Empty-state message override. */
emptyMessage?: string;
/** Human label for the entity (used in modal title, e.g. "Organisation"). */
entityLabel?: string;
}
const _DEFAULT_HIDDEN_COLUMNS = [
'mandateId',
'featureInstanceId',
'_hideDelete',
'_permissions',
];
const _SYSTEM_FORM_FIELDS = [
'id',
'mandateId',
'instanceId',
'featureInstanceId',
'sysCreatedAt',
'sysCreatedBy',
'sysModifiedAt',
'sysModifiedBy',
];
export const TrusteeDataTab: React.FC<TrusteeDataTabProps> = ({
hookResult,
operationsHook,
apiEndpoint,
readOnly = true,
hiddenColumns,
initialSort,
pageSize = 25,
emptyMessage,
entityLabel,
}) => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const {
items,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchById,
updateOptimistically,
removeOptimistically,
} = hookResult;
const handleDelete = operationsHook?.handleDelete;
const handleUpdate = operationsHook?.handleUpdate;
const deletingItems: Set<string> = operationsHook?.deletingItems ?? new Set();
// Permission gating (RBAC enforced by backend; we only avoid leaking buttons)
const canUpdate = !readOnly && !!handleUpdate && permissions?.update !== 'n';
const canDelete = !readOnly && !!handleDelete && permissions?.delete !== 'n';
// Edit modal state
const [editingRow, setEditingRow] = useState<any | null>(null);
const _tableRefetch = useCallback(async (params?: any) => {
await refetch(params);
}, [refetch]);
const _refresh = useCallback(async () => {
await _tableRefetch({ page: 1, pageSize, sort: initialSort });
}, [_tableRefetch, pageSize, initialSort]);
useEffect(() => {
_tableRefetch({ page: 1, pageSize, sort: initialSort });
}, [_tableRefetch, pageSize, initialSort]);
const columns = useMemo(() => {
const hidden = new Set([..._DEFAULT_HIDDEN_COLUMNS, ...(hiddenColumns || [])]);
return (attributes || [])
.filter((attr: any) => !hidden.has(attr.name))
.map((attr: any) => ({
key: attr.name,
label: attr.label || attr.name,
type: (attr.type as any) || 'text',
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: attr.fkSource,
fkDisplayField: attr.fkDisplayField,
}));
}, [attributes, hiddenColumns]);
const formAttributes = useMemo(() => {
return (attributes || []).filter((attr: any) => !_SYSTEM_FORM_FIELDS.includes(attr.name));
}, [attributes]);
const _handleEditClick = useCallback(async (row: any) => {
if (!fetchById) {
setEditingRow(row);
return;
}
const full = await fetchById(row.id);
setEditingRow(full || row);
}, [fetchById]);
const _handleDeleteRow = useCallback(async (row: any) => {
if (!handleDelete) return;
if (removeOptimistically) removeOptimistically(row.id);
const ok = await handleDelete(row.id);
if (!ok) await _tableRefetch();
}, [handleDelete, removeOptimistically, _tableRefetch]);
const _handleFormSubmit = useCallback(async (data: any) => {
if (!editingRow || !handleUpdate) return;
const result = await handleUpdate(editingRow.id, data);
if (result?.success) {
setEditingRow(null);
await _tableRefetch();
}
}, [editingRow, handleUpdate, _tableRefetch]);
const _handleInlineUpdate = useCallback(async (itemId: string, updateData: any, row: any) => {
if (!handleUpdate) return;
if (updateOptimistically) updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result?.success) await _tableRefetch();
}, [handleUpdate, updateOptimistically, _tableRefetch]);
// Layout: bounded height chain inside parent's `.tableContainer`
const _rootStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
width: '100%',
};
const _tableWrapStyle: React.CSSProperties = {
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
width: '100%',
};
if (error) {
return (
<div style={_rootStyle}>
<div className={adminStyles.errorContainer}>
<span className={adminStyles.errorIcon}></span>
<p className={adminStyles.errorMessage}>
{t('Fehler beim Laden: {detail}', { detail: String(error) })}
</p>
<button className={adminStyles.secondaryButton} onClick={_refresh}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
const actionButtons: any[] = [];
if (canUpdate) {
actionButtons.push({
type: 'edit' as const,
onAction: _handleEditClick,
title: t('Bearbeiten'),
});
}
if (canDelete) {
actionButtons.push({
type: 'delete' as const,
title: t('Löschen'),
loading: (row: any) => deletingItems.has(row.id),
});
}
return (
<div style={_rootStyle}>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '0.5rem',
flexShrink: 0,
}}
>
<button
className={adminStyles.secondaryButton}
onClick={_refresh}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
<div style={_tableWrapStyle}>
<FormGeneratorTable
data={items}
columns={columns}
apiEndpoint={apiEndpoint}
loading={loading}
pagination={true}
pageSize={pageSize}
searchable={true}
filterable={true}
sortable={true}
selectable={!readOnly}
initialSort={initialSort}
actionButtons={actionButtons}
onDelete={canDelete ? _handleDeleteRow : undefined}
hookData={{
refetch: _tableRefetch,
permissions,
pagination,
handleDelete,
handleInlineUpdate: canUpdate ? _handleInlineUpdate : undefined,
updateOptimistically,
}}
emptyMessage={emptyMessage || t('Keine Daten gefunden')}
/>
</div>
{editingRow && canUpdate && (
<div className={adminStyles.modalOverlay}>
<div className={adminStyles.modal}>
<div className={adminStyles.modalHeader}>
<h2 className={adminStyles.modalTitle}>
{entityLabel
? t('{label} bearbeiten', { label: entityLabel })
: t('Bearbeiten')}
</h2>
<button
className={adminStyles.modalClose}
onClick={() => setEditingRow(null)}
>
</button>
</div>
<div className={adminStyles.modalContent}>
{formAttributes.length === 0 ? (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingRow}
mode="edit"
onSubmit={_handleFormSubmit}
onCancel={() => setEditingRow(null)}
submitButtonText={t('Speichern')}
cancelButtonText={t('Abbrechen')}
instanceId={instanceId || undefined}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default TrusteeDataTab;

View file

@ -8,6 +8,8 @@ export { TrusteePositionsView } from './TrusteePositionsView';
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
export { TrusteeImportProcessView } from './TrusteeImportProcessView';
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
export { TrusteeAnalyseView } from './TrusteeAnalyseView';
export { TrusteeAbschlussView } from './TrusteeAbschlussView';
export { TrusteeDataTablesView } from './TrusteeDataTablesView';

View file

@ -8,6 +8,7 @@ import { ProviderMultiSelect } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry';
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
import api from '../../../api';
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -50,9 +51,22 @@ interface WorkspaceInputProps {
onPasteAsFile?: (file: File) => void;
draftAppend?: string;
onDraftAppendConsumed?: () => void;
/**
* Per-chat attachment persistence. When the parent loads a workflow, it
* passes the IDs the backend has stored for that chat plus a nonce that
* increments on every load. The chip-bar is then rehydrated, dropping
* any IDs that no longer resolve against the available sources.
*
* `workflowId` is needed so that "x" detachments can be persisted via a
* PATCH call without waiting for the next sendMessage round-trip.
*/
workflowId?: string | null;
loadedAttachedDataSourceIds?: string[];
loadedAttachedFeatureDataSourceIds?: string[];
loadedNonce?: number;
}
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _instanceId,
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
onSend,
isProcessing,
onStop,
@ -76,6 +90,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
onPasteAsFile,
draftAppend,
onDraftAppendConsumed,
workflowId,
loadedAttachedDataSourceIds,
loadedAttachedFeatureDataSourceIds,
loadedNonce,
}) => {
const { t } = useLanguage();
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
@ -118,6 +136,50 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
}
}, [pendingAttachFdsId, onPendingAttachFdsConsumed]);
// Rehydrate the chip-bar whenever the parent re-loads a chat (loadedNonce
// bumps on every loadWorkflow call). We trust the loaded IDs initially;
// a separate effect below drops IDs that don't resolve once the source
// lists have arrived from the backend.
useEffect(() => {
if (loadedNonce === undefined) return;
setAttachedFileIds([]);
setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []);
setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []);
}, [loadedNonce]);
// Drop persisted attachment IDs that no longer resolve to an existing
// source (e.g. the DataSource was deleted while the chat was closed).
// We only run this once the lists are populated to avoid wiping chips
// before the lists have loaded.
useEffect(() => {
if (dataSources.length === 0 && attachedDataSourceIds.length === 0) return;
const validIds = new Set(dataSources.map(d => d.id));
setAttachedDataSourceIds(prev => {
const filtered = prev.filter(id => validIds.has(id));
return filtered.length === prev.length ? prev : filtered;
});
}, [dataSources, attachedDataSourceIds.length]);
useEffect(() => {
if (featureDataSources.length === 0 && attachedFeatureDataSourceIds.length === 0) return;
const validIds = new Set(featureDataSources.map(d => d.id));
setAttachedFeatureDataSourceIds(prev => {
const filtered = prev.filter(id => validIds.has(id));
return filtered.length === prev.length ? prev : filtered;
});
}, [featureDataSources, attachedFeatureDataSourceIds.length]);
// Persist a changed attachment list to the backend so the next chat
// reload reflects the current state. We debounce slightly by sending on
// the next animation frame to coalesce rapid clicks.
const _persistAttachments = useCallback((dsIds: string[], fdsIds: string[]) => {
if (!instanceId || !workflowId) return;
api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, {
dataSourceIds: dsIds,
featureDataSourceIds: fdsIds,
}).catch(err => console.warn('Failed to persist chat attachments:', err));
}, [instanceId, workflowId]);
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
const currentInterimRef = useRef('');
@ -210,14 +272,20 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
}, []);
const _removeAttachedDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
}, []);
setAttachedDataSourceIds(prev => {
const next = prev.filter(id => id !== dsId);
_persistAttachments(next, attachedFeatureDataSourceIds);
return next;
});
}, [_persistAttachments, attachedFeatureDataSourceIds]);
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
setAttachedFeatureDataSourceIds(prev =>
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
);
}, []);
setAttachedFeatureDataSourceIds(prev => {
const next = prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId];
_persistAttachments(attachedDataSourceIds, next);
return next;
});
}, [_persistAttachments, attachedDataSourceIds]);
const _buildPromptFromRefs = useCallback(() => {
const parts = [

View file

@ -539,6 +539,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
onPasteAsFile={_uploadAndAttach}
draftAppend={draftAppend}
onDraftAppendConsumed={() => setDraftAppend('')}
workflowId={workspace.workflowId}
loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds}
loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds}
loadedNonce={workspace.loadedNonce}
/>
</main>

View file

@ -116,6 +116,19 @@ interface UseWorkspaceReturn {
refreshFolders: () => void;
refreshDataSources: () => void;
dataSourceAccesses: DataSourceAccessEvent[];
/**
* Hydrated chip-bar state for the WorkspaceInput. Set by ``loadWorkflow``
* to whatever the backend persisted for the chat (per-chat attachment
* persistence). Sources that no longer exist are filtered out by the
* WorkspaceInput before they're rendered.
*
* The `loadedNonce` increments on every load so the WorkspaceInput can
* tell apart "same workflow, no change" from "user re-loaded the same
* chat" and re-hydrate accordingly.
*/
loadedAttachedDataSourceIds: string[];
loadedAttachedFeatureDataSourceIds: string[];
loadedNonce: number;
}
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
@ -131,6 +144,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [workflowVersion, setWorkflowVersion] = useState(0);
const [dataSourceAccesses, setDataSourceAccesses] = useState<DataSourceAccessEvent[]>([]);
const [loadedAttachedDataSourceIds, setLoadedAttachedDataSourceIds] = useState<string[]>([]);
const [loadedAttachedFeatureDataSourceIds, setLoadedAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [loadedNonce, setLoadedNonce] = useState(0);
const cleanupRef = useRef<(() => void) | null>(null);
const refreshFiles = useCallback(() => {
@ -177,6 +193,8 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
setPendingEdits([]);
setAgentProgress(null);
setDataSourceAccesses([]);
setLoadedAttachedDataSourceIds([]);
setLoadedAttachedFeatureDataSourceIds([]);
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
.then(res => {
@ -184,6 +202,15 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
.map((m: any) => _mapLoadedWorkspaceMessage(m, wfId))
.sort(_compareWorkspaceMessages);
setMessages(msgs);
const dsIds: string[] = Array.isArray(res.data.attachedDataSourceIds)
? res.data.attachedDataSourceIds.map((x: unknown) => String(x))
: [];
const fdsIds: string[] = Array.isArray(res.data.attachedFeatureDataSourceIds)
? res.data.attachedFeatureDataSourceIds.map((x: unknown) => String(x))
: [];
setLoadedAttachedDataSourceIds(dsIds);
setLoadedAttachedFeatureDataSourceIds(fdsIds);
setLoadedNonce(n => n + 1);
})
.catch(() => {});
}, [instanceId]);
@ -195,6 +222,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
setPendingEdits([]);
setAgentProgress(null);
setDataSourceAccesses([]);
setLoadedAttachedDataSourceIds([]);
setLoadedAttachedFeatureDataSourceIds([]);
setLoadedNonce(n => n + 1);
}, []);
const sendMessage = useCallback(
@ -496,6 +526,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
refreshFolders,
refreshDataSources,
dataSourceAccesses,
loadedAttachedDataSourceIds,
loadedAttachedFeatureDataSourceIds,
loadedNonce,
};
}

View file

@ -75,7 +75,8 @@ export interface FeatureInstance {
id: string; // UUID der Instanz
featureCode: string; // "trustee", "chatbot", "chatworkflow", etc.
mandateId: string; // Zugehöriger Mandant
mandateName: string; // Für Anzeige
mandateName: string; // Kurzzeichen / Slug des Mandanten (audit-stable)
mandateLabel?: string; // Voller Name des Mandanten (UI-Anzeige) — optional fuer Backwards-Compat
instanceLabel: string; // z.B. "PamoCreate AG"
userRoles: string[]; // Rollen des Users in dieser Instanz (kann mehrere haben)
permissions: InstancePermissions;
@ -205,11 +206,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
icon: 'briefcase',
views: [
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
{ code: 'positions', label: 'Positionen', path: 'positions' },
{ code: 'documents', label: 'Dokumente', path: 'documents' },
{ code: 'data-tables', label: 'Daten-Tabellen', path: 'data-tables' },
{ code: 'position-documents', label: 'Zuordnungen', path: 'position-documents' },
{ code: 'expense-import', label: 'Spesen Import', path: 'expense-import' },
{ code: 'scan-upload', label: 'Scannen / Hochladen', path: 'scan-upload' },
{ code: 'import-process', label: 'Import & Verarbeitung', path: 'import-process' },
{ code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
{ code: 'settings', label: 'Buchhaltungseinstellungen', path: 'settings' },
]

View file

@ -84,7 +84,29 @@ export function mergeBillingIntoMandateFormData(
};
}
/** Split form submit payload into mandate PUT body and billing POST body. */
/** Mandate fields that the AdminMandates form is allowed to update. */
const _MANDATE_INVOICE_FIELDS = [
'invoiceCompanyName',
'invoiceContactName',
'invoiceEmail',
'invoiceLine1',
'invoiceLine2',
'invoicePostalCode',
'invoiceCity',
'invoiceState',
'invoiceCountry',
'invoiceVatNumber',
] as const;
/**
* Split form submit payload into mandate PUT body and billing POST body.
*
* Only fields that the user can actually edit are forwarded. Audit-only /
* read-only fields (id, deletedAt, isSystem, ...) are intentionally dropped.
* The structured ``invoice*`` address fields are round-tripped here so the
* address entered in the form is persisted on Mandate; empty strings are
* normalized to ``null`` so the backend stores nothing instead of "".
*/
export function splitMandateAndBillingFromForm(
formData: Record<string, unknown>
): { mandatePayload: Record<string, unknown>; billingUpdate: BillingSettingsUpdate } {
@ -92,6 +114,18 @@ export function splitMandateAndBillingFromForm(
if ('name' in formData) mandatePayload.name = formData.name;
if ('label' in formData) mandatePayload.label = formData.label;
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
for (const fieldName of _MANDATE_INVOICE_FIELDS) {
if (!(fieldName in formData)) continue;
const raw = formData[fieldName];
if (raw === null || raw === undefined) {
mandatePayload[fieldName] = null;
} else if (typeof raw === 'string') {
const trimmed = raw.trim();
mandatePayload[fieldName] = trimmed.length === 0 ? null : trimmed;
} else {
mandatePayload[fieldName] = raw;
}
}
const billingUpdate: BillingSettingsUpdate = {};
if (