Merge pull request #49 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
This commit is contained in:
commit
3f4a98381d
37 changed files with 3899 additions and 773 deletions
|
|
@ -147,8 +147,7 @@ function App() {
|
|||
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
|
||||
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
|
||||
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
|
||||
<Route path="documents" element={<FeatureViewPage view="documents" />} />
|
||||
<Route path="positions" element={<FeatureViewPage view="positions" />} />
|
||||
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
|
||||
<Route path="roles" element={<FeatureViewPage view="roles" />} />
|
||||
<Route path="access" element={<FeatureViewPage view="access" />} />
|
||||
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
||||
|
|
@ -158,8 +157,7 @@ function App() {
|
|||
<Route path="chat" element={<FeatureViewPage view="chat" />} />
|
||||
<Route path="threads" element={<FeatureViewPage view="threads" />} />
|
||||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
|
||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
||||
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
||||
|
|
|
|||
|
|
@ -56,8 +56,12 @@ export interface BillingSettingsUpdate {
|
|||
rechargeMaxPerMonth?: number;
|
||||
}
|
||||
|
||||
export type BillingBucketSize = 'day' | 'month' | 'year';
|
||||
|
||||
export interface UsageReport {
|
||||
period: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
bucketSize: BillingBucketSize;
|
||||
totalCost: number;
|
||||
transactionCount: number;
|
||||
costByProvider: Record<string, number>;
|
||||
|
|
@ -65,6 +69,12 @@ export interface UsageReport {
|
|||
costByFeature: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface StatisticsRangeRequest {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
bucketSize: BillingBucketSize;
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
|
|
@ -141,24 +151,21 @@ export async function fetchTransactions(
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch usage statistics
|
||||
* Endpoint: GET /api/billing/statistics/{period}
|
||||
* Fetch usage statistics for an explicit date range.
|
||||
* Endpoint: GET /api/billing/statistics
|
||||
*/
|
||||
export async function fetchStatistics(
|
||||
request: ApiRequestFunction,
|
||||
period: 'day' | 'month' | 'year',
|
||||
year: number,
|
||||
month?: number
|
||||
range: StatisticsRangeRequest
|
||||
): Promise<UsageReport> {
|
||||
const params: Record<string, any> = { year };
|
||||
if (month !== undefined) {
|
||||
params.month = month;
|
||||
}
|
||||
|
||||
return await request({
|
||||
url: `/api/billing/statistics/${period}`,
|
||||
url: '/api/billing/statistics',
|
||||
method: 'get',
|
||||
params
|
||||
params: {
|
||||
dateFrom: range.dateFrom,
|
||||
dateTo: range.dateTo,
|
||||
bucketSize: range.bucketSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +232,19 @@ export async function addCreditAdmin(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the server-side allow-list of CHF top-up amounts
|
||||
* Endpoint: GET /api/billing/checkout/amounts
|
||||
*/
|
||||
export async function fetchCheckoutAmounts(
|
||||
request: ApiRequestFunction
|
||||
): Promise<number[]> {
|
||||
return await request({
|
||||
url: '/api/billing/checkout/amounts',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Stripe Checkout Session for credit top-up
|
||||
* Endpoint: POST /api/billing/checkout/create/{mandateId}
|
||||
|
|
|
|||
|
|
@ -875,6 +875,91 @@ export async function fetchSyncStatus(
|
|||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// READ-ONLY DATA TABLE API (Daten-Tabellen page)
|
||||
// ============================================================================
|
||||
//
|
||||
// Generic read-only endpoints for the consolidated data tables view.
|
||||
// All entities are paginated, sortable, filterable via the Unified Filter API
|
||||
// (mode=filterValues / mode=ids); no CRUD writes are exposed by these helpers.
|
||||
|
||||
export interface TrusteeDataAccount { id: string; [key: string]: any; }
|
||||
export interface TrusteeDataJournalEntry { id: string; [key: string]: any; }
|
||||
export interface TrusteeDataJournalLine { id: string; [key: string]: any; }
|
||||
export interface TrusteeDataContact { id: string; [key: string]: any; }
|
||||
export interface TrusteeDataAccountBalance { id: string; [key: string]: any; }
|
||||
export interface TrusteeAccountingConfigRecord { id: string; [key: string]: any; }
|
||||
export interface TrusteeAccountingSyncRecord { id: string; [key: string]: any; }
|
||||
|
||||
async function _fetchReadOnlyTable<T = any>(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
pathSegment: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<T> | T[]> {
|
||||
return await request({
|
||||
url: `${_getTrusteeBaseUrl(instanceId)}/${pathSegment}`,
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDataAccounts(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataAccount> | TrusteeDataAccount[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataAccount>(request, instanceId, 'data/accounts', params);
|
||||
}
|
||||
|
||||
export async function fetchDataJournalEntries(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataJournalEntry> | TrusteeDataJournalEntry[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataJournalEntry>(request, instanceId, 'data/journal-entries', params);
|
||||
}
|
||||
|
||||
export async function fetchDataJournalLines(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataJournalLine> | TrusteeDataJournalLine[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataJournalLine>(request, instanceId, 'data/journal-lines', params);
|
||||
}
|
||||
|
||||
export async function fetchDataContacts(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataContact> | TrusteeDataContact[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataContact>(request, instanceId, 'data/contacts', params);
|
||||
}
|
||||
|
||||
export async function fetchDataAccountBalances(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataAccountBalance> | TrusteeDataAccountBalance[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataAccountBalance>(request, instanceId, 'data/account-balances', params);
|
||||
}
|
||||
|
||||
export async function fetchAccountingConfigs(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeAccountingConfigRecord> | TrusteeAccountingConfigRecord[]> {
|
||||
return _fetchReadOnlyTable<TrusteeAccountingConfigRecord>(request, instanceId, 'accounting/configs', params);
|
||||
}
|
||||
|
||||
export async function fetchAccountingSyncs(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeAccountingSyncRecord> | TrusteeAccountingSyncRecord[]> {
|
||||
return _fetchReadOnlyTable<TrusteeAccountingSyncRecord>(request, instanceId, 'accounting/syncs', params);
|
||||
}
|
||||
|
||||
export async function exportAccountingData(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ import {
|
|||
} from 'recharts';
|
||||
import styles from './FormGeneratorReport.module.css';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import {
|
||||
PeriodPicker,
|
||||
fromIsoDate,
|
||||
toIsoDate,
|
||||
type PeriodPreset,
|
||||
type PeriodValue,
|
||||
} from '../../PeriodPicker';
|
||||
|
||||
import type {
|
||||
FormGeneratorReportProps,
|
||||
|
|
@ -531,14 +538,36 @@ const _Toolbar: React.FC<ToolbarProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const _handleDateRangeChange = (field: 'from' | 'to', dateStr: string) => {
|
||||
const dateRange = filterState.dateRange || { from: new Date(), to: new Date() };
|
||||
const _handlePeriodPickerChange = (next: PeriodValue) => {
|
||||
const fromD = fromIsoDate(next.fromDate) || new Date();
|
||||
const toD = fromIsoDate(next.toDate) || new Date();
|
||||
onFilterStateChange({
|
||||
...filterState,
|
||||
dateRange: { ...dateRange, [field]: new Date(dateStr) }
|
||||
dateRange: { from: fromD, to: toD },
|
||||
periodValue: next,
|
||||
});
|
||||
};
|
||||
|
||||
// Prefer the preserved PeriodValue (carries the preset) so the round-trip
|
||||
// back into PeriodPicker does not collapse to `custom`, which would clash
|
||||
// with `direction: 'past'` for presets whose natural end is in the future
|
||||
// (e.g. `thisMonth`, `thisQuarter`, `ytd`) and trigger an infinite fallback
|
||||
// loop in PeriodPicker's constraint-correction effect.
|
||||
const _periodPickerValue: PeriodValue | null = useMemo(() => {
|
||||
if (filterState.periodValue) return filterState.periodValue;
|
||||
const dr = filterState.dateRange;
|
||||
if (!dr?.from || !dr?.to) return null;
|
||||
return {
|
||||
preset: { kind: 'custom' },
|
||||
fromDate: toIsoDate(dr.from),
|
||||
toDate: toIsoDate(dr.to),
|
||||
};
|
||||
}, [filterState.periodValue, filterState.dateRange]);
|
||||
|
||||
const _periodPickerDefault: PeriodPreset = useMemo(() => {
|
||||
return { kind: dateRangeSelector?.defaultPresetKind || 'ytd' } as PeriodPreset;
|
||||
}, [dateRangeSelector?.defaultPresetKind]);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
|
||||
|
||||
|
|
@ -605,22 +634,18 @@ const _Toolbar: React.FC<ToolbarProps> = ({
|
|||
<div className={styles.toolbarSeparator} />
|
||||
)}
|
||||
|
||||
{/* Date Range */}
|
||||
{/* Date Range (rendered via shared PeriodPicker) */}
|
||||
{hasDateRange && (
|
||||
<div className={styles.toolbarGroup}>
|
||||
<span className={styles.toolbarLabel}>{t('Von')}</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={filterState.dateRange?.from?.toISOString().split('T')[0] || ''}
|
||||
onChange={(e) => _handleDateRangeChange('from', e.target.value)}
|
||||
/>
|
||||
<span className={styles.toolbarLabel}>{t('Bis')}</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={filterState.dateRange?.to?.toISOString().split('T')[0] || ''}
|
||||
onChange={(e) => _handleDateRangeChange('to', e.target.value)}
|
||||
<span className={styles.toolbarLabel}>{t('Zeitraum')}</span>
|
||||
<PeriodPicker
|
||||
value={_periodPickerValue}
|
||||
onChange={_handlePeriodPickerChange}
|
||||
direction={dateRangeSelector!.direction || 'any'}
|
||||
defaultPreset={_periodPickerDefault}
|
||||
enabledPresets={dateRangeSelector!.enabledPresets}
|
||||
minDate={dateRangeSelector!.minDate}
|
||||
maxDate={dateRangeSelector!.maxDate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export interface ReportPeriodSelectorConfig {
|
|||
defaultMonth?: number;
|
||||
}
|
||||
|
||||
/** Date range selector configuration */
|
||||
/** Date range selector configuration. Renders the shared PeriodPicker. */
|
||||
export interface ReportDateRangeSelectorConfig {
|
||||
/** Whether the date range selector is enabled */
|
||||
enabled: boolean;
|
||||
|
|
@ -62,6 +62,28 @@ export interface ReportDateRangeSelectorConfig {
|
|||
defaultFrom?: Date;
|
||||
/** Default to date */
|
||||
defaultTo?: Date;
|
||||
/**
|
||||
* Allowed direction relative to today. Default: `'any'`. Set to `'past'`
|
||||
* for historic reports (most cases), `'future'` for forecasts.
|
||||
*/
|
||||
direction?: 'past' | 'future' | 'any';
|
||||
/**
|
||||
* Default preset kind shown when neither `defaultFrom`/`defaultTo` nor a
|
||||
* stored selection is available. Default: `'ytd'`.
|
||||
*/
|
||||
defaultPresetKind?:
|
||||
| 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom';
|
||||
/** Whitelist of preset kinds offered to the user. */
|
||||
enabledPresets?: Array<
|
||||
'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
|
||||
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter'
|
||||
| 'lastN' | 'nextN' | 'custom'
|
||||
>;
|
||||
/** Min/max boundaries (ISO `YYYY-MM-DD`). */
|
||||
minDate?: string;
|
||||
/** Min/max boundaries (ISO `YYYY-MM-DD`). */
|
||||
maxDate?: string;
|
||||
}
|
||||
|
||||
/** Combined filter state passed to the data callback */
|
||||
|
|
@ -72,8 +94,15 @@ export interface ReportFilterState {
|
|||
year?: number;
|
||||
/** Selected month (1-12) */
|
||||
month?: number;
|
||||
/** Date range */
|
||||
/** Date range (always synthesized from `periodValue` when the
|
||||
* `dateRangeSelector` is enabled). */
|
||||
dateRange?: ReportDateRange;
|
||||
/**
|
||||
* Full PeriodPicker value when the `dateRangeSelector` is enabled. Carries
|
||||
* the original preset (e.g. `thisMonth`) so that the round-trip back into
|
||||
* the picker preserves preset semantics and does not collapse to `custom`.
|
||||
*/
|
||||
periodValue?: import('../../PeriodPicker').PeriodValue;
|
||||
/** Custom filter values: key -> value(s) */
|
||||
filters: Record<string, string | string[]>;
|
||||
}
|
||||
|
|
|
|||
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 CheckoutCreateRequest,
|
||||
type MandateUserSummary,
|
||||
type StatisticsRangeRequest,
|
||||
type BillingBucketSize,
|
||||
} from '../api/billingApi';
|
||||
|
||||
// Re-export types
|
||||
|
|
@ -41,6 +43,8 @@ export type {
|
|||
AccountSummary,
|
||||
CreditAddRequest,
|
||||
MandateUserSummary,
|
||||
StatisticsRangeRequest,
|
||||
BillingBucketSize,
|
||||
};
|
||||
|
||||
export type { TransactionType, ReferenceType } from '../api/billingApi';
|
||||
|
|
@ -91,14 +95,9 @@ export function useBilling() {
|
|||
}
|
||||
}, [request]);
|
||||
|
||||
// Fetch statistics
|
||||
const loadStatistics = useCallback(async (
|
||||
period: 'day' | 'month' | 'year',
|
||||
year: number,
|
||||
month?: number
|
||||
) => {
|
||||
const loadStatistics = useCallback(async (range: StatisticsRangeRequest) => {
|
||||
try {
|
||||
const data = await fetchStatistics(request, period, year, month);
|
||||
const data = await fetchStatistics(request, range);
|
||||
setStatistics(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
|
|
|
|||
116
src/hooks/usePeriod.ts
Normal file
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,
|
||||
updatePosition as updatePositionApi,
|
||||
deletePosition as deletePositionApi,
|
||||
// Read-only data table API (Daten-Tabellen page)
|
||||
fetchDataAccounts as fetchDataAccountsApi,
|
||||
fetchDataJournalEntries as fetchDataJournalEntriesApi,
|
||||
fetchDataJournalLines as fetchDataJournalLinesApi,
|
||||
fetchDataContacts as fetchDataContactsApi,
|
||||
fetchDataAccountBalances as fetchDataAccountBalancesApi,
|
||||
fetchAccountingConfigs as fetchAccountingConfigsApi,
|
||||
fetchAccountingSyncs as fetchAccountingSyncsApi,
|
||||
type TrusteeDataAccount,
|
||||
type TrusteeDataJournalEntry,
|
||||
type TrusteeDataJournalLine,
|
||||
type TrusteeDataContact,
|
||||
type TrusteeDataAccountBalance,
|
||||
type TrusteeAccountingConfigRecord,
|
||||
type TrusteeAccountingSyncRecord,
|
||||
} from '../api/trusteeApi';
|
||||
|
||||
export type {
|
||||
|
|
@ -569,3 +584,61 @@ export const useTrusteePositionOperations = _createTrusteeOperationsHook(positio
|
|||
export { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from './useTrusteePositionDocuments';
|
||||
export type { TrusteePositionDocument } from '../api/trusteeApi';
|
||||
|
||||
// ============================================================================
|
||||
// READ-ONLY DATA TABLE HOOKS (Daten-Tabellen page)
|
||||
// ============================================================================
|
||||
//
|
||||
// These hooks expose synced/operational tables in read-only form. Mutations
|
||||
// would be overwritten by the next accounting sync, so create/update/delete
|
||||
// are intentionally not implemented (`_readOnlyMutator` raises if called).
|
||||
|
||||
function _readOnlyMutator(): never {
|
||||
throw new Error('Read-only entity: mutations are not supported via this hook.');
|
||||
}
|
||||
|
||||
function _buildReadOnlyConfig<T extends { id: string }>(
|
||||
entityName: string,
|
||||
fetchAll: TrusteeEntityConfig<T>['fetchAll']
|
||||
): TrusteeEntityConfig<T> {
|
||||
return {
|
||||
entityName,
|
||||
fetchAll,
|
||||
fetchById: async () => null,
|
||||
create: _readOnlyMutator as any,
|
||||
update: _readOnlyMutator as any,
|
||||
deleteItem: _readOnlyMutator as any,
|
||||
};
|
||||
}
|
||||
|
||||
export const useTrusteeDataAccounts = _createTrusteeEntityHook(
|
||||
_buildReadOnlyConfig<TrusteeDataAccount>('TrusteeDataAccount', fetchDataAccountsApi)
|
||||
);
|
||||
export const useTrusteeDataJournalEntries = _createTrusteeEntityHook(
|
||||
_buildReadOnlyConfig<TrusteeDataJournalEntry>('TrusteeDataJournalEntry', fetchDataJournalEntriesApi)
|
||||
);
|
||||
export const useTrusteeDataJournalLines = _createTrusteeEntityHook(
|
||||
_buildReadOnlyConfig<TrusteeDataJournalLine>('TrusteeDataJournalLine', fetchDataJournalLinesApi)
|
||||
);
|
||||
export const useTrusteeDataContacts = _createTrusteeEntityHook(
|
||||
_buildReadOnlyConfig<TrusteeDataContact>('TrusteeDataContact', fetchDataContactsApi)
|
||||
);
|
||||
export const useTrusteeDataAccountBalances = _createTrusteeEntityHook(
|
||||
_buildReadOnlyConfig<TrusteeDataAccountBalance>('TrusteeDataAccountBalance', fetchDataAccountBalancesApi)
|
||||
);
|
||||
export const useTrusteeAccountingConfigs = _createTrusteeEntityHook(
|
||||
_buildReadOnlyConfig<TrusteeAccountingConfigRecord>('TrusteeAccountingConfig', fetchAccountingConfigsApi)
|
||||
);
|
||||
export const useTrusteeAccountingSyncs = _createTrusteeEntityHook(
|
||||
_buildReadOnlyConfig<TrusteeAccountingSyncRecord>('TrusteeAccountingSync', fetchAccountingSyncsApi)
|
||||
);
|
||||
|
||||
export type {
|
||||
TrusteeDataAccount,
|
||||
TrusteeDataJournalEntry,
|
||||
TrusteeDataJournalLine,
|
||||
TrusteeDataContact,
|
||||
TrusteeDataAccountBalance,
|
||||
TrusteeAccountingConfigRecord,
|
||||
TrusteeAccountingSyncRecord,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,16 @@ import { useLanguage } from '../providers/language/LanguageContext';
|
|||
import { useUserMandates } from '../hooks/useUserMandates';
|
||||
import { useConfirm } from '../hooks/useConfirm';
|
||||
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
|
||||
import {
|
||||
PeriodPicker,
|
||||
resolvePeriod,
|
||||
type PeriodValue,
|
||||
} from '../components/PeriodPicker';
|
||||
import styles from './ComplianceAuditPage.module.css';
|
||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||
|
||||
const _DEFAULT_STATS_PRESET = { kind: 'lastN' as const, amount: 30, unit: 'day' as const };
|
||||
|
||||
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32'];
|
||||
|
||||
const _CATEGORY_COLORS: Record<string, string> = {
|
||||
|
|
@ -153,7 +160,10 @@ export const ComplianceAuditPage: React.FC = () => {
|
|||
// ── Tab C state ──
|
||||
const [stats, setStats] = useState<AuditStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [statsRange, setStatsRange] = useState(30);
|
||||
const [statsPeriod, setStatsPeriod] = useState<PeriodValue>(() => {
|
||||
const r = resolvePeriod(_DEFAULT_STATS_PRESET);
|
||||
return { preset: _DEFAULT_STATS_PRESET, fromDate: r.fromDate, toDate: r.toDate };
|
||||
});
|
||||
|
||||
// ── Tab D: Neutralization Mappings state ──
|
||||
const [neutEntries, setNeutEntries] = useState<any[]>([]);
|
||||
|
|
@ -254,12 +264,12 @@ export const ComplianceAuditPage: React.FC = () => {
|
|||
|
||||
// ── Tab C loader ──
|
||||
|
||||
const _loadStats = useCallback(async (days = 30) => {
|
||||
const _loadStats = useCallback(async (range: { dateFrom: string; dateTo: string }) => {
|
||||
if (!selectedMandateId) return;
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/api/audit/stats', {
|
||||
params: { timeRange: days },
|
||||
params: { dateFrom: range.dateFrom, dateTo: range.dateTo },
|
||||
headers: _mandateHeaders(),
|
||||
});
|
||||
setStats(data ?? null);
|
||||
|
|
@ -341,7 +351,7 @@ export const ComplianceAuditPage: React.FC = () => {
|
|||
if (!selectedMandateId) return;
|
||||
if (activeTab === 'ai-log') void _loadAiLog();
|
||||
else if (activeTab === 'audit-log') void _loadAuditLog();
|
||||
else if (activeTab === 'stats') void _loadStats(statsRange);
|
||||
else if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate });
|
||||
else if (activeTab === 'neutralization') void _loadNeutMappings();
|
||||
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
|
@ -641,15 +651,20 @@ export const ComplianceAuditPage: React.FC = () => {
|
|||
{activeTab === 'stats' && (
|
||||
<div className={styles.tabContentScrollable}>
|
||||
<div className={styles.statsControls}>
|
||||
{[7, 30, 90].map(d => (
|
||||
<button
|
||||
key={d}
|
||||
className={`${styles.rangeBtn} ${statsRange === d ? styles.rangeBtnActive : ''}`}
|
||||
onClick={() => { setStatsRange(d); void _loadStats(d); }}
|
||||
>
|
||||
{t('{n} Tage', { n: String(d) })}
|
||||
</button>
|
||||
))}
|
||||
<PeriodPicker
|
||||
value={statsPeriod}
|
||||
onChange={(next) => {
|
||||
setStatsPeriod(next);
|
||||
void _loadStats({ dateFrom: next.fromDate, dateTo: next.toDate });
|
||||
}}
|
||||
direction="past"
|
||||
defaultPreset={_DEFAULT_STATS_PRESET}
|
||||
enabledPresets={[
|
||||
'lastN', 'last12Months', 'lastYear',
|
||||
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||
'ytd', 'custom',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statsLoading ? (
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
|||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||
// Trustee Views
|
||||
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
||||
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
|
||||
import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
|
||||
// Note: TrusteePositionsView/TrusteeDocumentsView are no longer mounted directly here -
|
||||
// they live as tabs inside TrusteeDataTablesView (and that file imports them).
|
||||
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
|
||||
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
|
||||
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
|
||||
import { TrusteeScanUploadView } from './views/trustee/TrusteeScanUploadView';
|
||||
import { TrusteeImportProcessView } from './views/trustee/TrusteeImportProcessView';
|
||||
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
|
||||
import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
|
||||
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
|
||||
import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView';
|
||||
|
||||
// Chatbot Views
|
||||
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
||||
|
|
@ -121,11 +121,9 @@ type ViewComponent = React.FC;
|
|||
const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||
trustee: {
|
||||
dashboard: TrusteeDashboardView,
|
||||
documents: TrusteeDocumentsView,
|
||||
positions: TrusteePositionsView,
|
||||
'data-tables': TrusteeDataTablesView,
|
||||
'instance-roles': TrusteeInstanceRolesView,
|
||||
'expense-import': TrusteeExpenseImportView,
|
||||
'scan-upload': TrusteeScanUploadView,
|
||||
'import-process': TrusteeImportProcessView,
|
||||
settings: TrusteeAccountingSettingsView,
|
||||
analyse: TrusteeAnalyseView,
|
||||
abschluss: TrusteeAbschlussView,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle } from 'react-icons/fa';
|
||||
import { FaSync, FaTrashAlt, FaBroom, FaExclamationTriangle, FaDownload } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -44,6 +44,10 @@ interface OrphanEntry {
|
|||
targetTable: string;
|
||||
targetColumn: string;
|
||||
orphanCount: number;
|
||||
sourceRowCount?: number;
|
||||
targetRowCount?: number;
|
||||
targetEmpty?: boolean;
|
||||
wouldDeleteAll?: boolean;
|
||||
}
|
||||
|
||||
interface CleanResult {
|
||||
|
|
@ -52,6 +56,7 @@ interface CleanResult {
|
|||
column: string;
|
||||
deleted: number;
|
||||
error?: string;
|
||||
skipped?: string;
|
||||
}
|
||||
|
||||
interface PaginationParams {
|
||||
|
|
@ -367,6 +372,7 @@ const OrphansTab: React.FC = () => {
|
|||
const [allOrphans, setAllOrphans] = useState<OrphanEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cleaning, setCleaning] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState<string | null>(null);
|
||||
const [cleaningAll, setCleaningAll] = useState(false);
|
||||
const [onlyProblems, setOnlyProblems] = useState(true);
|
||||
const [dbFilter, setDbFilter] = useState<string>('');
|
||||
|
|
@ -404,46 +410,131 @@ const OrphansTab: React.FC = () => {
|
|||
|
||||
const totalOrphans = useMemo(() => allOrphans.reduce((s, o) => s + o.orphanCount, 0), [allOrphans]);
|
||||
|
||||
const _cleanOne = async (o: OrphanEntry) => {
|
||||
const ok = await confirm(
|
||||
t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn }),
|
||||
{ title: t('Orphans bereinigen'), variant: 'danger' },
|
||||
);
|
||||
if (!ok) return;
|
||||
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
|
||||
setCleaning(key);
|
||||
const _postCleanOne = async (o: OrphanEntry, force: boolean): Promise<number | 'refused'> => {
|
||||
try {
|
||||
const res = await api.post('/api/admin/database-health/orphans/clean', {
|
||||
db: o.sourceDb,
|
||||
table: o.sourceTable,
|
||||
column: o.sourceColumn,
|
||||
force,
|
||||
});
|
||||
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: res.data.deleted }));
|
||||
return res.data.deleted as number;
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
const detail = err?.response?.data?.detail;
|
||||
if (status === 409 && detail?.refused) {
|
||||
return 'refused';
|
||||
}
|
||||
const reason = typeof detail === 'string' ? detail : (detail?.reason || t('Fehler beim Bereinigen'));
|
||||
throw new Error(reason);
|
||||
}
|
||||
};
|
||||
|
||||
const _downloadOne = async (o: OrphanEntry) => {
|
||||
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
|
||||
setDownloading(key);
|
||||
try {
|
||||
const res = await api.get('/api/admin/database-health/orphans/list', {
|
||||
params: {
|
||||
db: o.sourceDb,
|
||||
table: o.sourceTable,
|
||||
column: o.sourceColumn,
|
||||
limit: 5000,
|
||||
},
|
||||
});
|
||||
const payload = {
|
||||
sourceDb: o.sourceDb,
|
||||
sourceTable: o.sourceTable,
|
||||
sourceColumn: o.sourceColumn,
|
||||
targetDb: o.targetDb,
|
||||
targetTable: o.targetTable,
|
||||
targetColumn: o.targetColumn,
|
||||
scannedOrphanCount: o.orphanCount,
|
||||
downloadedAt: new Date().toISOString(),
|
||||
...res.data,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
a.href = url;
|
||||
a.download = `orphans_${o.sourceDb}_${o.sourceTable}_${o.sourceColumn}_${ts}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.showSuccess(t('{count} verwaiste Datensätze heruntergeladen', { count: res.data?.count ?? 0 }));
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
const reason = typeof detail === 'string' ? detail : (detail?.reason || t('Fehler beim Download'));
|
||||
toast.showError(reason);
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const _cleanOne = async (o: OrphanEntry) => {
|
||||
const baseMsg = t('{count} verwaiste Einträge in {table}.{column} löschen?', { count: o.orphanCount, table: o.sourceTable, column: o.sourceColumn });
|
||||
const warning = (o.targetEmpty || o.wouldDeleteAll)
|
||||
? '\n\n' + t('WARNUNG: Target-Tabelle {target} ist leer oder die Bereinigung würde alle Source-Zeilen löschen. Das ist meist eine Fehlkonfiguration!', { target: `${o.targetDb}.${o.targetTable}` })
|
||||
: '';
|
||||
const ok = await confirm(baseMsg + warning, { title: t('Orphans bereinigen'), variant: 'danger' });
|
||||
if (!ok) return;
|
||||
const key = `${o.sourceDb}.${o.sourceTable}.${o.sourceColumn}`;
|
||||
setCleaning(key);
|
||||
try {
|
||||
let result = await _postCleanOne(o, false);
|
||||
if (result === 'refused') {
|
||||
const forceOk = await confirm(
|
||||
t('Sicherheits-Check ausgelöst (leere Target-Tabelle oder >50% der Source würden gelöscht). Trotzdem mit force=true bereinigen?'),
|
||||
{ title: t('Bereinigung erzwingen?'), variant: 'danger' },
|
||||
);
|
||||
if (!forceOk) {
|
||||
toast.showInfo(t('Bereinigung abgebrochen'));
|
||||
return;
|
||||
}
|
||||
result = await _postCleanOne(o, true);
|
||||
}
|
||||
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: result as number }));
|
||||
_fetchOrphans();
|
||||
} catch (err: any) {
|
||||
toast.showError(err.response?.data?.detail || t('Fehler beim Bereinigen'));
|
||||
toast.showError(err?.message || t('Fehler beim Bereinigen'));
|
||||
} finally {
|
||||
setCleaning(null);
|
||||
}
|
||||
};
|
||||
|
||||
const _cleanAll = async () => {
|
||||
const _cleanAll = async (force: boolean = false) => {
|
||||
const ok = await confirm(
|
||||
t('{count} verwaiste Einträge in {relations} Beziehungen löschen?', {
|
||||
count: totalOrphans,
|
||||
relations: allOrphans.filter(o => o.orphanCount > 0).length,
|
||||
}),
|
||||
}) + (force ? '\n\n' + t('FORCE-Modus: Sicherheits-Checks werden ignoriert!') : ''),
|
||||
{ title: t('Alle Orphans bereinigen'), variant: 'danger' },
|
||||
);
|
||||
if (!ok) return;
|
||||
setCleaningAll(true);
|
||||
try {
|
||||
const res = await api.post('/api/admin/database-health/orphans/clean-all');
|
||||
const res = await api.post('/api/admin/database-health/orphans/clean-all', { force });
|
||||
const results: CleanResult[] = res.data.results || [];
|
||||
const totalDeleted = results.reduce((s, r) => s + r.deleted, 0);
|
||||
const errors = results.filter(r => r.error);
|
||||
const skipped = results.filter(r => r.skipped);
|
||||
if (skipped.length > 0 && !force) {
|
||||
const retryOk = await confirm(
|
||||
t('{deleted} gelöscht. {skipped} Bereinigungen wurden vom Sicherheits-Check abgelehnt (leere Target-Tabelle oder >50% Löschung). Mit force=true erneut versuchen?', { deleted: totalDeleted, skipped: skipped.length }),
|
||||
{ title: t('Force benötigt'), variant: 'danger' },
|
||||
);
|
||||
if (retryOk) {
|
||||
setCleaningAll(false);
|
||||
await _cleanAll(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler', { deleted: totalDeleted, errors: errors.length }));
|
||||
toast.showWarning(t('{deleted} gelöscht, {errors} Fehler, {skipped} übersprungen', { deleted: totalDeleted, errors: errors.length, skipped: skipped.length }));
|
||||
} else if (skipped.length > 0) {
|
||||
toast.showWarning(t('{deleted} gelöscht, {skipped} übersprungen', { deleted: totalDeleted, skipped: skipped.length }));
|
||||
} else {
|
||||
toast.showSuccess(t('{deleted} Einträge gelöscht', { deleted: totalDeleted }));
|
||||
}
|
||||
|
|
@ -550,7 +641,7 @@ const OrphansTab: React.FC = () => {
|
|||
<FaSync className={loading ? 'spinning' : ''} /> {t('Scan')}
|
||||
</button>
|
||||
{totalOrphans > 0 && (
|
||||
<button className={styles.dangerButton} onClick={_cleanAll} disabled={cleaningAll || loading}>
|
||||
<button className={styles.dangerButton} onClick={() => _cleanAll(false)} disabled={cleaningAll || loading}>
|
||||
<FaBroom className={cleaningAll ? 'spinning' : ''} /> {t('Alle bereinigen')} ({_formatNumber(totalOrphans)})
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -579,6 +670,14 @@ const OrphansTab: React.FC = () => {
|
|||
pageSize={50}
|
||||
selectable={false}
|
||||
customActions={[
|
||||
{
|
||||
id: 'download',
|
||||
icon: <FaDownload />,
|
||||
onClick: (row: OrphanEntry) => _downloadOne(row),
|
||||
visible: (row: OrphanEntry) => row.orphanCount > 0,
|
||||
loading: (row: OrphanEntry) => downloading === `${row.sourceDb}.${row.sourceTable}.${row.sourceColumn}`,
|
||||
title: t('Orphan-Liste herunterladen (JSON)'),
|
||||
},
|
||||
{
|
||||
id: 'clean',
|
||||
icon: <FaTrashAlt />,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
||||
import type { CheckoutCreateRequest } from '../../api/billingApi';
|
||||
import { fetchCheckoutAmounts, type CheckoutCreateRequest } from '../../api/billingApi';
|
||||
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
||||
import { useCurrentUser } from '../../hooks/useUsers';
|
||||
import { SubscriptionTab } from './SubscriptionTab';
|
||||
|
|
@ -369,6 +369,28 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
|||
const [amount, setAmount] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [localMsg, setLocalMsg] = useState<string | null>(null);
|
||||
const [allowedAmounts, setAllowedAmounts] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const _request = async (opts: any) => {
|
||||
const res = await api.request({ url: opts.url, method: opts.method, data: opts.data, params: opts.params });
|
||||
return res.data;
|
||||
};
|
||||
fetchCheckoutAmounts(_request)
|
||||
.then(list => {
|
||||
if (cancelled) return;
|
||||
const sorted = [...list].sort((a, b) => a - b);
|
||||
setAllowedAmounts(sorted);
|
||||
if (sorted.length > 0) {
|
||||
setAmount(String(sorted[0]));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAllowedAmounts([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const _handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -377,6 +399,10 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
|||
setLocalMsg('Betrag muss positiv sein');
|
||||
return;
|
||||
}
|
||||
if (allowedAmounts.length > 0 && !allowedAmounts.includes(n)) {
|
||||
setLocalMsg(t('Ungültiger Betrag. Erlaubt: {list} CHF', { list: allowedAmounts.join(', ') }));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setLocalMsg(null);
|
||||
try {
|
||||
|
|
@ -412,22 +438,34 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
|||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>{t('Betrag (CHF)')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
placeholder={t('z. B. 50 oder -20')}
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
{allowedAmounts.length > 0 ? (
|
||||
<select
|
||||
className={styles.input}
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
required
|
||||
>
|
||||
{allowedAmounts.map(a => (
|
||||
<option key={a} value={String(a)}>
|
||||
{new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(a)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
value={t('Lade erlaubte Beträge…')}
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
disabled={busy || !amount}
|
||||
disabled={busy || !amount || allowedAmounts.length === 0}
|
||||
>
|
||||
{busy ? t('Weiterleitung…') : t('Mit Stripe bezahlen')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,26 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useBilling, type BillingBalance, type UsageReport } from '../../hooks/useBilling';
|
||||
import { useBilling, type BillingBalance, type UsageReport, type BillingBucketSize } from '../../hooks/useBilling';
|
||||
import { BillingNav } from './BillingNav';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import {
|
||||
PeriodPicker,
|
||||
resolvePeriod,
|
||||
daysInRange,
|
||||
type PeriodValue,
|
||||
} from '../../components/PeriodPicker';
|
||||
|
||||
const _DEFAULT_BILLING_PRESET = { kind: 'thisMonth' as const };
|
||||
|
||||
function _suggestBucketSize(value: PeriodValue): BillingBucketSize {
|
||||
const days = daysInRange(value.fromDate, value.toDate);
|
||||
if (days <= 62) return 'day';
|
||||
if (days <= 24 * 31) return 'month';
|
||||
return 'year';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BALANCE CARD COMPONENT
|
||||
|
|
@ -154,37 +169,41 @@ const StatisticsChart: React.FC<StatisticsChartProps> = ({ statistics, loading }
|
|||
|
||||
export const BillingDashboard: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const {
|
||||
balances,
|
||||
statistics,
|
||||
loading,
|
||||
loadStatistics
|
||||
const {
|
||||
balances,
|
||||
statistics,
|
||||
loading,
|
||||
loadStatistics
|
||||
} = useBilling();
|
||||
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'month' | 'year'>('month');
|
||||
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
||||
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1);
|
||||
|
||||
// Load statistics when period changes
|
||||
useEffect(() => {
|
||||
if (selectedPeriod === 'month') {
|
||||
loadStatistics('month', selectedYear);
|
||||
} else {
|
||||
loadStatistics('year', selectedYear);
|
||||
|
||||
const [period, setPeriod] = useState<PeriodValue>(() => {
|
||||
const r = resolvePeriod(_DEFAULT_BILLING_PRESET);
|
||||
return { preset: _DEFAULT_BILLING_PRESET, fromDate: r.fromDate, toDate: r.toDate };
|
||||
});
|
||||
// Frontend-Heuristik fuer Default; user kann uebersteuern.
|
||||
const [bucketSize, setBucketSize] = useState<BillingBucketSize>(() => _suggestBucketSize(period));
|
||||
const [bucketUserOverridden, setBucketUserOverridden] = useState(false);
|
||||
|
||||
const _handlePeriodChange = (next: PeriodValue) => {
|
||||
setPeriod(next);
|
||||
if (!bucketUserOverridden) {
|
||||
setBucketSize(_suggestBucketSize(next));
|
||||
}
|
||||
}, [selectedPeriod, selectedYear, loadStatistics]);
|
||||
|
||||
// Available years (current and last 2 years)
|
||||
const availableYears = useMemo(() => {
|
||||
const current = new Date().getFullYear();
|
||||
return [current, current - 1, current - 2];
|
||||
}, []);
|
||||
|
||||
// Available months
|
||||
const availableMonths = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: i + 1,
|
||||
label: String(i + 1),
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatistics({
|
||||
dateFrom: period.fromDate,
|
||||
dateTo: period.toDate,
|
||||
bucketSize,
|
||||
});
|
||||
}, [period.fromDate, period.toDate, bucketSize, loadStatistics]);
|
||||
|
||||
const _bucketLabel = useMemo(() => ({
|
||||
day: t('Tag'),
|
||||
month: t('Monat'),
|
||||
year: t('Jahr'),
|
||||
} as Record<BillingBucketSize, string>), [t]);
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard}>
|
||||
|
|
@ -216,34 +235,30 @@ export const BillingDashboard: React.FC = () => {
|
|||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>{t('Nutzungsstatistik')}</h2>
|
||||
<div className={styles.periodSelector}>
|
||||
<select
|
||||
value={selectedPeriod}
|
||||
onChange={(e) => setSelectedPeriod(e.target.value as 'month' | 'year')}
|
||||
<PeriodPicker
|
||||
value={period}
|
||||
onChange={_handlePeriodChange}
|
||||
direction="past"
|
||||
defaultPreset={_DEFAULT_BILLING_PRESET}
|
||||
enabledPresets={[
|
||||
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||
]}
|
||||
/>
|
||||
<label className={styles.bucketLabel}>{t('Gruppierung')}</label>
|
||||
<select
|
||||
value={bucketSize}
|
||||
onChange={(e) => {
|
||||
setBucketSize(e.target.value as BillingBucketSize);
|
||||
setBucketUserOverridden(true);
|
||||
}}
|
||||
className={styles.select}
|
||||
aria-label={t('Gruppierung')}
|
||||
>
|
||||
<option value="month">{t('Monat')}</option>
|
||||
<option value="year">{t('Jahr')}</option>
|
||||
<option value="day">{_bucketLabel.day}</option>
|
||||
<option value="month">{_bucketLabel.month}</option>
|
||||
<option value="year">{_bucketLabel.year}</option>
|
||||
</select>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(Number(e.target.value))}
|
||||
className={styles.select}
|
||||
>
|
||||
{availableYears.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPeriod === 'month' && (
|
||||
<select
|
||||
value={selectedMonth}
|
||||
onChange={(e) => setSelectedMonth(Number(e.target.value))}
|
||||
className={styles.select}
|
||||
>
|
||||
{availableMonths.map((month) => (
|
||||
<option key={month.value} value={month.value}>{month.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatisticsChart statistics={statistics} loading={loading} />
|
||||
|
|
|
|||
|
|
@ -11,14 +11,34 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|||
import { useSearchParams } from 'react-router-dom';
|
||||
import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import type { ReportSection, ReportFilterState, ReportChartDataPoint, ReportDateRangeSelectorConfig } from '../../components/FormGenerator/FormGeneratorReport';
|
||||
import api from '../../api';
|
||||
import { useBilling } from '../../hooks/useBilling';
|
||||
import { useBilling, type BillingBucketSize } from '../../hooks/useBilling';
|
||||
import { UserTransaction } from '../../api/billingApi';
|
||||
import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import {
|
||||
daysInRange,
|
||||
resolvePeriod,
|
||||
toIsoDate,
|
||||
type PeriodValue,
|
||||
} from '../../components/PeriodPicker';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
const _DEFAULT_STATS_PRESET = { kind: 'thisMonth' as const };
|
||||
|
||||
function _suggestBucketSize(fromIso: string, toIso: string): BillingBucketSize {
|
||||
const days = daysInRange(fromIso, toIso);
|
||||
if (days <= 62) return 'day';
|
||||
if (days <= 24 * 31) return 'month';
|
||||
return 'year';
|
||||
}
|
||||
|
||||
function _initialStatsPeriod(): PeriodValue {
|
||||
const r = resolvePeriod(_DEFAULT_STATS_PRESET);
|
||||
return { preset: _DEFAULT_STATS_PRESET, fromDate: r.fromDate, toDate: r.toDate };
|
||||
}
|
||||
|
||||
type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -336,14 +356,24 @@ export const BillingDataView: React.FC = () => {
|
|||
return params;
|
||||
}, [selectedScope, onlyMyData]);
|
||||
|
||||
// Load aggregated statistics from the view/statistics route
|
||||
const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => {
|
||||
const [statsPeriod, setStatsPeriod] = useState<PeriodValue>(() => _initialStatsPeriod());
|
||||
const [statsBucketSize, setStatsBucketSize] = useState<BillingBucketSize>(() => {
|
||||
const init = _initialStatsPeriod();
|
||||
return _suggestBucketSize(init.fromDate, init.toDate);
|
||||
});
|
||||
const [bucketUserOverridden, setBucketUserOverridden] = useState(false);
|
||||
|
||||
const _loadViewStatistics = useCallback(async (
|
||||
range: { dateFrom: string; dateTo: string; bucketSize: BillingBucketSize },
|
||||
) => {
|
||||
try {
|
||||
setStatsLoading(true);
|
||||
const params: Record<string, string | number> = { period, year, ..._scopeParams };
|
||||
if (period === 'day' && month) {
|
||||
params.month = month;
|
||||
}
|
||||
const params: Record<string, string | number> = {
|
||||
dateFrom: range.dateFrom,
|
||||
dateTo: range.dateTo,
|
||||
bucketSize: range.bucketSize,
|
||||
..._scopeParams,
|
||||
};
|
||||
const response = await api.get('/api/billing/view/statistics', { params });
|
||||
setViewStats(response.data);
|
||||
} catch (err: any) {
|
||||
|
|
@ -354,13 +384,26 @@ export const BillingDataView: React.FC = () => {
|
|||
}
|
||||
}, [_scopeParams]);
|
||||
|
||||
// Handle filter changes from FormGeneratorReport (user changes period/year/month)
|
||||
// Handle PeriodPicker change coming back from the shared `dateRangeSelector`
|
||||
// of `FormGeneratorReport`. Prefer the full `periodValue` so we keep the
|
||||
// original preset (e.g. `thisMonth`) instead of collapsing to `custom`.
|
||||
const _handleStatsFilterChange = useCallback((filterState: ReportFilterState) => {
|
||||
const period = filterState.period || 'month';
|
||||
const year = filterState.year || new Date().getFullYear();
|
||||
const month = filterState.month;
|
||||
_loadViewStatistics(period, year, month);
|
||||
}, [_loadViewStatistics]);
|
||||
let next: PeriodValue | null = null;
|
||||
if (filterState.periodValue) {
|
||||
next = filterState.periodValue;
|
||||
} else if (filterState.dateRange) {
|
||||
next = {
|
||||
preset: { kind: 'custom' },
|
||||
fromDate: toIsoDate(filterState.dateRange.from),
|
||||
toDate: toIsoDate(filterState.dateRange.to),
|
||||
};
|
||||
}
|
||||
if (!next) return;
|
||||
setStatsPeriod(next);
|
||||
if (!bucketUserOverridden) {
|
||||
setStatsBucketSize(_suggestBucketSize(next.fromDate, next.toDate));
|
||||
}
|
||||
}, [bucketUserOverridden]);
|
||||
|
||||
// Load storage volume for all accessible mandates
|
||||
const _loadStorageData = useCallback(async () => {
|
||||
|
|
@ -398,13 +441,17 @@ export const BillingDataView: React.FC = () => {
|
|||
}
|
||||
}, [balances, selectedScope, onlyMyData]);
|
||||
|
||||
// Initial data load
|
||||
// Initial / reactive load: any change to period / bucketSize / scope reloads.
|
||||
useEffect(() => {
|
||||
if (activeTab === 'overview' || activeTab === 'diagrams') {
|
||||
_loadViewStatistics('month', new Date().getFullYear());
|
||||
void _loadViewStatistics({
|
||||
dateFrom: statsPeriod.fromDate,
|
||||
dateTo: statsPeriod.toDate,
|
||||
bucketSize: statsBucketSize,
|
||||
});
|
||||
_loadStorageData();
|
||||
}
|
||||
}, [activeTab, _loadViewStatistics, _loadStorageData]);
|
||||
}, [activeTab, statsPeriod.fromDate, statsPeriod.toDate, statsBucketSize, _loadViewStatistics, _loadStorageData]);
|
||||
|
||||
// Load transactions with pagination support + scope filter
|
||||
const _loadTransactions = useCallback(async (paginationParams?: any) => {
|
||||
|
|
@ -499,14 +546,15 @@ export const BillingDataView: React.FC = () => {
|
|||
return _buildDiagramSections(viewStats, chartMode, t);
|
||||
}, [viewStats, chartMode, t]);
|
||||
|
||||
// Period selector config (shared between overview and statistics)
|
||||
const periodSelectorConfig = useMemo(() => ({
|
||||
periods: ['month' as const, 'day' as const],
|
||||
defaultPeriod: 'month' as const,
|
||||
showYear: true,
|
||||
showMonth: true,
|
||||
defaultYear: new Date().getFullYear(),
|
||||
defaultMonth: new Date().getMonth() + 1
|
||||
// Date-range selector config: use shared PeriodPicker via FormGeneratorReport.
|
||||
const dateRangeSelectorConfig = useMemo<ReportDateRangeSelectorConfig>(() => ({
|
||||
enabled: true,
|
||||
direction: 'past',
|
||||
defaultPresetKind: 'thisMonth',
|
||||
enabledPresets: [
|
||||
'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter',
|
||||
'ytd', 'lastYear', 'last12Months', 'lastN', 'custom',
|
||||
],
|
||||
}), []);
|
||||
|
||||
// Build scope options from balances (mandates the user has access to)
|
||||
|
|
@ -627,8 +675,24 @@ export const BillingDataView: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<label style={{ fontSize: '13px', opacity: 0.7 }}>{t('Gruppierung')}</label>
|
||||
<select
|
||||
className={styles.select || ''}
|
||||
value={statsBucketSize}
|
||||
onChange={(e) => {
|
||||
setStatsBucketSize(e.target.value as BillingBucketSize);
|
||||
setBucketUserOverridden(true);
|
||||
}}
|
||||
aria-label={t('Gruppierung')}
|
||||
>
|
||||
<option value="day">{t('Tag')}</option>
|
||||
<option value="month">{t('Monat')}</option>
|
||||
<option value="year">{t('Jahr')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<FormGeneratorReport
|
||||
periodSelector={periodSelectorConfig}
|
||||
dateRangeSelector={dateRangeSelectorConfig}
|
||||
onFilterChange={_handleStatsFilterChange}
|
||||
loading={statsLoading}
|
||||
sections={diagramSections}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ import { useToast } from '../../../contexts/ToastContext';
|
|||
import api from '../../../api';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import {
|
||||
PeriodPicker,
|
||||
resolvePeriod,
|
||||
type PeriodValue,
|
||||
} from '../../../components/PeriodPicker';
|
||||
|
||||
const _DEFAULT_PERIOD_PRESET = { kind: 'lastYear' as const };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab definitions
|
||||
|
|
@ -21,18 +28,23 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
|||
|
||||
interface TabDef {
|
||||
id: string;
|
||||
templateTag: string;
|
||||
templateTag: string | null;
|
||||
icon: string;
|
||||
color: string;
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
const _TABS: TabDef[] = [
|
||||
{ id: 'year-end', templateTag: 'template:trustee-year-end-check', icon: '\u2705', color: '#795548' },
|
||||
{ id: 'vat', templateTag: null, icon: '\uD83E\uDDFE', color: '#9E9E9E', comingSoon: true },
|
||||
{ id: 'reporting', templateTag: null, icon: '\uD83D\uDCEC', color: '#9E9E9E', comingSoon: true },
|
||||
];
|
||||
|
||||
function _tabLabel(tabId: string, t: (k: string) => string): string {
|
||||
switch (tabId) {
|
||||
case 'year-end': return t('Jahresabschluss prüfen');
|
||||
case 'vat': return t('MWST-Abrechnung');
|
||||
case 'reporting': return t('Reporting Behörden');
|
||||
default: return tabId;
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +52,8 @@ function _tabLabel(tabId: string, t: (k: string) => string): string {
|
|||
function _tabDescription(tabId: string, t: (k: string) => string): string {
|
||||
switch (tabId) {
|
||||
case 'year-end': return t('Automatische Prüfungen für den Jahresabschluss: Saldovalidierung, Vorjahresvergleich, gesetzliche Checks.');
|
||||
case 'vat': return t('Vierteljährliche MWST-Abrechnung vorbereiten und validieren.');
|
||||
case 'reporting': return t('Meldungen an Behörden vorbereiten (z. B. Lohnausweise, Sozialversicherungen).');
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +95,11 @@ export const TrusteeAbschlussView: React.FC = () => {
|
|||
const pollTimerRef = useRef<number | null>(null);
|
||||
const isPollingRef = useRef(false);
|
||||
|
||||
const [period, setPeriod] = useState<PeriodValue>(() => {
|
||||
const r = resolvePeriod(_DEFAULT_PERIOD_PRESET);
|
||||
return { preset: _DEFAULT_PERIOD_PRESET, fromDate: r.fromDate, toDate: r.toDate };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
const _load = async () => {
|
||||
|
|
@ -104,8 +123,9 @@ export const TrusteeAbschlussView: React.FC = () => {
|
|||
|
||||
const _findWorkflow = useCallback((tab: string): WorkflowSummary | undefined => {
|
||||
const tabDef = _TABS.find((tabItem) => tabItem.id === tab);
|
||||
if (!tabDef) return undefined;
|
||||
return workflows.find((w) => w.tags.includes(tabDef.templateTag));
|
||||
if (!tabDef || !tabDef.templateTag) return undefined;
|
||||
const templateTag = tabDef.templateTag;
|
||||
return workflows.find((w) => w.tags.includes(templateTag));
|
||||
}, [workflows]);
|
||||
|
||||
const _stopPolling = useCallback(() => {
|
||||
|
|
@ -180,7 +200,10 @@ export const TrusteeAbschlussView: React.FC = () => {
|
|||
setRunError(null);
|
||||
setRunSummary(t('Workflow wird gestartet…'));
|
||||
try {
|
||||
const res = await api.post(`/api/workflows/${instanceId}/execute`, { workflowId: wf.id });
|
||||
const res = await api.post(`/api/workflows/${instanceId}/execute`, {
|
||||
workflowId: wf.id,
|
||||
payload: { dateFrom: period.fromDate, dateTo: period.toDate },
|
||||
});
|
||||
const rid = res?.data?.runId;
|
||||
if (rid) {
|
||||
setRunId(rid);
|
||||
|
|
@ -199,10 +222,11 @@ export const TrusteeAbschlussView: React.FC = () => {
|
|||
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
}
|
||||
}, [activeTab, instanceId, _findWorkflow, showError, showSuccess, t]);
|
||||
}, [activeTab, instanceId, _findWorkflow, period, showError, showSuccess, t]);
|
||||
|
||||
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
|
||||
const currentWorkflow = _findWorkflow(activeTab);
|
||||
const isComingSoon = !!currentTab.comingSoon;
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
|
|
@ -242,7 +266,14 @@ export const TrusteeAbschlussView: React.FC = () => {
|
|||
{_tabDescription(activeTab, t)}
|
||||
</p>
|
||||
|
||||
{workflowsLoading ? (
|
||||
{isComingSoon ? (
|
||||
<div className={styles.infoBox}>
|
||||
<p>
|
||||
<strong>{t('In Kürze verfügbar.')}</strong>{' '}
|
||||
{t('Diese Funktion befindet sich in Vorbereitung.')}
|
||||
</p>
|
||||
</div>
|
||||
) : workflowsLoading ? (
|
||||
<p className={styles.loadingText}>{t('Workflows werden geladen…')}</p>
|
||||
) : !currentWorkflow ? (
|
||||
<div className={styles.infoBox}>
|
||||
|
|
@ -260,6 +291,18 @@ export const TrusteeAbschlussView: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
<label style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--text-secondary, #4A5568)' }}>
|
||||
{t('Geschäftsjahr')}
|
||||
</label>
|
||||
<PeriodPicker
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
direction="past"
|
||||
defaultPreset={_DEFAULT_PERIOD_PRESET}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={_handleExecute}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
|
|
@ -23,13 +24,33 @@ import {
|
|||
type AccountingConnectorInfo,
|
||||
type AccountingConfig,
|
||||
} from '../../../api/trusteeApi';
|
||||
import { PeriodPicker, type PeriodValue } from '../../../components/PeriodPicker';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
|
||||
const _SETTINGS_TABS = [
|
||||
{ id: 'settings', icon: '\u2699\uFE0F', color: '#2196F3' },
|
||||
{ id: 'import-data', icon: '\u2B07\uFE0F', color: '#FF9800' },
|
||||
];
|
||||
|
||||
function _settingsTabLabel(tabId: string, t: (k: string) => string): string {
|
||||
switch (tabId) {
|
||||
case 'settings': return t('Verbindungseinstellungen');
|
||||
case 'import-data': return t('Buchhaltungsdaten importieren');
|
||||
default: return tabId;
|
||||
}
|
||||
}
|
||||
|
||||
export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
const { instanceId } = useCurrentInstance();
|
||||
const { request } = useApiRequest();
|
||||
const { showSuccess, showError } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const activeTab = searchParams.get('tab') || _SETTINGS_TABS[0].id;
|
||||
const _setActiveTab = useCallback((tab: string) => {
|
||||
setSearchParams({ tab }, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const [connectors, setConnectors] = useState<AccountingConnectorInfo[]>([]);
|
||||
const [existingConfig, setExistingConfig] = useState<AccountingConfig | null>(null);
|
||||
|
|
@ -47,8 +68,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const [importJobId, setImportJobId] = useState<string | null>(null);
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [importPeriod, setImportPeriod] = useState<PeriodValue | null>(null);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -218,17 +238,44 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
<div className={styles.listView}>
|
||||
<div className={styles.expenseImportSection}>
|
||||
<h3 className={styles.sectionTitle}>{t('Buchhaltungssystem-Anbindung')}</h3>
|
||||
<p className={styles.sectionDescription}>
|
||||
{t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}
|
||||
</p>
|
||||
|
||||
{existingConfig?.configured && (
|
||||
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--border-color, #e0e0e0)', paddingBottom: 0 }}>
|
||||
{_SETTINGS_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => _setActiveTab(tab.id)}
|
||||
style={{
|
||||
padding: '0.625rem 1rem',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
|
||||
background: 'transparent',
|
||||
color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
|
||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
marginBottom: '-2px',
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
|
||||
{_settingsTabLabel(tab.id, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
|
||||
{activeTab === 'settings' && (
|
||||
<>
|
||||
<p className={styles.sectionDescription}>
|
||||
{t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}
|
||||
</p>
|
||||
|
||||
{existingConfig?.configured && (
|
||||
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
||||
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
|
||||
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
||||
<div className={styles.stepContent}>
|
||||
|
|
@ -366,12 +413,23 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Import Accounting Data */}
|
||||
{existingConfig?.configured && (
|
||||
<div className={styles.setupStep}>
|
||||
<div className={styles.stepNumber}>4</div>
|
||||
<div className={styles.stepContent}>
|
||||
<h4>{t('Buchhaltungsdaten importieren')}</h4>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'import-data' && (
|
||||
<>
|
||||
{!existingConfig?.configured && (
|
||||
<div className={styles.infoBox}>
|
||||
<p>
|
||||
{t('Bevor Sie Daten importieren können, richten Sie zuerst die Verbindung zum Buchhaltungssystem im Tab «Verbindungseinstellungen» ein.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{existingConfig?.configured && (
|
||||
<div className={styles.setupStep}>
|
||||
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
||||
<div className={styles.stepContent}>
|
||||
<h4 style={{ marginTop: 0 }}>{t('Buchhaltungsdaten importieren')}</h4>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.75rem' }}>
|
||||
{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}
|
||||
</p>
|
||||
|
|
@ -425,40 +483,16 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
);
|
||||
})()}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Von (optional)')}</label>
|
||||
<input type="date" className={styles.folderSelect} value={dateFrom} onChange={e => setDateFrom(e.target.value)} style={{ width: '160px' }} />
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.25rem' }}>{t('Zeitraum (optional)')}</label>
|
||||
<PeriodPicker
|
||||
value={importPeriod}
|
||||
onChange={setImportPeriod}
|
||||
direction="past"
|
||||
placeholder={t('Alle Daten')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Bis (optional)')}</label>
|
||||
<input type="date" className={styles.folderSelect} value={dateTo} onChange={e => setDateTo(e.target.value)} style={{ width: '160px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: t('Laufendes Jahr'), from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) },
|
||||
{
|
||||
label: t('Letztes Jahr'),
|
||||
from: `${new Date().getFullYear() - 1}-01-01`,
|
||||
to: `${new Date().getFullYear() - 1}-12-31`,
|
||||
},
|
||||
{
|
||||
label: t('Letzter Monat'),
|
||||
from: (() => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); })(),
|
||||
to: (() => { const d = new Date(); d.setDate(0); return d.toISOString().slice(0, 10); })(),
|
||||
},
|
||||
].map(s => (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
style={{ fontSize: '0.75rem', padding: '0.25rem 0.6rem', minWidth: 0 }}
|
||||
onClick={() => { setDateFrom(s.from); setDateTo(s.to); }}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
|
|
@ -472,8 +506,8 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
setImportJobId(null);
|
||||
try {
|
||||
const body: Record<string, string> = {};
|
||||
if (dateFrom) body.dateFrom = dateFrom;
|
||||
if (dateTo) body.dateTo = dateTo;
|
||||
if (importPeriod?.fromDate) body.dateFrom = importPeriod.fromDate;
|
||||
if (importPeriod?.toDate) body.dateTo = importPeriod.toDate;
|
||||
const result = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
||||
const newJobId: string | undefined = result?.jobId;
|
||||
if (newJobId) {
|
||||
|
|
@ -576,8 +610,10 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* and results/status are shown inline with polling.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
|
|
@ -15,6 +15,13 @@ import api from '../../../api';
|
|||
import styles from './TrusteeViews.module.css';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { FaUpload, FaTimes } from 'react-icons/fa';
|
||||
import {
|
||||
PeriodPicker,
|
||||
resolvePeriod,
|
||||
type PeriodDirection,
|
||||
type PeriodPreset,
|
||||
type PeriodValue,
|
||||
} from '../../../components/PeriodPicker';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab definitions
|
||||
|
|
@ -54,6 +61,29 @@ function _tabDescription(tabId: string, t: (k: string) => string): string {
|
|||
}
|
||||
}
|
||||
|
||||
interface TabPeriodConfig {
|
||||
defaultPreset: PeriodPreset;
|
||||
direction: PeriodDirection;
|
||||
}
|
||||
|
||||
function _periodConfigForTab(tabId: string): TabPeriodConfig {
|
||||
switch (tabId) {
|
||||
case 'forecast':
|
||||
return { defaultPreset: { kind: 'next12Months' }, direction: 'future' };
|
||||
case 'budget':
|
||||
case 'kpi':
|
||||
case 'cashflow':
|
||||
default:
|
||||
return { defaultPreset: { kind: 'ytd' }, direction: 'any' };
|
||||
}
|
||||
}
|
||||
|
||||
function _initialPeriodForTab(tabId: string): PeriodValue {
|
||||
const cfg = _periodConfigForTab(tabId);
|
||||
const r = resolvePeriod(cfg.defaultPreset);
|
||||
return { preset: cfg.defaultPreset, fromDate: r.fromDate, toDate: r.toDate };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -99,6 +129,18 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// One PeriodValue per tab, defaults derived from `_periodConfigForTab`.
|
||||
const [periodByTab, setPeriodByTab] = useState<Record<string, PeriodValue>>(() => {
|
||||
const initial: Record<string, PeriodValue> = {};
|
||||
for (const tab of _TABS) initial[tab.id] = _initialPeriodForTab(tab.id);
|
||||
return initial;
|
||||
});
|
||||
const tabPeriodConfig = useMemo(() => _periodConfigForTab(activeTab), [activeTab]);
|
||||
const currentPeriod = periodByTab[activeTab] || _initialPeriodForTab(activeTab);
|
||||
const _setCurrentPeriod = useCallback((next: PeriodValue) => {
|
||||
setPeriodByTab((prev) => ({ ...prev, [activeTab]: next }));
|
||||
}, [activeTab]);
|
||||
|
||||
// Load workflows for this instance once
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
|
|
@ -266,9 +308,14 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
setResultDocuments([]);
|
||||
try {
|
||||
const executeBody: Record<string, any> = { workflowId: wf.id };
|
||||
const payload: Record<string, any> = {
|
||||
dateFrom: currentPeriod.fromDate,
|
||||
dateTo: currentPeriod.toDate,
|
||||
};
|
||||
if (activeTab === 'budget' && budgetFileId) {
|
||||
executeBody.payload = { documentList: [budgetFileId] };
|
||||
payload.documentList = [budgetFileId];
|
||||
}
|
||||
executeBody.payload = payload;
|
||||
const res = await api.post(`/api/workflows/${instanceId}/execute`, executeBody);
|
||||
const rid = res?.data?.runId;
|
||||
if (rid) {
|
||||
|
|
@ -291,7 +338,7 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
showError(t('Fehler'), typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
}
|
||||
}, [activeTab, instanceId, _findWorkflow, budgetFileId, showError, showSuccess, t]);
|
||||
}, [activeTab, instanceId, _findWorkflow, budgetFileId, currentPeriod, showError, showSuccess, t]);
|
||||
|
||||
const currentTab = _TABS.find((tabItem) => tabItem.id === activeTab) || _TABS[0];
|
||||
const currentWorkflow = _findWorkflow(activeTab);
|
||||
|
|
@ -396,6 +443,18 @@ export const TrusteeAnalyseView: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
||||
<label style={{ fontSize: '0.8125rem', fontWeight: 500, color: 'var(--text-secondary, #4A5568)' }}>
|
||||
{t('Zeitraum')}
|
||||
</label>
|
||||
<PeriodPicker
|
||||
value={currentPeriod}
|
||||
onChange={_setCurrentPeriod}
|
||||
direction={tabPeriodConfig.direction}
|
||||
defaultPreset={tabPeriodConfig.defaultPreset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={_handleExecute}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const TrusteeDashboardView: React.FC = () => {
|
|||
const navigate = useNavigate();
|
||||
const { mandateId } = useParams<{ mandateId: string }>();
|
||||
|
||||
const { instance, instanceId } = useCurrentInstance();
|
||||
const { mandate, instance, instanceId } = useCurrentInstance();
|
||||
const { items: positions, loading: posLoading } = useTrusteePositions();
|
||||
const { items: documents, loading: docsLoading } = useTrusteeDocuments();
|
||||
const { request } = useApiRequest();
|
||||
|
|
@ -136,7 +136,7 @@ export const TrusteeDashboardView: React.FC = () => {
|
|||
<div className={styles.statValueSmall}>
|
||||
{instance?.userRoles?.length ? (
|
||||
instance.userRoles.map((role: string, idx: number) => (
|
||||
<div key={idx}>{role}</div>
|
||||
<div key={idx}>{t(role)}</div>
|
||||
))
|
||||
) : '-'}
|
||||
</div>
|
||||
|
|
@ -162,15 +162,17 @@ export const TrusteeDashboardView: React.FC = () => {
|
|||
<span className={styles.infoValue}>{instance?.instanceLabel}</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>{t('Mandant')}</span>
|
||||
<span className={styles.infoValue}>{instance?.mandateName}</span>
|
||||
<span className={styles.infoLabel}>{t('Mandant:')}</span>
|
||||
<span className={styles.infoValue}>
|
||||
{mandate?.label || instance?.mandateLabel || mandate?.name || instance?.mandateName || '-'}
|
||||
</span>
|
||||
</div>
|
||||
{accountingConfig?.configured && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.infoLabel}>{t('Buchhaltungssystem:')}</span>
|
||||
<span className={styles.infoValue}>
|
||||
{accountingConfig.displayLabel || accountingConfig.connectorType}
|
||||
{accountingConfig.lastSyncStatus && ` (${accountingConfig.lastSyncStatus})`}
|
||||
{accountingConfig.lastSyncStatus && ` (${t(accountingConfig.lastSyncStatus)})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
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 { instanceId, mandateId } = useCurrentInstance();
|
||||
const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections();
|
||||
|
|
@ -464,10 +468,9 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
<div className={styles.expenseImportSection}>
|
||||
<h3 className={styles.sectionTitle}>{t('Einrichtung des Ausgabenimports')}</h3>
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && <h3 className={styles.sectionTitle}>{t('Einrichtung des Ausgabenimports')}</h3>}
|
||||
<p className={styles.sectionDescription}>
|
||||
{t('Verbinden Sie Ihr Microsoft-Konto und wählen Sie einen SharePoint-Ordner mit Ausgaben-PDFs. Das System extrahiert automatisch täglich die Ausgabendaten und speichert sie als Positionen.')}
|
||||
<span
|
||||
|
|
@ -699,7 +702,17 @@ export const TrusteeExpenseImportView: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
<div className={styles.expenseImportSection}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
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);
|
||||
};
|
||||
|
||||
export const TrusteeScanUploadView: React.FC = () => {
|
||||
interface TrusteeScanUploadViewProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export const TrusteeScanUploadView: React.FC<TrusteeScanUploadViewProps> = ({ embedded = false }) => {
|
||||
const { t } = useLanguage();
|
||||
const { instanceId } = useCurrentInstance();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
|
@ -262,12 +266,11 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
<div className={styles.expenseImportSection}>
|
||||
<h3 className={styles.sectionTitle}>{t('Scan-Upload')}</h3>
|
||||
const content = (
|
||||
<>
|
||||
{!embedded && <h3 className={styles.sectionTitle}>{t('Scan-Upload')}</h3>}
|
||||
<p className={styles.sectionDescription}>
|
||||
Upload PDF or JPG documents (receipts, invoices). Then start the pipeline: extract data → create positions → sync to accounting.
|
||||
{t('Laden Sie PDF- oder JPG-Dokumente (Belege, Rechnungen) hoch. Starten Sie dann die Pipeline: Daten extrahieren → Positionen erstellen → in Buchhaltung synchronisieren.')}
|
||||
</p>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
{pipelineState !== 'idle' && (
|
||||
|
|
@ -369,6 +372,17 @@ export const TrusteeScanUploadView: React.FC = () => {
|
|||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return <>{content}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
<div className={styles.expenseImportSection}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
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 { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
||||
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
||||
export { TrusteeImportProcessView } from './TrusteeImportProcessView';
|
||||
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
|
||||
export { TrusteeAnalyseView } from './TrusteeAnalyseView';
|
||||
export { TrusteeAbschlussView } from './TrusteeAbschlussView';
|
||||
export { TrusteeDataTablesView } from './TrusteeDataTablesView';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ProviderMultiSelect } from '../../../components/ProviderSelector';
|
|||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||||
import { getPageIcon } from '../../../config/pageRegistry';
|
||||
import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture';
|
||||
import api from '../../../api';
|
||||
import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
|
@ -50,9 +51,22 @@ interface WorkspaceInputProps {
|
|||
onPasteAsFile?: (file: File) => void;
|
||||
draftAppend?: string;
|
||||
onDraftAppendConsumed?: () => void;
|
||||
/**
|
||||
* Per-chat attachment persistence. When the parent loads a workflow, it
|
||||
* passes the IDs the backend has stored for that chat plus a nonce that
|
||||
* increments on every load. The chip-bar is then rehydrated, dropping
|
||||
* any IDs that no longer resolve against the available sources.
|
||||
*
|
||||
* `workflowId` is needed so that "x" detachments can be persisted via a
|
||||
* PATCH call without waiting for the next sendMessage round-trip.
|
||||
*/
|
||||
workflowId?: string | null;
|
||||
loadedAttachedDataSourceIds?: string[];
|
||||
loadedAttachedFeatureDataSourceIds?: string[];
|
||||
loadedNonce?: number;
|
||||
}
|
||||
|
||||
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _instanceId,
|
||||
export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId,
|
||||
onSend,
|
||||
isProcessing,
|
||||
onStop,
|
||||
|
|
@ -76,6 +90,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
|||
onPasteAsFile,
|
||||
draftAppend,
|
||||
onDraftAppendConsumed,
|
||||
workflowId,
|
||||
loadedAttachedDataSourceIds,
|
||||
loadedAttachedFeatureDataSourceIds,
|
||||
loadedNonce,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { languages: voiceCatalogLanguages } = useVoiceCatalog();
|
||||
|
|
@ -118,6 +136,50 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
|||
}
|
||||
}, [pendingAttachFdsId, onPendingAttachFdsConsumed]);
|
||||
|
||||
// Rehydrate the chip-bar whenever the parent re-loads a chat (loadedNonce
|
||||
// bumps on every loadWorkflow call). We trust the loaded IDs initially;
|
||||
// a separate effect below drops IDs that don't resolve once the source
|
||||
// lists have arrived from the backend.
|
||||
useEffect(() => {
|
||||
if (loadedNonce === undefined) return;
|
||||
setAttachedFileIds([]);
|
||||
setAttachedDataSourceIds(Array.isArray(loadedAttachedDataSourceIds) ? [...loadedAttachedDataSourceIds] : []);
|
||||
setAttachedFeatureDataSourceIds(Array.isArray(loadedAttachedFeatureDataSourceIds) ? [...loadedAttachedFeatureDataSourceIds] : []);
|
||||
}, [loadedNonce]);
|
||||
|
||||
// Drop persisted attachment IDs that no longer resolve to an existing
|
||||
// source (e.g. the DataSource was deleted while the chat was closed).
|
||||
// We only run this once the lists are populated to avoid wiping chips
|
||||
// before the lists have loaded.
|
||||
useEffect(() => {
|
||||
if (dataSources.length === 0 && attachedDataSourceIds.length === 0) return;
|
||||
const validIds = new Set(dataSources.map(d => d.id));
|
||||
setAttachedDataSourceIds(prev => {
|
||||
const filtered = prev.filter(id => validIds.has(id));
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
}, [dataSources, attachedDataSourceIds.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (featureDataSources.length === 0 && attachedFeatureDataSourceIds.length === 0) return;
|
||||
const validIds = new Set(featureDataSources.map(d => d.id));
|
||||
setAttachedFeatureDataSourceIds(prev => {
|
||||
const filtered = prev.filter(id => validIds.has(id));
|
||||
return filtered.length === prev.length ? prev : filtered;
|
||||
});
|
||||
}, [featureDataSources, attachedFeatureDataSourceIds.length]);
|
||||
|
||||
// Persist a changed attachment list to the backend so the next chat
|
||||
// reload reflects the current state. We debounce slightly by sending on
|
||||
// the next animation frame to coalesce rapid clicks.
|
||||
const _persistAttachments = useCallback((dsIds: string[], fdsIds: string[]) => {
|
||||
if (!instanceId || !workflowId) return;
|
||||
api.patch(`/api/workspace/${instanceId}/workflows/${workflowId}/attachments`, {
|
||||
dataSourceIds: dsIds,
|
||||
featureDataSourceIds: fdsIds,
|
||||
}).catch(err => console.warn('Failed to persist chat attachments:', err));
|
||||
}, [instanceId, workflowId]);
|
||||
|
||||
const promptBeforeVoiceRef = useRef('');
|
||||
const finalizedTextRef = useRef('');
|
||||
const currentInterimRef = useRef('');
|
||||
|
|
@ -210,14 +272,20 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({ instanceId: _ins
|
|||
}, []);
|
||||
|
||||
const _removeAttachedDataSource = useCallback((dsId: string) => {
|
||||
setAttachedDataSourceIds(prev => prev.filter(id => id !== dsId));
|
||||
}, []);
|
||||
setAttachedDataSourceIds(prev => {
|
||||
const next = prev.filter(id => id !== dsId);
|
||||
_persistAttachments(next, attachedFeatureDataSourceIds);
|
||||
return next;
|
||||
});
|
||||
}, [_persistAttachments, attachedFeatureDataSourceIds]);
|
||||
|
||||
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
|
||||
setAttachedFeatureDataSourceIds(prev =>
|
||||
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
|
||||
);
|
||||
}, []);
|
||||
setAttachedFeatureDataSourceIds(prev => {
|
||||
const next = prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId];
|
||||
_persistAttachments(attachedDataSourceIds, next);
|
||||
return next;
|
||||
});
|
||||
}, [_persistAttachments, attachedDataSourceIds]);
|
||||
|
||||
const _buildPromptFromRefs = useCallback(() => {
|
||||
const parts = [
|
||||
|
|
|
|||
|
|
@ -539,6 +539,10 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
|||
onPasteAsFile={_uploadAndAttach}
|
||||
draftAppend={draftAppend}
|
||||
onDraftAppendConsumed={() => setDraftAppend('')}
|
||||
workflowId={workspace.workflowId}
|
||||
loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds}
|
||||
loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds}
|
||||
loadedNonce={workspace.loadedNonce}
|
||||
/>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@ interface UseWorkspaceReturn {
|
|||
refreshFolders: () => void;
|
||||
refreshDataSources: () => void;
|
||||
dataSourceAccesses: DataSourceAccessEvent[];
|
||||
/**
|
||||
* Hydrated chip-bar state for the WorkspaceInput. Set by ``loadWorkflow``
|
||||
* to whatever the backend persisted for the chat (per-chat attachment
|
||||
* persistence). Sources that no longer exist are filtered out by the
|
||||
* WorkspaceInput before they're rendered.
|
||||
*
|
||||
* The `loadedNonce` increments on every load so the WorkspaceInput can
|
||||
* tell apart "same workflow, no change" from "user re-loaded the same
|
||||
* chat" and re-hydrate accordingly.
|
||||
*/
|
||||
loadedAttachedDataSourceIds: string[];
|
||||
loadedAttachedFeatureDataSourceIds: string[];
|
||||
loadedNonce: number;
|
||||
}
|
||||
|
||||
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||
|
|
@ -131,6 +144,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||
const [workflowVersion, setWorkflowVersion] = useState(0);
|
||||
const [dataSourceAccesses, setDataSourceAccesses] = useState<DataSourceAccessEvent[]>([]);
|
||||
const [loadedAttachedDataSourceIds, setLoadedAttachedDataSourceIds] = useState<string[]>([]);
|
||||
const [loadedAttachedFeatureDataSourceIds, setLoadedAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||
const [loadedNonce, setLoadedNonce] = useState(0);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const refreshFiles = useCallback(() => {
|
||||
|
|
@ -177,6 +193,8 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
setPendingEdits([]);
|
||||
setAgentProgress(null);
|
||||
setDataSourceAccesses([]);
|
||||
setLoadedAttachedDataSourceIds([]);
|
||||
setLoadedAttachedFeatureDataSourceIds([]);
|
||||
|
||||
api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`)
|
||||
.then(res => {
|
||||
|
|
@ -184,6 +202,15 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
.map((m: any) => _mapLoadedWorkspaceMessage(m, wfId))
|
||||
.sort(_compareWorkspaceMessages);
|
||||
setMessages(msgs);
|
||||
const dsIds: string[] = Array.isArray(res.data.attachedDataSourceIds)
|
||||
? res.data.attachedDataSourceIds.map((x: unknown) => String(x))
|
||||
: [];
|
||||
const fdsIds: string[] = Array.isArray(res.data.attachedFeatureDataSourceIds)
|
||||
? res.data.attachedFeatureDataSourceIds.map((x: unknown) => String(x))
|
||||
: [];
|
||||
setLoadedAttachedDataSourceIds(dsIds);
|
||||
setLoadedAttachedFeatureDataSourceIds(fdsIds);
|
||||
setLoadedNonce(n => n + 1);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [instanceId]);
|
||||
|
|
@ -195,6 +222,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
setPendingEdits([]);
|
||||
setAgentProgress(null);
|
||||
setDataSourceAccesses([]);
|
||||
setLoadedAttachedDataSourceIds([]);
|
||||
setLoadedAttachedFeatureDataSourceIds([]);
|
||||
setLoadedNonce(n => n + 1);
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
|
|
@ -496,6 +526,9 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
refreshFolders,
|
||||
refreshDataSources,
|
||||
dataSourceAccesses,
|
||||
loadedAttachedDataSourceIds,
|
||||
loadedAttachedFeatureDataSourceIds,
|
||||
loadedNonce,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,8 @@ export interface FeatureInstance {
|
|||
id: string; // UUID der Instanz
|
||||
featureCode: string; // "trustee", "chatbot", "chatworkflow", etc.
|
||||
mandateId: string; // Zugehöriger Mandant
|
||||
mandateName: string; // Für Anzeige
|
||||
mandateName: string; // Kurzzeichen / Slug des Mandanten (audit-stable)
|
||||
mandateLabel?: string; // Voller Name des Mandanten (UI-Anzeige) — optional fuer Backwards-Compat
|
||||
instanceLabel: string; // z.B. "PamoCreate AG"
|
||||
userRoles: string[]; // Rollen des Users in dieser Instanz (kann mehrere haben)
|
||||
permissions: InstancePermissions;
|
||||
|
|
@ -205,11 +206,9 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
icon: 'briefcase',
|
||||
views: [
|
||||
{ code: 'dashboard', label: 'Übersicht', path: 'dashboard' },
|
||||
{ code: 'positions', label: 'Positionen', path: 'positions' },
|
||||
{ code: 'documents', label: 'Dokumente', path: 'documents' },
|
||||
{ code: 'data-tables', label: 'Daten-Tabellen', path: 'data-tables' },
|
||||
{ code: 'position-documents', label: 'Zuordnungen', path: 'position-documents' },
|
||||
{ code: 'expense-import', label: 'Spesen Import', path: 'expense-import' },
|
||||
{ code: 'scan-upload', label: 'Scannen / Hochladen', path: 'scan-upload' },
|
||||
{ code: 'import-process', label: 'Import & Verarbeitung', path: 'import-process' },
|
||||
{ code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true },
|
||||
{ code: 'settings', label: 'Buchhaltungseinstellungen', path: 'settings' },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -84,7 +84,29 @@ export function mergeBillingIntoMandateFormData(
|
|||
};
|
||||
}
|
||||
|
||||
/** Split form submit payload into mandate PUT body and billing POST body. */
|
||||
/** Mandate fields that the AdminMandates form is allowed to update. */
|
||||
const _MANDATE_INVOICE_FIELDS = [
|
||||
'invoiceCompanyName',
|
||||
'invoiceContactName',
|
||||
'invoiceEmail',
|
||||
'invoiceLine1',
|
||||
'invoiceLine2',
|
||||
'invoicePostalCode',
|
||||
'invoiceCity',
|
||||
'invoiceState',
|
||||
'invoiceCountry',
|
||||
'invoiceVatNumber',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Split form submit payload into mandate PUT body and billing POST body.
|
||||
*
|
||||
* Only fields that the user can actually edit are forwarded. Audit-only /
|
||||
* read-only fields (id, deletedAt, isSystem, ...) are intentionally dropped.
|
||||
* The structured ``invoice*`` address fields are round-tripped here so the
|
||||
* address entered in the form is persisted on Mandate; empty strings are
|
||||
* normalized to ``null`` so the backend stores nothing instead of "".
|
||||
*/
|
||||
export function splitMandateAndBillingFromForm(
|
||||
formData: Record<string, unknown>
|
||||
): { mandatePayload: Record<string, unknown>; billingUpdate: BillingSettingsUpdate } {
|
||||
|
|
@ -92,6 +114,18 @@ export function splitMandateAndBillingFromForm(
|
|||
if ('name' in formData) mandatePayload.name = formData.name;
|
||||
if ('label' in formData) mandatePayload.label = formData.label;
|
||||
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
|
||||
for (const fieldName of _MANDATE_INVOICE_FIELDS) {
|
||||
if (!(fieldName in formData)) continue;
|
||||
const raw = formData[fieldName];
|
||||
if (raw === null || raw === undefined) {
|
||||
mandatePayload[fieldName] = null;
|
||||
} else if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim();
|
||||
mandatePayload[fieldName] = trimmed.length === 0 ? null : trimmed;
|
||||
} else {
|
||||
mandatePayload[fieldName] = raw;
|
||||
}
|
||||
}
|
||||
|
||||
const billingUpdate: BillingSettingsUpdate = {};
|
||||
if (
|
||||
|
|
|
|||
Loading…
Reference in a new issue