data source fixes
This commit is contained in:
parent
59017138ff
commit
7d84160cdb
37 changed files with 3898 additions and 772 deletions
|
|
@ -147,8 +147,7 @@ function App() {
|
||||||
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
|
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
|
||||||
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
|
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
|
||||||
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
|
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
|
||||||
<Route path="documents" element={<FeatureViewPage view="documents" />} />
|
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
|
||||||
<Route path="positions" element={<FeatureViewPage view="positions" />} />
|
|
||||||
<Route path="roles" element={<FeatureViewPage view="roles" />} />
|
<Route path="roles" element={<FeatureViewPage view="roles" />} />
|
||||||
<Route path="access" element={<FeatureViewPage view="access" />} />
|
<Route path="access" element={<FeatureViewPage view="access" />} />
|
||||||
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
||||||
|
|
@ -158,8 +157,7 @@ function App() {
|
||||||
<Route path="chat" element={<FeatureViewPage view="chat" />} />
|
<Route path="chat" element={<FeatureViewPage view="chat" />} />
|
||||||
<Route path="threads" element={<FeatureViewPage view="threads" />} />
|
<Route path="threads" element={<FeatureViewPage view="threads" />} />
|
||||||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
|
||||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
|
||||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||||
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
||||||
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,12 @@ export interface BillingSettingsUpdate {
|
||||||
rechargeMaxPerMonth?: number;
|
rechargeMaxPerMonth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BillingBucketSize = 'day' | 'month' | 'year';
|
||||||
|
|
||||||
export interface UsageReport {
|
export interface UsageReport {
|
||||||
period: string;
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
bucketSize: BillingBucketSize;
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
costByProvider: Record<string, number>;
|
costByProvider: Record<string, number>;
|
||||||
|
|
@ -65,6 +69,12 @@ export interface UsageReport {
|
||||||
costByFeature: Record<string, number>;
|
costByFeature: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatisticsRangeRequest {
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
bucketSize: BillingBucketSize;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccountSummary {
|
export interface AccountSummary {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
|
@ -141,24 +151,21 @@ export async function fetchTransactions(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch usage statistics
|
* Fetch usage statistics for an explicit date range.
|
||||||
* Endpoint: GET /api/billing/statistics/{period}
|
* Endpoint: GET /api/billing/statistics
|
||||||
*/
|
*/
|
||||||
export async function fetchStatistics(
|
export async function fetchStatistics(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
period: 'day' | 'month' | 'year',
|
range: StatisticsRangeRequest
|
||||||
year: number,
|
|
||||||
month?: number
|
|
||||||
): Promise<UsageReport> {
|
): Promise<UsageReport> {
|
||||||
const params: Record<string, any> = { year };
|
|
||||||
if (month !== undefined) {
|
|
||||||
params.month = month;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/billing/statistics/${period}`,
|
url: '/api/billing/statistics',
|
||||||
method: 'get',
|
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
|
* Create Stripe Checkout Session for credit top-up
|
||||||
* Endpoint: POST /api/billing/checkout/create/{mandateId}
|
* Endpoint: POST /api/billing/checkout/create/{mandateId}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
export async function exportAccountingData(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string
|
instanceId: string
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,13 @@ import {
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import styles from './FormGeneratorReport.module.css';
|
import styles from './FormGeneratorReport.module.css';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import {
|
||||||
|
PeriodPicker,
|
||||||
|
fromIsoDate,
|
||||||
|
toIsoDate,
|
||||||
|
type PeriodPreset,
|
||||||
|
type PeriodValue,
|
||||||
|
} from '../../PeriodPicker';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FormGeneratorReportProps,
|
FormGeneratorReportProps,
|
||||||
|
|
@ -531,14 +538,36 @@ const _Toolbar: React.FC<ToolbarProps> = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const _handleDateRangeChange = (field: 'from' | 'to', dateStr: string) => {
|
const _handlePeriodPickerChange = (next: PeriodValue) => {
|
||||||
const dateRange = filterState.dateRange || { from: new Date(), to: new Date() };
|
const fromD = fromIsoDate(next.fromDate) || new Date();
|
||||||
|
const toD = fromIsoDate(next.toDate) || new Date();
|
||||||
onFilterStateChange({
|
onFilterStateChange({
|
||||||
...filterState,
|
...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 currentYear = new Date().getFullYear();
|
||||||
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
|
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
|
||||||
|
|
||||||
|
|
@ -605,22 +634,18 @@ const _Toolbar: React.FC<ToolbarProps> = ({
|
||||||
<div className={styles.toolbarSeparator} />
|
<div className={styles.toolbarSeparator} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date Range */}
|
{/* Date Range (rendered via shared PeriodPicker) */}
|
||||||
{hasDateRange && (
|
{hasDateRange && (
|
||||||
<div className={styles.toolbarGroup}>
|
<div className={styles.toolbarGroup}>
|
||||||
<span className={styles.toolbarLabel}>{t('Von')}</span>
|
<span className={styles.toolbarLabel}>{t('Zeitraum')}</span>
|
||||||
<input
|
<PeriodPicker
|
||||||
type="date"
|
value={_periodPickerValue}
|
||||||
className={styles.dateInput}
|
onChange={_handlePeriodPickerChange}
|
||||||
value={filterState.dateRange?.from?.toISOString().split('T')[0] || ''}
|
direction={dateRangeSelector!.direction || 'any'}
|
||||||
onChange={(e) => _handleDateRangeChange('from', e.target.value)}
|
defaultPreset={_periodPickerDefault}
|
||||||
/>
|
enabledPresets={dateRangeSelector!.enabledPresets}
|
||||||
<span className={styles.toolbarLabel}>{t('Bis')}</span>
|
minDate={dateRangeSelector!.minDate}
|
||||||
<input
|
maxDate={dateRangeSelector!.maxDate}
|
||||||
type="date"
|
|
||||||
className={styles.dateInput}
|
|
||||||
value={filterState.dateRange?.to?.toISOString().split('T')[0] || ''}
|
|
||||||
onChange={(e) => _handleDateRangeChange('to', e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export interface ReportPeriodSelectorConfig {
|
||||||
defaultMonth?: number;
|
defaultMonth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Date range selector configuration */
|
/** Date range selector configuration. Renders the shared PeriodPicker. */
|
||||||
export interface ReportDateRangeSelectorConfig {
|
export interface ReportDateRangeSelectorConfig {
|
||||||
/** Whether the date range selector is enabled */
|
/** Whether the date range selector is enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -62,6 +62,28 @@ export interface ReportDateRangeSelectorConfig {
|
||||||
defaultFrom?: Date;
|
defaultFrom?: Date;
|
||||||
/** Default to date */
|
/** Default to date */
|
||||||
defaultTo?: 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 */
|
/** Combined filter state passed to the data callback */
|
||||||
|
|
@ -72,8 +94,15 @@ export interface ReportFilterState {
|
||||||
year?: number;
|
year?: number;
|
||||||
/** Selected month (1-12) */
|
/** Selected month (1-12) */
|
||||||
month?: number;
|
month?: number;
|
||||||
/** Date range */
|
/** Date range (always synthesized from `periodValue` when the
|
||||||
|
* `dateRangeSelector` is enabled). */
|
||||||
dateRange?: ReportDateRange;
|
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) */
|
/** Custom filter values: key -> value(s) */
|
||||||
filters: Record<string, string | string[]>;
|
filters: Record<string, string | string[]>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
394
src/components/PeriodPicker/PeriodPicker.module.css
Normal file
394
src/components/PeriodPicker/PeriodPicker.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/components/PeriodPicker/PeriodPicker.tsx
Normal file
179
src/components/PeriodPicker/PeriodPicker.tsx
Normal 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;
|
||||||
132
src/components/PeriodPicker/PeriodPickerCalendar.tsx
Normal file
132
src/components/PeriodPicker/PeriodPickerCalendar.tsx
Normal 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;
|
||||||
241
src/components/PeriodPicker/PeriodPickerLogic.ts
Normal file
241
src/components/PeriodPicker/PeriodPickerLogic.ts
Normal 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);
|
||||||
|
}
|
||||||
303
src/components/PeriodPicker/PeriodPickerPopover.tsx
Normal file
303
src/components/PeriodPicker/PeriodPickerPopover.tsx
Normal 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;
|
||||||
68
src/components/PeriodPicker/PeriodPickerTypes.ts
Normal file
68
src/components/PeriodPicker/PeriodPickerTypes.ts
Normal 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;
|
||||||
|
}
|
||||||
21
src/components/PeriodPicker/index.ts
Normal file
21
src/components/PeriodPicker/index.ts
Normal 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
|
|
@ -29,6 +29,8 @@ import {
|
||||||
type CreditAddRequest,
|
type CreditAddRequest,
|
||||||
type CheckoutCreateRequest,
|
type CheckoutCreateRequest,
|
||||||
type MandateUserSummary,
|
type MandateUserSummary,
|
||||||
|
type StatisticsRangeRequest,
|
||||||
|
type BillingBucketSize,
|
||||||
} from '../api/billingApi';
|
} from '../api/billingApi';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
|
|
@ -41,6 +43,8 @@ export type {
|
||||||
AccountSummary,
|
AccountSummary,
|
||||||
CreditAddRequest,
|
CreditAddRequest,
|
||||||
MandateUserSummary,
|
MandateUserSummary,
|
||||||
|
StatisticsRangeRequest,
|
||||||
|
BillingBucketSize,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
||||||
|
|
@ -91,14 +95,9 @@ export function useBilling() {
|
||||||
}
|
}
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
// Fetch statistics
|
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
|
||||||
const loadStatistics = useCallback(async (
|
|
||||||
period: 'day' | 'month' | 'year',
|
|
||||||
year: number,
|
|
||||||
month?: number
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchStatistics(request, period, year, month);
|
const data = await fetchStatistics(request, range);
|
||||||
setStatistics(data);
|
setStatistics(data);
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
116
src/hooks/usePeriod.ts
Normal file
116
src/hooks/usePeriod.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,21 @@ import {
|
||||||
createPosition as createPositionApi,
|
createPosition as createPositionApi,
|
||||||
updatePosition as updatePositionApi,
|
updatePosition as updatePositionApi,
|
||||||
deletePosition as deletePositionApi,
|
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';
|
} from '../api/trusteeApi';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -569,3 +584,61 @@ export const useTrusteePositionOperations = _createTrusteeOperationsHook(positio
|
||||||
export { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from './useTrusteePositionDocuments';
|
export { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from './useTrusteePositionDocuments';
|
||||||
export type { TrusteePositionDocument } from '../api/trusteeApi';
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,16 @@ import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useUserMandates } from '../hooks/useUserMandates';
|
import { useUserMandates } from '../hooks/useUserMandates';
|
||||||
import { useConfirm } from '../hooks/useConfirm';
|
import { useConfirm } from '../hooks/useConfirm';
|
||||||
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||||||
|
import {
|
||||||
|
PeriodPicker,
|
||||||
|
resolvePeriod,
|
||||||
|
type PeriodValue,
|
||||||
|
} from '../components/PeriodPicker';
|
||||||
import styles from './ComplianceAuditPage.module.css';
|
import styles from './ComplianceAuditPage.module.css';
|
||||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
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 CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
|
||||||
|
|
||||||
const _CATEGORY_COLORS: Record<string, string> = {
|
const _CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
|
@ -153,7 +160,10 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
// ── Tab C state ──
|
// ── Tab C state ──
|
||||||
const [stats, setStats] = useState<AuditStats | null>(null);
|
const [stats, setStats] = useState<AuditStats | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
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 ──
|
// ── Tab D: Neutralization Mappings state ──
|
||||||
const [neutEntries, setNeutEntries] = useState<any[]>([]);
|
const [neutEntries, setNeutEntries] = useState<any[]>([]);
|
||||||
|
|
@ -254,12 +264,12 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
|
|
||||||
// ── Tab C loader ──
|
// ── Tab C loader ──
|
||||||
|
|
||||||
const _loadStats = useCallback(async (days = 30) => {
|
const _loadStats = useCallback(async (range: { dateFrom: string; dateTo: string }) => {
|
||||||
if (!selectedMandateId) return;
|
if (!selectedMandateId) return;
|
||||||
setStatsLoading(true);
|
setStatsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/api/audit/stats', {
|
const { data } = await api.get('/api/audit/stats', {
|
||||||
params: { timeRange: days },
|
params: { dateFrom: range.dateFrom, dateTo: range.dateTo },
|
||||||
headers: _mandateHeaders(),
|
headers: _mandateHeaders(),
|
||||||
});
|
});
|
||||||
setStats(data ?? null);
|
setStats(data ?? null);
|
||||||
|
|
@ -341,7 +351,7 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
if (!selectedMandateId) return;
|
if (!selectedMandateId) return;
|
||||||
if (activeTab === 'ai-log') void _loadAiLog();
|
if (activeTab === 'ai-log') void _loadAiLog();
|
||||||
else if (activeTab === 'audit-log') void _loadAuditLog();
|
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();
|
else if (activeTab === 'neutralization') void _loadNeutMappings();
|
||||||
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|
@ -641,15 +651,20 @@ export const ComplianceAuditPage: React.FC = () => {
|
||||||
{activeTab === 'stats' && (
|
{activeTab === 'stats' && (
|
||||||
<div className={styles.tabContentScrollable}>
|
<div className={styles.tabContentScrollable}>
|
||||||
<div className={styles.statsControls}>
|
<div className={styles.statsControls}>
|
||||||
{[7, 30, 90].map(d => (
|
<PeriodPicker
|
||||||
<button
|
value={statsPeriod}
|
||||||
key={d}
|
onChange={(next) => {
|
||||||
className={`${styles.rangeBtn} ${statsRange === d ? styles.rangeBtnActive : ''}`}
|
setStatsPeriod(next);
|
||||||
onClick={() => { setStatsRange(d); void _loadStats(d); }}
|
void _loadStats({ dateFrom: next.fromDate, dateTo: next.toDate });
|
||||||
>
|
}}
|
||||||
{t('{n} Tage', { n: String(d) })}
|
direction="past"
|
||||||
</button>
|
defaultPreset={_DEFAULT_STATS_PRESET}
|
||||||
))}
|
enabledPresets={[
|
||||||
|
'lastN', 'last12Months', 'lastYear',
|
||||||
|
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||||
|
'ytd', 'custom',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,15 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
// Trustee Views
|
// Trustee Views
|
||||||
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
||||||
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
|
// Note: TrusteePositionsView/TrusteeDocumentsView are no longer mounted directly here -
|
||||||
import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
|
// they live as tabs inside TrusteeDataTablesView (and that file imports them).
|
||||||
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
|
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
|
||||||
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
|
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
|
||||||
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
|
import { TrusteeImportProcessView } from './views/trustee/TrusteeImportProcessView';
|
||||||
import { TrusteeScanUploadView } from './views/trustee/TrusteeScanUploadView';
|
|
||||||
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
|
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
|
||||||
import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
|
import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
|
||||||
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
|
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
|
||||||
|
import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView';
|
||||||
|
|
||||||
// Chatbot Views
|
// Chatbot Views
|
||||||
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
||||||
|
|
@ -121,11 +121,9 @@ type ViewComponent = React.FC;
|
||||||
const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
trustee: {
|
trustee: {
|
||||||
dashboard: TrusteeDashboardView,
|
dashboard: TrusteeDashboardView,
|
||||||
documents: TrusteeDocumentsView,
|
'data-tables': TrusteeDataTablesView,
|
||||||
positions: TrusteePositionsView,
|
|
||||||
'instance-roles': TrusteeInstanceRolesView,
|
'instance-roles': TrusteeInstanceRolesView,
|
||||||
'expense-import': TrusteeExpenseImportView,
|
'import-process': TrusteeImportProcessView,
|
||||||
'scan-upload': TrusteeScanUploadView,
|
|
||||||
settings: TrusteeAccountingSettingsView,
|
settings: TrusteeAccountingSettingsView,
|
||||||
analyse: TrusteeAnalyseView,
|
analyse: TrusteeAnalyseView,
|
||||||
abschluss: TrusteeAbschlussView,
|
abschluss: TrusteeAbschlussView,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
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 api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
|
@ -44,6 +44,10 @@ interface OrphanEntry {
|
||||||
targetTable: string;
|
targetTable: string;
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
orphanCount: number;
|
orphanCount: number;
|
||||||
|
sourceRowCount?: number;
|
||||||
|
targetRowCount?: number;
|
||||||
|
targetEmpty?: boolean;
|
||||||
|
wouldDeleteAll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CleanResult {
|
interface CleanResult {
|
||||||
|
|
@ -52,6 +56,7 @@ interface CleanResult {
|
||||||
column: string;
|
column: string;
|
||||||
deleted: number;
|
deleted: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
skipped?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginationParams {
|
interface PaginationParams {
|
||||||
|
|
@ -367,6 +372,7 @@ const OrphansTab: React.FC = () => {
|
||||||
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
|
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [cleaning, setCleaning] = useState<string | null>(null);
|
const [cleaning, setCleaning] = useState<string | null>(null);
|
||||||
|
const [downloading, setDownloading] = useState<string | null>(null);
|
||||||
const [cleaningAll, setCleaningAll] = useState(false);
|
const [cleaningAll, setCleaningAll] = useState(false);
|
||||||
const [onlyProblems, setOnlyProblems] = useState(true);
|
const [onlyProblems, setOnlyProblems] = useState(true);
|
||||||
const [dbFilter, setDbFilter] = useState<string>('');
|
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 totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]);
|
||||||
|
|
||||||
const _cleanOne = async (o: OrphanEntry) => {
|
const _postCleanOne = async (o: OrphanEntry, force: boolean): Promise<number | 'refused'> => {
|
||||||
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);
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/api/admin/database-health/orphans/clean', {
|
const res = await api.post('/api/admin/database-health/orphans/clean', {
|
||||||
db: o.sourceDb,
|
db: o.sourceDb,
|
||||||
table: o.sourceTable,
|
table: o.sourceTable,
|
||||||
column: o.sourceColumn,
|
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();
|
_fetchOrphans();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
|
toast.showError(err?.message || t('Fehler beim Bereinigen'));
|
||||||
} finally {
|
} finally {
|
||||||
setCleaning(null);
|
setCleaning(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const _cleanAll = async () => {
|
const _cleanAll = async (force: boolean = false) => {
|
||||||
const ok = await confirm(
|
const ok = await confirm(
|
||||||
t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', {
|
t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', {
|
||||||
count: totalOrphans,
|
count: totalOrphans,
|
||||||
relations: allOrphans.filter(o => o.orphanCount > 0).length,
|
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' },
|
{ title: t('Alle Orphans bereinigen'), variant: 'danger' },
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setCleaningAll(true);
|
setCleaningAll(true);
|
||||||
try {
|
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 results: CleanResult[] = res.data.results || [];
|
||||||
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
|
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
|
||||||
const errors = results.filter(r => r.error);
|
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) {
|
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 {
|
} else {
|
||||||
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted }));
|
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted }));
|
||||||
}
|
}
|
||||||
|
|
@ -550,7 +641,7 @@ const OrphansTab: React.FC = () => {
|
||||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
|
||||||
</button>
|
</button>
|
||||||
{totalOrphans > 0 && (
|
{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)})
|
<FaBroom className={cleaningAll ? 'spinning' : ''} /> {t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -579,6 +670,14 @@ const OrphansTab: React.FC = () => {
|
||||||
pageSize={50}
|
pageSize={50}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
customActions={[
|
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',
|
id: 'clean',
|
||||||
icon: <FaTrashAlt />,
|
icon: <FaTrashAlt />,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
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 { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
||||||
import { useCurrentUser } from '../../hooks/useUsers';
|
import { useCurrentUser } from '../../hooks/useUsers';
|
||||||
import { SubscriptionTab } from './SubscriptionTab';
|
import { SubscriptionTab } from './SubscriptionTab';
|
||||||
|
|
@ -369,6 +369,28 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [localMsg, setLocalMsg] = useState<string | null>(null);
|
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) => {
|
const _handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -377,6 +399,10 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
||||||
setLocalMsg('Betrag muss positiv sein');
|
setLocalMsg('Betrag muss positiv sein');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (allowedAmounts.length > 0 && !allowedAmounts.includes(n)) {
|
||||||
|
setLocalMsg(t('Ungültiger Betrag. Erlaubt: {list} CHF', { list: allowedAmounts.join(', ') }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setLocalMsg(null);
|
setLocalMsg(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -412,22 +438,34 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
||||||
<div className={styles.formRow}>
|
<div className={styles.formRow}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
<label>{t('Betrag (CHF)')}</label>
|
<label>{t('Betrag (CHF)')}</label>
|
||||||
<input
|
{allowedAmounts.length > 0 ? (
|
||||||
type="number"
|
<select
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={e => setAmount(e.target.value)}
|
onChange={e => setAmount(e.target.value)}
|
||||||
placeholder={t('z. B. 50 oder -20')}
|
required
|
||||||
min="0.01"
|
>
|
||||||
step="0.01"
|
{allowedAmounts.map(a => (
|
||||||
required
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
disabled={busy || !amount}
|
disabled={busy || !amount || allowedAmounts.length === 0}
|
||||||
>
|
>
|
||||||
{busy ? t('Weiterleitung…') : t('Mit Stripe bezahlen')}
|
{busy ? t('Weiterleitung…') : t('Mit Stripe bezahlen')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,26 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
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 { BillingNav } from './BillingNav';
|
||||||
import styles from './Billing.module.css';
|
import styles from './Billing.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
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
|
// BALANCE CARD COMPONENT
|
||||||
|
|
@ -161,30 +176,34 @@ export const BillingDashboard: React.FC = () => {
|
||||||
loadStatistics
|
loadStatistics
|
||||||
} = useBilling();
|
} = useBilling();
|
||||||
|
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month');
|
const [period, setPeriod] = useState<PeriodValue>(() => {
|
||||||
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
const r = resolvePeriod(_DEFAULT_BILLING_PRESET);
|
||||||
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
|
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);
|
||||||
|
|
||||||
// Load statistics when period changes
|
const _handlePeriodChange = (next: PeriodValue) => {
|
||||||
useEffect(() => {
|
setPeriod(next);
|
||||||
if (selectedPeriod === 'month') {
|
if (!bucketUserOverridden) {
|
||||||
loadStatistics('month', selectedYear);
|
setBucketSize(_suggestBucketSize(next));
|
||||||
} else {
|
|
||||||
loadStatistics('year', selectedYear);
|
|
||||||
}
|
}
|
||||||
}, [selectedPeriod, selectedYear, loadStatistics]);
|
};
|
||||||
|
|
||||||
// Available years (current and last 2 years)
|
useEffect(() => {
|
||||||
const availableYears = useMemo(() => {
|
void loadStatistics({
|
||||||
const current = new Date().getFullYear();
|
dateFrom: period.fromDate,
|
||||||
return [current, current - 1, current - 2];
|
dateTo: period.toDate,
|
||||||
}, []);
|
bucketSize,
|
||||||
|
});
|
||||||
|
}, [period.fromDate, period.toDate, bucketSize, loadStatistics]);
|
||||||
|
|
||||||
// Available months
|
const _bucketLabel = useMemo(() => ({
|
||||||
const availableMonths = Array.from({ length: 12 }, (_, i) => ({
|
day: t('Tag'),
|
||||||
value: i + 1,
|
month: t('Monat'),
|
||||||
label: String(i + 1),
|
year: t('Jahr'),
|
||||||
}));
|
} as Record<BillingBucketSize, string>), [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.billingDashboard}>
|
<div className={styles.billingDashboard}>
|
||||||
|
|
@ -216,34 +235,30 @@ export const BillingDashboard: React.FC = () => {
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h2 className={styles.sectionTitle}>{t('Nutzungsstatistik')}</h2>
|
<h2 className={styles.sectionTitle}>{t('Nutzungsstatistik')}</h2>
|
||||||
<div className={styles.periodSelector}>
|
<div className={styles.periodSelector}>
|
||||||
|
<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
|
<select
|
||||||
value={selectedPeriod}
|
value={bucketSize}
|
||||||
onChange={(e) => setSelectedPeriod(e.target.value as 'month' | 'year')}
|
onChange={(e) => {
|
||||||
|
setBucketSize(e.target.value as BillingBucketSize);
|
||||||
|
setBucketUserOverridden(true);
|
||||||
|
}}
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
|
aria-label={t('Gruppierung')}
|
||||||
>
|
>
|
||||||
<option value="month">{t('Monat')}</option>
|
<option value="day">{_bucketLabel.day}</option>
|
||||||
<option value="year">{t('Jahr')}</option>
|
<option value="month">{_bucketLabel.month}</option>
|
||||||
|
<option value="year">{_bucketLabel.year}</option>
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<StatisticsChart statistics={statistics} loading={loading} />
|
<StatisticsChart statistics={statistics} loading={loading} />
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,32 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator
|
||||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useBilling } from '../../hooks/useBilling';
|
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
||||||
import { UserTransaction } from '../../api/billingApi';
|
import { UserTransaction } from '../../api/billingApi';
|
||||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import {
|
||||||
|
daysInRange,
|
||||||
|
resolvePeriod,
|
||||||
|
toIsoDate,
|
||||||
|
type PeriodValue,
|
||||||
|
} from '../../components/PeriodPicker';
|
||||||
import styles from './Billing.module.css';
|
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;
|
type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -336,14 +356,24 @@ export const BillingDataView: React.FC = () => {
|
||||||
return params;
|
return params;
|
||||||
}, [selectedScope, onlyMyData]);
|
}, [selectedScope, onlyMyData]);
|
||||||
|
|
||||||
// Load aggregated statistics from the view/statistics route
|
const [statsPeriod, setStatsPeriod] = useState<PeriodValue>(() => _initialStatsPeriod());
|
||||||
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
|
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 {
|
try {
|
||||||
setStatsLoading(true);
|
setStatsLoading(true);
|
||||||
const params: Record<string, string | number> = { period, year, ..._scopeParams };
|
const params: Record<string, string | number> = {
|
||||||
if (period === 'day' && month) {
|
dateFrom: range.dateFrom,
|
||||||
params.month = month;
|
dateTo: range.dateTo,
|
||||||
}
|
bucketSize: range.bucketSize,
|
||||||
|
..._scopeParams,
|
||||||
|
};
|
||||||
const response = await api.get('/api/billing/view/statistics', { params });
|
const response = await api.get('/api/billing/view/statistics', { params });
|
||||||
setViewStats(response.data);
|
setViewStats(response.data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -354,13 +384,26 @@ export const BillingDataView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [_scopeParams]);
|
}, [_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 _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
|
||||||
const period = filterState.period || 'month';
|
let next: PeriodValue | null = null;
|
||||||
const year = filterState.year || new Date().getFullYear();
|
if (filterState.periodValue) {
|
||||||
const month = filterState.month;
|
next = filterState.periodValue;
|
||||||
_loadViewStatistics(period, year, month);
|
} else if (filterState.dateRange) {
|
||||||
}, [_loadViewStatistics]);
|
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
|
// Load storage volume for all accessible mandates
|
||||||
const _loadStorageData = useCallback(async () => {
|
const _loadStorageData = useCallback(async () => {
|
||||||
|
|
@ -398,13 +441,17 @@ export const BillingDataView: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [balances, selectedScope, onlyMyData]);
|
}, [balances, selectedScope, onlyMyData]);
|
||||||
|
|
||||||
// Initial data load
|
// Initial / reactive load: any change to period / bucketSize / scope reloads.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'overview' || activeTab === 'diagrams') {
|
if (activeTab === 'overview' || activeTab === 'diagrams') {
|
||||||
_loadViewStatistics('month', new Date().getFullYear());
|
void _loadViewStatistics({
|
||||||
|
dateFrom: statsPeriod.fromDate,
|
||||||
|
dateTo: statsPeriod.toDate,
|
||||||
|
bucketSize: statsBucketSize,
|
||||||
|
});
|
||||||
_loadStorageData();
|
_loadStorageData();
|
||||||
}
|
}
|
||||||
}, [activeTab, _loadViewStatistics, _loadStorageData]);
|
}, [activeTab, statsPeriod.fromDate, statsPeriod.toDate, statsBucketSize, _loadViewStatistics, _loadStorageData]);
|
||||||
|
|
||||||
// Load transactions with pagination support + scope filter
|
// Load transactions with pagination support + scope filter
|
||||||
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
||||||
|
|
@ -499,14 +546,15 @@ export const BillingDataView: React.FC = () => {
|
||||||
return _buildDiagramSections(viewStats, chartMode, t);
|
return _buildDiagramSections(viewStats, chartMode, t);
|
||||||
}, [viewStats, chartMode, t]);
|
}, [viewStats, chartMode, t]);
|
||||||
|
|
||||||
// Period selector config (shared between overview and statistics)
|
// Date-range selector config: use shared PeriodPicker via FormGeneratorReport.
|
||||||
const periodSelectorConfig = useMemo(() => ({
|
const dateRangeSelectorConfig = useMemo(() => ({
|
||||||
periods: ['month' as const, 'day' as const],
|
enabled: true,
|
||||||
defaultPeriod: 'month' as const,
|
direction: 'past' as const,
|
||||||
showYear: true,
|
defaultPresetKind: 'thisMonth' as const,
|
||||||
showMonth: true,
|
enabledPresets: [
|
||||||
defaultYear: new Date().getFullYear(),
|
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||||
defaultMonth: new Date().getMonth() + 1
|
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||||
|
] as const,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
// Build scope options from balances (mandates the user has access to)
|
// Build scope options from balances (mandates the user has access to)
|
||||||
|
|
@ -627,8 +675,24 @@ export const BillingDataView: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<FormGeneratorReport
|
||||||
periodSelector={periodSelectorConfig}
|
dateRangeSelector={dateRangeSelectorConfig}
|
||||||
onFilterChange={_handleStatsFilterChange}
|
onFilterChange={_handleStatsFilterChange}
|
||||||
loading={statsLoading}
|
loading={statsLoading}
|
||||||
sections={diagramSections}
|
sections={diagramSections}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ import { useToast } from '../../../contexts/ToastContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import styles from './TrusteeViews.module.css';
|
import styles from './TrusteeViews.module.css';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import {
|
||||||
|
PeriodPicker,
|
||||||
|
resolvePeriod,
|
||||||
|
type PeriodValue,
|
||||||
|
} from '../../../components/PeriodPicker';
|
||||||
|
|
||||||
|
const _DEFAULT_PERIOD_PRESET = { kind: 'lastYear' as const };
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tab definitions
|
// Tab definitions
|
||||||
|
|
@ -21,18 +28,23 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface TabDef {
|
interface TabDef {
|
||||||
id: string;
|
id: string;
|
||||||
templateTag: string;
|
templateTag: string | null;
|
||||||
icon: string;
|
icon: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
comingSoon?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _TABS: TabDef[] = [
|
const _TABS: TabDef[] = [
|
||||||
{ id: 'year-end', templateTag: 'template:trustee-year-end-check', icon: '\u2705', color: '#795548' },
|
{ 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 {
|
function _tabLabel(tabId: string, t: (k: string) => string): string {
|
||||||
switch (tabId) {
|
switch (tabId) {
|
||||||
case 'year-end': return t('Jahresabschluss prüfen');
|
case 'year-end': return t('Jahresabschluss prüfen');
|
||||||
|
case 'vat': return t('MWST-Abrechnung');
|
||||||
|
case 'reporting': return t('Reporting Behörden');
|
||||||
default: return tabId;
|
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 {
|
function _tabDescription(tabId: string, t: (k: string) => string): string {
|
||||||
switch (tabId) {
|
switch (tabId) {
|
||||||
case 'year-end': return t('Automatische Prüfungen für den Jahresabschluss: Saldovalidierung, Vorjahresvergleich, gesetzliche Checks.');
|
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 '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -81,6 +95,11 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
const pollTimerRef = useRef<number | null>(null);
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
const isPollingRef = useRef(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
const _load = async () => {
|
const _load = async () => {
|
||||||
|
|
@ -104,8 +123,9 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
|
|
||||||
const _findWorkflow = useCallback((tab: string): WorkflowSummary | undefined => {
|
const _findWorkflow = useCallback((tab: string): WorkflowSummary | undefined => {
|
||||||
const tabDef = _TABS.find((tabItem) => tabItem.id === tab);
|
const tabDef = _TABS.find((tabItem) => tabItem.id === tab);
|
||||||
if (!tabDef) return undefined;
|
if (!tabDef || !tabDef.templateTag) return undefined;
|
||||||
return workflows.find((w) => w.tags.includes(tabDef.templateTag));
|
const templateTag = tabDef.templateTag;
|
||||||
|
return workflows.find((w) => w.tags.includes(templateTag));
|
||||||
}, [workflows]);
|
}, [workflows]);
|
||||||
|
|
||||||
const _stopPolling = useCallback(() => {
|
const _stopPolling = useCallback(() => {
|
||||||
|
|
@ -180,7 +200,10 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
setRunError(null);
|
setRunError(null);
|
||||||
setRunSummary(t('Workflow wird gestartet…'));
|
setRunSummary(t('Workflow wird gestartet…'));
|
||||||
try {
|
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;
|
const rid = res?.data?.runId;
|
||||||
if (rid) {
|
if (rid) {
|
||||||
setRunId(rid);
|
setRunId(rid);
|
||||||
|
|
@ -199,10 +222,11 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
showError(t('Fehler'), 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 currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
|
||||||
const currentWorkflow = _findWorkflow(activeTab);
|
const currentWorkflow = _findWorkflow(activeTab);
|
||||||
|
const isComingSoon = !!currentTab.comingSoon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.listView}>
|
||||||
|
|
@ -242,7 +266,14 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
{_tabDescription(activeTab, t)}
|
{_tabDescription(activeTab, t)}
|
||||||
</p>
|
</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>
|
<p className={styles.loadingText}>{t('Workflows werden geladen…')}</p>
|
||||||
) : !currentWorkflow ? (
|
) : !currentWorkflow ? (
|
||||||
<div className={styles.infoBox}>
|
<div className={styles.infoBox}>
|
||||||
|
|
@ -260,6 +291,18 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
className={styles.primaryButton}
|
className={styles.primaryButton}
|
||||||
onClick={_handleExecute}
|
onClick={_handleExecute}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
|
@ -23,13 +24,33 @@ import {
|
||||||
type AccountingConnectorInfo,
|
type AccountingConnectorInfo,
|
||||||
type AccountingConfig,
|
type AccountingConfig,
|
||||||
} from '../../../api/trusteeApi';
|
} from '../../../api/trusteeApi';
|
||||||
|
import { PeriodPicker, type PeriodValue } from '../../../components/PeriodPicker';
|
||||||
import styles from './TrusteeViews.module.css';
|
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 = () => {
|
export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { instanceId } = useCurrentInstance();
|
const { instanceId } = useCurrentInstance();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showSuccess, showError } = useToast();
|
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 [connectors, setConnectors] = useState<AccountingConnectorInfo[]>([]);
|
||||||
const [existingConfig, setExistingConfig] = useState<AccountingConfig | null>(null);
|
const [existingConfig, setExistingConfig] = useState<AccountingConfig | null>(null);
|
||||||
|
|
@ -47,8 +68,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const [importJobId, setImportJobId] = useState<string | null>(null);
|
const [importJobId, setImportJobId] = useState<string | null>(null);
|
||||||
const [clearingCache, setClearingCache] = useState(false);
|
const [clearingCache, setClearingCache] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [importPeriod, setImportPeriod] = useState<PeriodValue | null>(null);
|
||||||
const [dateTo, setDateTo] = useState('');
|
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -218,17 +238,44 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
<div className={styles.listView}>
|
<div className={styles.listView}>
|
||||||
<div className={styles.expenseImportSection}>
|
<div className={styles.expenseImportSection}>
|
||||||
<h3 className={styles.sectionTitle}>{t('Buchhaltungssystem-Anbindung')}</h3>
|
<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 style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--border-color, #e0e0e0)', paddingBottom: 0 }}>
|
||||||
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
{_SETTINGS_TABS.map((tab) => (
|
||||||
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
<button
|
||||||
</div>
|
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.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||||
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
||||||
<div className={styles.stepContent}>
|
<div className={styles.stepContent}>
|
||||||
|
|
@ -366,12 +413,23 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Import Accounting Data */}
|
</>
|
||||||
{existingConfig?.configured && (
|
)}
|
||||||
<div className={styles.setupStep}>
|
|
||||||
<div className={styles.stepNumber}>4</div>
|
{activeTab === 'import-data' && (
|
||||||
<div className={styles.stepContent}>
|
<>
|
||||||
<h4>{t('Buchhaltungsdaten importieren')}</h4>
|
{!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' }}>
|
<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.')}
|
{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>
|
</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>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Von (optional)')}</label>
|
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.25rem' }}>{t('Zeitraum (optional)')}</label>
|
||||||
<input type="date" className={styles.folderSelect} value={dateFrom} onChange={e => setDateFrom(e.target.value)} style={{ width: '160px' }} />
|
<PeriodPicker
|
||||||
|
value={importPeriod}
|
||||||
|
onChange={setImportPeriod}
|
||||||
|
direction="past"
|
||||||
|
placeholder={t('Alle Daten')}
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
|
@ -472,8 +506,8 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
setImportJobId(null);
|
setImportJobId(null);
|
||||||
try {
|
try {
|
||||||
const body: Record<string, string> = {};
|
const body: Record<string, string> = {};
|
||||||
if (dateFrom) body.dateFrom = dateFrom;
|
if (importPeriod?.fromDate) body.dateFrom = importPeriod.fromDate;
|
||||||
if (dateTo) body.dateTo = dateTo;
|
if (importPeriod?.toDate) body.dateTo = importPeriod.toDate;
|
||||||
const result = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
const result = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
||||||
const newJobId: string | undefined = result?.jobId;
|
const newJobId: string | undefined = result?.jobId;
|
||||||
if (newJobId) {
|
if (newJobId) {
|
||||||
|
|
@ -576,8 +610,10 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* and results/status are shown inline with polling.
|
* 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 { useSearchParams } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
|
@ -15,6 +15,13 @@ import api from '../../../api';
|
||||||
import styles from './TrusteeViews.module.css';
|
import styles from './TrusteeViews.module.css';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { FaUpload, FaTimes } from 'react-icons/fa';
|
import { FaUpload, FaTimes } from 'react-icons/fa';
|
||||||
|
import {
|
||||||
|
PeriodPicker,
|
||||||
|
resolvePeriod,
|
||||||
|
type PeriodDirection,
|
||||||
|
type PeriodPreset,
|
||||||
|
type PeriodValue,
|
||||||
|
} from '../../../components/PeriodPicker';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tab definitions
|
// 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
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -99,6 +129,18 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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
|
// Load workflows for this instance once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
|
|
@ -266,9 +308,14 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
setResultDocuments([]);
|
setResultDocuments([]);
|
||||||
try {
|
try {
|
||||||
const executeBody: Record<string, any> = { workflowId: wf.id };
|
const executeBody: Record<string, any> = { workflowId: wf.id };
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
dateFrom: currentPeriod.fromDate,
|
||||||
|
dateTo: currentPeriod.toDate,
|
||||||
|
};
|
||||||
if (activeTab === 'budget' && budgetFileId) {
|
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 res = await api.post(`/api/workflows/${instanceId}/execute`, executeBody);
|
||||||
const rid = res?.data?.runId;
|
const rid = res?.data?.runId;
|
||||||
if (rid) {
|
if (rid) {
|
||||||
|
|
@ -291,7 +338,7 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
showError(t('Fehler'), 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 currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
|
||||||
const currentWorkflow = _findWorkflow(activeTab);
|
const currentWorkflow = _findWorkflow(activeTab);
|
||||||
|
|
@ -396,6 +443,18 @@ export const TrusteeAnalyseView: 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('Zeitraum')}
|
||||||
|
</label>
|
||||||
|
<PeriodPicker
|
||||||
|
value={currentPeriod}
|
||||||
|
onChange={_setCurrentPeriod}
|
||||||
|
direction={tabPeriodConfig.direction}
|
||||||
|
defaultPreset={tabPeriodConfig.defaultPreset}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={styles.primaryButton}
|
className={styles.primaryButton}
|
||||||
onClick={_handleExecute}
|
onClick={_handleExecute}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export const TrusteeDashboardView: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mandateId } = useParams<{ mandateId: string }>();
|
const { mandateId } = useParams<{ mandateId: string }>();
|
||||||
|
|
||||||
const { instance, instanceId } = useCurrentInstance();
|
const { mandate, instance, instanceId } = useCurrentInstance();
|
||||||
const { items: positions, loading: posLoading } = useTrusteePositions();
|
const { items: positions, loading: posLoading } = useTrusteePositions();
|
||||||
const { items: documents, loading: docsLoading } = useTrusteeDocuments();
|
const { items: documents, loading: docsLoading } = useTrusteeDocuments();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
|
@ -136,7 +136,7 @@ export const TrusteeDashboardView: React.FC = () => {
|
||||||
<div className={styles.statValueSmall}>
|
<div className={styles.statValueSmall}>
|
||||||
{instance?.userRoles?.length ? (
|
{instance?.userRoles?.length ? (
|
||||||
instance.userRoles.map((role: string, idx: number) => (
|
instance.userRoles.map((role: string, idx: number) => (
|
||||||
<div key={idx}>{role}</div>
|
<div key={idx}>{t(role)}</div>
|
||||||
))
|
))
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,15 +162,17 @@ export const TrusteeDashboardView: React.FC = () => {
|
||||||
<span className={styles.infoValue}>{instance?.instanceLabel}</span>
|
<span className={styles.infoValue}>{instance?.instanceLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.infoLabel}>{t('Mandant')}</span>
|
<span className={styles.infoLabel}>{t('Mandant:')}</span>
|
||||||
<span className={styles.infoValue}>{instance?.mandateName}</span>
|
<span className={styles.infoValue}>
|
||||||
|
{mandate?.label || instance?.mandateLabel || mandate?.name || instance?.mandateName || '-'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{accountingConfig?.configured && (
|
{accountingConfig?.configured && (
|
||||||
<div className={styles.infoItem}>
|
<div className={styles.infoItem}>
|
||||||
<span className={styles.infoLabel}>{t('Buchhaltungssystem:')}</span>
|
<span className={styles.infoLabel}>{t('Buchhaltungssystem:')}</span>
|
||||||
<span className={styles.infoValue}>
|
<span className={styles.infoValue}>
|
||||||
{accountingConfig.displayLabel || accountingConfig.connectorType}
|
{accountingConfig.displayLabel || accountingConfig.connectorType}
|
||||||
{accountingConfig.lastSyncStatus && ` (${accountingConfig.lastSyncStatus})`}
|
{accountingConfig.lastSyncStatus && ` (${t(accountingConfig.lastSyncStatus)})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
271
src/pages/views/trustee/TrusteeDataTablesView.tsx
Normal file
271
src/pages/views/trustee/TrusteeDataTablesView.tsx
Normal 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;
|
||||||
|
|
@ -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 { t } = useLanguage();
|
||||||
const { instanceId, mandateId } = useCurrentInstance();
|
const { instanceId, mandateId } = useCurrentInstance();
|
||||||
const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections();
|
const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections();
|
||||||
|
|
@ -464,10 +468,9 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<div className={styles.listView}>
|
<>
|
||||||
<div className={styles.expenseImportSection}>
|
{!embedded && <h3 className={styles.sectionTitle}>{t('Einrichtung des Ausgabenimports')}</h3>}
|
||||||
<h3 className={styles.sectionTitle}>{t('Einrichtung des Ausgabenimports')}</h3>
|
|
||||||
<p className={styles.sectionDescription}>
|
<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.')}
|
{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
|
<span
|
||||||
|
|
@ -699,7 +702,17 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <>{content}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.listView}>
|
||||||
|
<div className={styles.expenseImportSection}>
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
115
src/pages/views/trustee/TrusteeImportProcessView.tsx
Normal file
115
src/pages/views/trustee/TrusteeImportProcessView.tsx
Normal 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;
|
||||||
|
|
@ -38,7 +38,11 @@ const _parseErrorDetail = (detail: unknown): string => {
|
||||||
return String(detail);
|
return String(detail);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TrusteeScanUploadView: React.FC = () => {
|
interface TrusteeScanUploadViewProps {
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrusteeScanUploadView: React.FC<TrusteeScanUploadViewProps> = ({ embedded = false }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { instanceId } = useCurrentInstance();
|
const { instanceId } = useCurrentInstance();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
@ -262,12 +266,11 @@ export const TrusteeScanUploadView: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<div className={styles.listView}>
|
<>
|
||||||
<div className={styles.expenseImportSection}>
|
{!embedded && <h3 className={styles.sectionTitle}>{t('Scan-Upload')}</h3>}
|
||||||
<h3 className={styles.sectionTitle}>{t('Scan-Upload')}</h3>
|
|
||||||
<p className={styles.sectionDescription}>
|
<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>
|
</p>
|
||||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||||
{pipelineState !== 'idle' && (
|
{pipelineState !== 'idle' && (
|
||||||
|
|
@ -369,6 +372,17 @@ export const TrusteeScanUploadView: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <>{content}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.listView}>
|
||||||
|
<div className={styles.expenseImportSection}>
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
309
src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
Normal file
309
src/pages/views/trustee/dataTables/TrusteeDataTab.tsx
Normal 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;
|
||||||
|
|
@ -8,6 +8,8 @@ export { TrusteePositionsView } from './TrusteePositionsView';
|
||||||
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
||||||
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
||||||
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
||||||
|
export { TrusteeImportProcessView } from './TrusteeImportProcessView';
|
||||||
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
|
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
|
||||||
export { TrusteeAnalyseView } from './TrusteeAnalyseView';
|
export { TrusteeAnalyseView } from './TrusteeAnalyseView';
|
||||||
export { TrusteeAbschlussView } from './TrusteeAbschlussView';
|
export { TrusteeAbschlussView } from './TrusteeAbschlussView';
|
||||||
|
export { TrusteeDataTablesView } from './TrusteeDataTablesView';
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
||||||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||||
import { getPageIcon } from '../../../config/pageRegistry';
|
import { getPageIcon } from '../../../config/pageRegistry';
|
||||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||||
|
import api from '../../../api';
|
||||||
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
@ -50,9 +51,22 @@ interface WorkspaceInputProps {
|
||||||
onPasteAsFile?: (file: File) => void;
|
onPasteAsFile?: (file: File) => void;
|
||||||
draftAppend?: string;
|
draftAppend?: string;
|
||||||
onDraftAppendConsumed?: () => void;
|
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,
|
onSend,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
onStop,
|
onStop,
|
||||||
|
|
@ -76,6 +90,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
||||||
onPasteAsFile,
|
onPasteAsFile,
|
||||||
draftAppend,
|
draftAppend,
|
||||||
onDraftAppendConsumed,
|
onDraftAppendConsumed,
|
||||||
|
workflowId,
|
||||||
|
loadedAttachedDataSourceIds,
|
||||||
|
loadedAttachedFeatureDataSourceIds,
|
||||||
|
loadedNonce,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
|
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
|
||||||
|
|
@ -118,6 +136,50 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
||||||
}
|
}
|
||||||
}, [pendingAttachFdsId, onPendingAttachFdsConsumed]);
|
}, [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 promptBeforeVoiceRef = useRef('');
|
||||||
const finalizedTextRef = useRef('');
|
const finalizedTextRef = useRef('');
|
||||||
const currentInterimRef = useRef('');
|
const currentInterimRef = useRef('');
|
||||||
|
|
@ -210,14 +272,20 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const _removeAttachedDataSource = useCallback((dsId: string) => {
|
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) => {
|
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
|
||||||
setAttachedFeatureDataSourceIds(prev =>
|
setAttachedFeatureDataSourceIds(prev => {
|
||||||
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
|
const next = prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId];
|
||||||
);
|
_persistAttachments(attachedDataSourceIds, next);
|
||||||
}, []);
|
return next;
|
||||||
|
});
|
||||||
|
}, [_persistAttachments, attachedDataSourceIds]);
|
||||||
|
|
||||||
const _buildPromptFromRefs = useCallback(() => {
|
const _buildPromptFromRefs = useCallback(() => {
|
||||||
const parts = [
|
const parts = [
|
||||||
|
|
|
||||||
|
|
@ -539,6 +539,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
onPasteAsFile={_uploadAndAttach}
|
onPasteAsFile={_uploadAndAttach}
|
||||||
draftAppend={draftAppend}
|
draftAppend={draftAppend}
|
||||||
onDraftAppendConsumed={() => setDraftAppend('')}
|
onDraftAppendConsumed={() => setDraftAppend('')}
|
||||||
|
workflowId={workspace.workflowId}
|
||||||
|
loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds}
|
||||||
|
loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds}
|
||||||
|
loadedNonce={workspace.loadedNonce}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@ interface UseWorkspaceReturn {
|
||||||
refreshFolders: () => void;
|
refreshFolders: () => void;
|
||||||
refreshDataSources: () => void;
|
refreshDataSources: () => void;
|
||||||
dataSourceAccesses: DataSourceAccessEvent[];
|
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 {
|
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
|
|
@ -131,6 +144,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||||
const [workflowVersion, setWorkflowVersion] = useState(0);
|
const [workflowVersion, setWorkflowVersion] = useState(0);
|
||||||
const [dataSourceAccesses, setDataSourceAccesses] = useState<DataSourceAccessEvent[]>([]);
|
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 cleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
const refreshFiles = useCallback(() => {
|
const refreshFiles = useCallback(() => {
|
||||||
|
|
@ -177,6 +193,8 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
setPendingEdits([]);
|
setPendingEdits([]);
|
||||||
setAgentProgress(null);
|
setAgentProgress(null);
|
||||||
setDataSourceAccesses([]);
|
setDataSourceAccesses([]);
|
||||||
|
setLoadedAttachedDataSourceIds([]);
|
||||||
|
setLoadedAttachedFeatureDataSourceIds([]);
|
||||||
|
|
||||||
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
|
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
|
|
@ -184,6 +202,15 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
.map((m: any) => _mapLoadedWorkspaceMessage(m, wfId))
|
.map((m: any) => _mapLoadedWorkspaceMessage(m, wfId))
|
||||||
.sort(_compareWorkspaceMessages);
|
.sort(_compareWorkspaceMessages);
|
||||||
setMessages(msgs);
|
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(() => {});
|
.catch(() => {});
|
||||||
}, [instanceId]);
|
}, [instanceId]);
|
||||||
|
|
@ -195,6 +222,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
setPendingEdits([]);
|
setPendingEdits([]);
|
||||||
setAgentProgress(null);
|
setAgentProgress(null);
|
||||||
setDataSourceAccesses([]);
|
setDataSourceAccesses([]);
|
||||||
|
setLoadedAttachedDataSourceIds([]);
|
||||||
|
setLoadedAttachedFeatureDataSourceIds([]);
|
||||||
|
setLoadedNonce(n => n + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
|
|
@ -496,6 +526,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
refreshFolders,
|
refreshFolders,
|
||||||
refreshDataSources,
|
refreshDataSources,
|
||||||
dataSourceAccesses,
|
dataSourceAccesses,
|
||||||
|
loadedAttachedDataSourceIds,
|
||||||
|
loadedAttachedFeatureDataSourceIds,
|
||||||
|
loadedNonce,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@ export interface FeatureInstance {
|
||||||
id: string; // UUID der Instanz
|
id: string; // UUID der Instanz
|
||||||
featureCode: string; // "trustee", "chatbot", "chatworkflow", etc.
|
featureCode: string; // "trustee", "chatbot", "chatworkflow", etc.
|
||||||
mandateId: string; // Zugehöriger Mandant
|
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"
|
instanceLabel: string; // z.B. "PamoCreate AG"
|
||||||
userRoles: string[]; // Rollen des Users in dieser Instanz (kann mehrere haben)
|
userRoles: string[]; // Rollen des Users in dieser Instanz (kann mehrere haben)
|
||||||
permissions: InstancePermissions;
|
permissions: InstancePermissions;
|
||||||
|
|
@ -205,11 +206,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
icon: 'briefcase',
|
icon: 'briefcase',
|
||||||
views: [
|
views: [
|
||||||
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
|
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
|
||||||
{ code: 'positions', label: 'Positionen', path: 'positions' },
|
{ code: 'data-tables', label: 'Daten-Tabellen', path: 'data-tables' },
|
||||||
{ code: 'documents', label: 'Dokumente', path: 'documents' },
|
|
||||||
{ code: 'position-documents', label: 'Zuordnungen', path: 'position-documents' },
|
{ code: 'position-documents', label: 'Zuordnungen', path: 'position-documents' },
|
||||||
{ code: 'expense-import', label: 'Spesen Import', path: 'expense-import' },
|
{ code: 'import-process', label: 'Import & Verarbeitung', path: 'import-process' },
|
||||||
{ code: 'scan-upload', label: 'Scannen / Hochladen', path: 'scan-upload' },
|
|
||||||
{ code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
|
{ code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
|
||||||
{ code: 'settings', label: 'Buchhaltungseinstellungen', path: 'settings' },
|
{ code: 'settings', label: 'Buchhaltungseinstellungen', path: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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(
|
export function splitMandateAndBillingFromForm(
|
||||||
formData: Record<string, unknown>
|
formData: Record<string, unknown>
|
||||||
): { mandatePayload: Record<string, unknown>; billingUpdate: BillingSettingsUpdate } {
|
): { mandatePayload: Record<string, unknown>; billingUpdate: BillingSettingsUpdate } {
|
||||||
|
|
@ -92,6 +114,18 @@ export function splitMandateAndBillingFromForm(
|
||||||
if ('name' in formData) mandatePayload.name = formData.name;
|
if ('name' in formData) mandatePayload.name = formData.name;
|
||||||
if ('label' in formData) mandatePayload.label = formData.label;
|
if ('label' in formData) mandatePayload.label = formData.label;
|
||||||
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
|
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 = {};
|
const billingUpdate: BillingSettingsUpdate = {};
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue