From 7d84160cdbf886e1bb45e0544c27bb565de1a84f Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 21 Apr 2026 00:50:42 +0200 Subject: [PATCH] data source fixes --- src/App.tsx | 6 +- src/api/billingApi.ts | 46 +- src/api/trusteeApi.ts | 85 ++ .../FormGeneratorReport.tsx | 59 +- .../FormGeneratorReportTypes.ts | 33 +- .../PeriodPicker/PeriodPicker.module.css | 394 ++++++ src/components/PeriodPicker/PeriodPicker.tsx | 179 +++ .../PeriodPicker/PeriodPickerCalendar.tsx | 132 ++ .../PeriodPicker/PeriodPickerLogic.ts | 241 ++++ .../PeriodPicker/PeriodPickerPopover.tsx | 303 ++++ .../PeriodPicker/PeriodPickerTypes.ts | 68 + src/components/PeriodPicker/index.ts | 21 + src/components/UnifiedDataBar/SourcesTab.tsx | 1228 ++++++++++------- src/hooks/useBilling.ts | 13 +- src/hooks/usePeriod.ts | 116 ++ src/hooks/useTrustee.ts | 73 + src/pages/ComplianceAuditPage.tsx | 41 +- src/pages/FeatureView.tsx | 14 +- src/pages/admin/AdminDatabaseHealthPage.tsx | 131 +- src/pages/billing/BillingAdmin.tsx | 62 +- src/pages/billing/BillingDashboard.tsx | 125 +- src/pages/billing/BillingDataView.tsx | 114 +- .../views/trustee/TrusteeAbschlussView.tsx | 55 +- .../trustee/TrusteeAccountingSettingsView.tsx | 142 +- .../views/trustee/TrusteeAnalyseView.tsx | 65 +- .../views/trustee/TrusteeDashboardView.tsx | 12 +- .../views/trustee/TrusteeDataTablesView.tsx | 271 ++++ .../trustee/TrusteeExpenseImportView.tsx | 25 +- .../trustee/TrusteeImportProcessView.tsx | 115 ++ .../views/trustee/TrusteeScanUploadView.tsx | 26 +- .../trustee/dataTables/TrusteeDataTab.tsx | 309 +++++ src/pages/views/trustee/index.ts | 2 + src/pages/views/workspace/WorkspaceInput.tsx | 82 +- src/pages/views/workspace/WorkspacePage.tsx | 4 + src/pages/views/workspace/useWorkspace.ts | 33 + src/types/mandate.ts | 9 +- src/utils/mandateBillingFormMerge.ts | 36 +- 37 files changed, 3898 insertions(+), 772 deletions(-) create mode 100644 src/components/PeriodPicker/PeriodPicker.module.css create mode 100644 src/components/PeriodPicker/PeriodPicker.tsx create mode 100644 src/components/PeriodPicker/PeriodPickerCalendar.tsx create mode 100644 src/components/PeriodPicker/PeriodPickerLogic.ts create mode 100644 src/components/PeriodPicker/PeriodPickerPopover.tsx create mode 100644 src/components/PeriodPicker/PeriodPickerTypes.ts create mode 100644 src/components/PeriodPicker/index.ts create mode 100644 src/hooks/usePeriod.ts create mode 100644 src/pages/views/trustee/TrusteeDataTablesView.tsx create mode 100644 src/pages/views/trustee/TrusteeImportProcessView.tsx create mode 100644 src/pages/views/trustee/dataTables/TrusteeDataTab.tsx diff --git a/src/App.tsx b/src/App.tsx index 821e327..a677a0d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -147,8 +147,7 @@ function App() { } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> @@ -158,8 +157,7 @@ function App() { } /> } /> } /> - } /> - } /> + } /> } /> } /> } /> diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 76f79fa..930749d 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -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; @@ -65,6 +69,12 @@ export interface UsageReport { costByFeature: Record; } +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 { - const params: Record = { 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 { + 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} diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts index d700f8b..c3eb5da 100644 --- a/src/api/trusteeApi.ts +++ b/src/api/trusteeApi.ts @@ -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( + request: ApiRequestFunction, + instanceId: string, + pathSegment: string, + params?: PaginationParams +): Promise | T[]> { + return await request({ + url: `${_getTrusteeBaseUrl(instanceId)}/${pathSegment}`, + method: 'get', + params: _buildPaginationParams(params), + }); +} + +export async function fetchDataAccounts( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise | TrusteeDataAccount[]> { + return _fetchReadOnlyTable(request, instanceId, 'data/accounts', params); +} + +export async function fetchDataJournalEntries( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise | TrusteeDataJournalEntry[]> { + return _fetchReadOnlyTable(request, instanceId, 'data/journal-entries', params); +} + +export async function fetchDataJournalLines( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise | TrusteeDataJournalLine[]> { + return _fetchReadOnlyTable(request, instanceId, 'data/journal-lines', params); +} + +export async function fetchDataContacts( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise | TrusteeDataContact[]> { + return _fetchReadOnlyTable(request, instanceId, 'data/contacts', params); +} + +export async function fetchDataAccountBalances( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise | TrusteeDataAccountBalance[]> { + return _fetchReadOnlyTable(request, instanceId, 'data/account-balances', params); +} + +export async function fetchAccountingConfigs( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise | TrusteeAccountingConfigRecord[]> { + return _fetchReadOnlyTable(request, instanceId, 'accounting/configs', params); +} + +export async function fetchAccountingSyncs( + request: ApiRequestFunction, + instanceId: string, + params?: PaginationParams +): Promise | TrusteeAccountingSyncRecord[]> { + return _fetchReadOnlyTable(request, instanceId, 'accounting/syncs', params); +} + export async function exportAccountingData( request: ApiRequestFunction, instanceId: string diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx index cb50290..8f3ff5f 100644 --- a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx @@ -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 = ({ }); }; - 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 = ({
)} - {/* Date Range */} + {/* Date Range (rendered via shared PeriodPicker) */} {hasDateRange && (
- {t('Von')} - _handleDateRangeChange('from', e.target.value)} - /> - {t('Bis')} - _handleDateRangeChange('to', e.target.value)} + {t('Zeitraum')} +
)} diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts index 3340569..4b03ecb 100644 --- a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts @@ -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; } diff --git a/src/components/PeriodPicker/PeriodPicker.module.css b/src/components/PeriodPicker/PeriodPicker.module.css new file mode 100644 index 0000000..73298ce --- /dev/null +++ b/src/components/PeriodPicker/PeriodPicker.module.css @@ -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; + } +} diff --git a/src/components/PeriodPicker/PeriodPicker.tsx b/src/components/PeriodPicker/PeriodPicker.tsx new file mode 100644 index 0000000..1b15a4e --- /dev/null +++ b/src/components/PeriodPicker/PeriodPicker.tsx @@ -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 = (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(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 ( +
+ + + {open && ( + + )} +
+ ); +}; + +export default PeriodPicker; diff --git a/src/components/PeriodPicker/PeriodPickerCalendar.tsx b/src/components/PeriodPicker/PeriodPickerCalendar.tsx new file mode 100644 index 0000000..486672c --- /dev/null +++ b/src/components/PeriodPicker/PeriodPickerCalendar.tsx @@ -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 = (props) => { + const { anchor, onAnchorChange, range, onPickDate, constraints } = props; + const { t } = useLanguage(); + + const monthsToShow = useMemo(() => [anchor, addMonthsToDate(anchor, 1)], [anchor]); + + return ( +
+
+ + + {`${_monthLabel(monthsToShow[0], t)} – ${_monthLabel(monthsToShow[1], t)}`} + + +
+ +
+ {monthsToShow.map((monthAnchor) => ( +
+
{_monthLabel(monthAnchor, t)}
+
+ {[0, 1, 2, 3, 4, 5, 6].map((i) => ( +
{_dayOfWeekLabel(i, t)}
+ ))} + {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 ( + + ); + })} +
+
+ ))} +
+
+ ); +}; + +export default PeriodPickerCalendar; diff --git a/src/components/PeriodPicker/PeriodPickerLogic.ts b/src/components/PeriodPicker/PeriodPickerLogic.ts new file mode 100644 index 0000000..627de6a --- /dev/null +++ b/src/components/PeriodPicker/PeriodPickerLogic.ts @@ -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); +} diff --git a/src/components/PeriodPicker/PeriodPickerPopover.tsx b/src/components/PeriodPicker/PeriodPickerPopover.tsx new file mode 100644 index 0000000..a1c521a --- /dev/null +++ b/src/components/PeriodPicker/PeriodPickerPopover.tsx @@ -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 = (props) => { + const { initialValue, constraints, onApply, onCancel } = props; + const { t } = useLanguage(); + + const [draft, setDraft] = useState(initialValue); + const [rangePick, setRangePick] = useState(() => ({ + from: fromIsoDate(initialValue.fromDate), + to: fromIsoDate(initialValue.toDate), + })); + const [calAnchor, setCalAnchor] = useState(() => { + 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(7); + const [lastNUnit, setLastNUnit] = useState('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(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 ( +
+
+ {/* Column 1: Presets */} +
+ {PRESETS_ORDER.map((kind) => { + const disabled = isPresetDisabled(kind, constraints); + const cls = [styles.presetBtn]; + if (draft.preset.kind === kind) cls.push(styles.active); + return ( + + ); + })} +
+ + {/* Column 2: Letzte/Nächste N */} +
+

{t('Letzte oder Nächste N')}

+ +
+
+ + +
+
+ +
+ setLastNAmount(parseInt(e.target.value, 10) || 1)} + /> + +
+ + +
+ + {/* Column 3: Calendar */} + +
+ + {/* Footer */} +
+ {t('Von')} + _onFooterFromChange(e.target.value)} + /> + {t('Bis')} + _onFooterToChange(e.target.value)} + /> + + + +
+
+ ); +}; + +export default PeriodPickerPopover; diff --git a/src/components/PeriodPicker/PeriodPickerTypes.ts b/src/components/PeriodPicker/PeriodPickerTypes.ts new file mode 100644 index 0000000..e4fd4fe --- /dev/null +++ b/src/components/PeriodPicker/PeriodPickerTypes.ts @@ -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; +} diff --git a/src/components/PeriodPicker/index.ts b/src/components/PeriodPicker/index.ts new file mode 100644 index 0000000..205d4e8 --- /dev/null +++ b/src/components/PeriodPicker/index.ts @@ -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'; diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index ad1f6d1..7806988 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -6,15 +6,24 @@ * └─ Service (Level 2, loaded when connection expanded) * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) * - * Feature Data tree: + * Feature Data tree (catalog-driven, supports recursive nesting): * MandateGroup * └─ FeatureConnection (feature instance) - * └─ FeatureTable (tables exposed by that instance) + * ├─ Group (categorical folder, isGroup=true) + * │ └─ ParentGroup or Table + * ├─ ParentGroup (table with isParent=true) → records + * │ └─ Record → child tables (which can themselves be ParentGroups → recursion) + * └─ Table (standalone) + * + * Path-aware state-keys (segments joined by '|', prefixed by featureInstanceId): + * g: - categorical group folder + * p: - parent group (record list of that table) + * r:: - specific record (its child tables rendered when expanded) * * Active Sources sections show scope-cycling and neutralize-toggle buttons. */ -import React, { useEffect, useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import { getPageIcon } from '../../config/pageRegistry'; @@ -73,7 +82,6 @@ interface FeatureConnectionNode { expanded: boolean; loading: boolean; tables: FeatureTableNode[] | null; - parentRecords: Record; } interface MandateGroupNode { @@ -89,9 +97,11 @@ interface FeatureTableNode { label: string; fields: string[]; isParent?: boolean; - parentTable?: string; - parentKey?: string; + parentTable?: string | null; + parentKey?: string | null; displayFields?: string[]; + isGroup?: boolean; + group?: string | null; } interface ParentRecordNode { @@ -99,7 +109,6 @@ interface ParentRecordNode { displayLabel: string; fields: Record; tableName: string; - expanded: boolean; } /* ─── Props ──────────────────────────────────────────────────────────── */ @@ -219,6 +228,84 @@ function _findTableFields( return []; } +/* ─── Feature tree builder (catalog → renderable items) ──────────────── */ + +type FeatureItem = + | { kind: 'group'; objectKey: string; label: string; items: FeatureItem[] } + | { kind: 'parentGroup'; table: FeatureTableNode } + | { kind: 'table'; table: FeatureTableNode }; + +/** + * Build the top-level feature tree from the flat catalog table list. + * + * - Items with `isGroup: true` become categorical group folders. + * - Items with `group: ` are placed inside the corresponding group. + * - Items with `parentTable` set are NOT rendered at top level — they are + * rendered dynamically when a parent record is expanded. + * - Items with `isParent: true` (and no parentTable) become top-level parent groups. + * - Everything else renders as a standalone table. + * + * Catalog declaration order is preserved; group children appear nested under + * the group folder in the order they were declared. + */ +function _buildTopFeatureTree(tables: FeatureTableNode[]): FeatureItem[] { + const groupChildren: Record = {}; + for (const t of tables) { + if (t.isGroup) groupChildren[t.objectKey] = []; + } + + const result: FeatureItem[] = []; + for (const t of tables) { + if (t.isGroup) { + result.push({ kind: 'group', objectKey: t.objectKey, label: t.label, items: groupChildren[t.objectKey] }); + } else if (t.parentTable) { + // Skip — child tables are rendered when their parent record is expanded. + continue; + } else if (t.group && groupChildren[t.group]) { + const item: FeatureItem = t.isParent + ? { kind: 'parentGroup', table: t } + : { kind: 'table', table: t }; + groupChildren[t.group].push(item); + } else if (t.isParent) { + result.push({ kind: 'parentGroup', table: t }); + } else { + result.push({ kind: 'table', table: t }); + } + } + return result; +} + +/** + * Children of a parent record: child tables (where parentTable === recordTableName) + * rendered as parentGroup or standalone table (recursion enables N-level nesting). + */ +function _childrenForRecord(allTables: FeatureTableNode[], parentTableName: string): FeatureItem[] { + return allTables + .filter(t => t.parentTable === parentTableName) + .map(t => t.isParent + ? { kind: 'parentGroup', table: t } + : { kind: 'table', table: t }); +} + +function _pathKey(featureInstanceId: string, segments: string[]): string { + return [featureInstanceId, ...segments].join('|'); +} + +/** Walks back through a path to find the closest preceding `r::` segment. */ +function _closestRecordSegment(segments: string[]): { tableName: string; recordId: string } | null { + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (seg.startsWith('r:')) { + const rest = seg.slice(2); + const sepIdx = rest.indexOf(':'); + if (sepIdx > 0) { + return { tableName: rest.slice(0, sepIdx), recordId: rest.slice(sepIdx + 1) }; + } + } + } + return null; +} + /* ─── Data fetching (module-level) ───────────────────────────────────── */ async function _loadServices(instanceId: string, connectionId: string): Promise { @@ -327,6 +414,12 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe const [loadingFeatures, setLoadingFeatures] = useState(false); const [addingFeatureKey, setAddingFeatureKey] = useState(null); + /* ── Path-aware feature node state (groups, parent groups, records) ── */ + const [featureExpandedPaths, setFeatureExpandedPaths] = useState>(new Set()); + const [featureRecordsByPath, setFeatureRecordsByPath] = useState>({}); + const [featureLoadingPath, setFeatureLoadingPath] = useState(null); + const [addingRecordPath, setAddingRecordPath] = useState(null); + /* ── Multi-selection state for Browse-Tree ── */ const [selectedKeys, setSelectedKeys] = useState>(new Set()); const lastClickedKeyRef = useRef(null); @@ -511,7 +604,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe } finally { if (mountedRef.current) setAddingPath(null); } - }, [instanceId, _fetchDataSources]); + }, [instanceId, _fetchDataSources, onSourcesChanged]); /* ── Remove DataSource ── */ const _removeDatasource = useCallback(async (dsId: string) => { @@ -522,7 +615,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe } catch (err) { console.error('Failed to remove data source:', err); } - }, [instanceId, _fetchDataSources]); + }, [instanceId, _fetchDataSources, onSourcesChanged]); /* ── Check if a path is already added ── */ const _isAdded = useCallback((connectionId: string, service: string | undefined, path: string | undefined): boolean => { @@ -647,7 +740,6 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe expanded: false, loading: false, tables: null, - parentRecords: {}, })), }))); }) @@ -691,7 +783,22 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe parentTable: t.parentTable ?? null, parentKey: t.parentKey ?? null, displayFields: t.displayFields ?? [], + isGroup: Boolean(t.isGroup), + group: t.group ?? null, })); + + // Default-expand all categorical groups so users immediately see their content. + const defaultExpansions: string[] = tables + .filter(t => t.isGroup) + .map(t => _pathKey(node.featureInstanceId, [`g:${t.objectKey}`])); + if (defaultExpansions.length > 0) { + setFeatureExpandedPaths(prev => { + const next = new Set(prev); + for (const k of defaultExpansions) next.add(k); + return next; + }); + } + if (mountedRef.current) { setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, loading: false, tables, @@ -707,7 +814,11 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe }, [instanceId]); /* ── Feature: Add table as FeatureDataSource ── */ - const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode): Promise => { + const _addFeatureTable = useCallback(async ( + node: FeatureConnectionNode, + table: FeatureTableNode, + extra?: { recordFilter?: Record; labelOverride?: string }, + ): Promise => { const key = `${node.featureInstanceId}-${table.tableName}`; setAddingFeatureKey(key); try { @@ -716,7 +827,8 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe featureCode: node.featureCode, tableName: table.tableName, objectKey: table.objectKey, - label: table.label || table.tableName, + label: extra?.labelOverride || table.label || table.tableName, + ...(extra?.recordFilter ? { recordFilter: extra.recordFilter } : {}), }); _fetchFeatureDataSources(); onSourcesChanged?.(); @@ -727,7 +839,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe } finally { if (mountedRef.current) setAddingFeatureKey(null); } - }, [instanceId, _fetchFeatureDataSources]); + }, [instanceId, _fetchFeatureDataSources, onSourcesChanged]); /* ── Feature: Remove FeatureDataSource ── */ const _removeFeatureDataSource = useCallback(async (fdsId: string) => { @@ -738,125 +850,131 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe } catch (err) { console.error('Failed to remove feature data source:', err); } - }, [instanceId, _fetchFeatureDataSources]); + }, [instanceId, _fetchFeatureDataSources, onSourcesChanged]); - /* ── Feature: check if table already added ── */ + /* ── Feature: check if table already added (no record filter) ── */ const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { return featureDataSources.some(fds => - fds.featureInstanceId === featureInstanceId && fds.tableName === tableName, + fds.featureInstanceId === featureInstanceId + && fds.tableName === tableName + && !fds.recordFilter, ); }, [featureDataSources]); - /* ── Parent groups: expand/collapse + load records ── */ - const [expandedParentGroups, setExpandedParentGroups] = useState>(new Set()); - const [loadingParentGroup, setLoadingParentGroup] = useState(null); - const [addingParentKey, setAddingParentKey] = useState(null); + /* ── Feature: toggle expand for a path-keyed node (group / parent group / record) ── */ + const _toggleFeaturePath = useCallback((pathKey: string) => { + setFeatureExpandedPaths(prev => { + const next = new Set(prev); + if (next.has(pathKey)) next.delete(pathKey); else next.add(pathKey); + return next; + }); + }, []); - const _toggleParentGroup = useCallback(async (node: FeatureConnectionNode, parentTableName: string) => { - const groupKey = `${node.featureInstanceId}-${parentTableName}`; + /** + * Load records for a parent group at a given path. + * If the path contains a preceding `r:
:` segment, the records are + * filtered to children of that ancestor record (nested record hierarchy). + */ + const _loadRecordsAtPath = useCallback(async ( + node: FeatureConnectionNode, + table: FeatureTableNode, + parentPathSegments: string[], + ) => { + const segments = [...parentPathSegments, `p:${table.tableName}`]; + const pathKey = _pathKey(node.featureInstanceId, segments); - if (expandedParentGroups.has(groupKey)) { - setExpandedParentGroups(prev => { const next = new Set(prev); next.delete(groupKey); return next; }); - return; - } + if (featureRecordsByPath[pathKey]) return; - setExpandedParentGroups(prev => new Set(prev).add(groupKey)); - - if (node.parentRecords[parentTableName]) return; - - setLoadingParentGroup(groupKey); + setFeatureLoadingPath(pathKey); try { + const params: Record = {}; + const ancestor = _closestRecordSegment(parentPathSegments); + if (ancestor && table.parentKey) { + params.parentKey = table.parentKey; + params.parentValue = ancestor.recordId; + } + const res = await api.get( - `/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${parentTableName}`, + `/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/parent-objects/${table.tableName}`, + Object.keys(params).length > 0 ? { params } : undefined, ); const records: ParentRecordNode[] = (res.data.parentObjects || []).map((r: any) => ({ id: r.id, displayLabel: r.displayLabel || r.id, fields: r.fields || {}, - tableName: parentTableName, - expanded: false, + tableName: table.tableName, })); if (mountedRef.current) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, - parentRecords: { ...n.parentRecords, [parentTableName]: records }, - }))); + setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: records })); } } catch { if (mountedRef.current) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, - parentRecords: { ...n.parentRecords, [parentTableName]: [] }, - }))); + setFeatureRecordsByPath(prev => ({ ...prev, [pathKey]: [] })); } } finally { - if (mountedRef.current) setLoadingParentGroup(null); + if (mountedRef.current) setFeatureLoadingPath(null); } - }, [instanceId, expandedParentGroups]); + }, [instanceId, featureRecordsByPath]); - const _toggleParentRecord = useCallback((featureInstanceId: string, parentTableName: string, recordId: string) => { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, featureInstanceId, n => ({ - ...n, - parentRecords: { - ...n.parentRecords, - [parentTableName]: (n.parentRecords[parentTableName] || []).map(r => - r.id === recordId ? { ...r, expanded: !r.expanded } : r, - ), - }, - }))); - }, []); - - /* ── Parent record: add parent + all children with recordFilter ── */ - const _addParentRecord = useCallback(async ( + /** + * Add a parent record + all its DIRECT child tables as FeatureDataSources. + * + * - Parent itself: recordFilter = { id: } + * - Each direct child table: recordFilter = { : } + * + * Nested parent groups (e.g. CoachingSession under CoachingContext) are added + * with the FK-only filter, scoping to "all sub-records of this ancestor". + * Users can drill in further to add a specific sub-record. + */ + const _addRecordWithChildren = useCallback(async ( node: FeatureConnectionNode, - parentRecord: ParentRecordNode, - allTables: FeatureTableNode[], + parentTable: FeatureTableNode, + record: ParentRecordNode, + pathSegments: string[], ) => { - const addKey = `${node.featureInstanceId}-parent-${parentRecord.id}`; - setAddingParentKey(addKey); + const addKey = `${_pathKey(node.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`; + setAddingRecordPath(addKey); try { - const parentTable = allTables.find(t => t.tableName === parentRecord.tableName && t.isParent); - const childTables = allTables.filter(t => t.parentTable === parentRecord.tableName); - - if (parentTable) { - const parentLabel = `${parentTable.label || parentTable.tableName}: ${parentRecord.displayLabel}`; - await api.post(`/api/workspace/${instanceId}/feature-datasources`, { - featureInstanceId: node.featureInstanceId, - featureCode: node.featureCode, - tableName: parentTable.tableName, - objectKey: parentTable.objectKey, - label: parentLabel, - recordFilter: { id: parentRecord.id }, - }); - } + const allTables = node.tables || []; + const parentLabel = `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`; + await api.post(`/api/workspace/${instanceId}/feature-datasources`, { + featureInstanceId: node.featureInstanceId, + featureCode: node.featureCode, + tableName: parentTable.tableName, + objectKey: parentTable.objectKey, + label: parentLabel, + recordFilter: { id: record.id }, + }); + const childTables = allTables.filter(t => t.parentTable === parentTable.tableName); for (const child of childTables) { - const childLabel = `${child.label || child.tableName}: ${parentRecord.displayLabel}`; + if (!child.parentKey) continue; + const childLabel = `${child.label || child.tableName}: ${record.displayLabel}`; await api.post(`/api/workspace/${instanceId}/feature-datasources`, { featureInstanceId: node.featureInstanceId, featureCode: node.featureCode, tableName: child.tableName, objectKey: child.objectKey, label: childLabel, - recordFilter: { [child.parentKey!]: parentRecord.id }, + recordFilter: { [child.parentKey]: record.id }, }); } _fetchFeatureDataSources(); onSourcesChanged?.(); } catch (err) { - console.error('Failed to add parent record sources:', err); + console.error('Failed to add record sources:', err); } finally { - if (mountedRef.current) setAddingParentKey(null); + if (mountedRef.current) setAddingRecordPath(null); } - }, [instanceId, _fetchFeatureDataSources]); + }, [instanceId, _fetchFeatureDataSources, onSourcesChanged]); /* ── Check if a parent record is already added ── */ - const _isParentRecordAdded = useCallback((featureInstanceId: string, parentTableName: string, recordId: string): boolean => { + const _isRecordAdded = useCallback((featureInstanceId: string, parentTableName: string, recordId: string): boolean => { return featureDataSources.some(fds => - fds.featureInstanceId === featureInstanceId && - fds.tableName === parentTableName && - fds.recordFilter?.id === recordId, + fds.featureInstanceId === featureInstanceId + && fds.tableName === parentTableName + && fds.recordFilter?.id === recordId, ); }, [featureDataSources]); @@ -947,16 +1065,17 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe group={g} onToggleGroup={_toggleMandateGroup} onToggleFeature={_toggleFeatureNode} - onEnsureFds={_addFeatureTable} + onAddFeatureTable={_addFeatureTable} isTableAdded={_isFeatureTableAdded} addingKey={addingFeatureKey} - onToggleParentGroup={_toggleParentGroup} - onToggleParentRecord={_toggleParentRecord} - onAddParentRecord={_addParentRecord} - isParentRecordAdded={_isParentRecordAdded} - expandedParentGroups={expandedParentGroups} - loadingParentGroup={loadingParentGroup} - addingParentKey={addingParentKey} + featureExpandedPaths={featureExpandedPaths} + featureRecordsByPath={featureRecordsByPath} + featureLoadingPath={featureLoadingPath} + addingRecordPath={addingRecordPath} + onToggleFeaturePath={_toggleFeaturePath} + onLoadRecordsAtPath={_loadRecordsAtPath} + onAddRecordWithChildren={_addRecordWithChildren} + isRecordAdded={_isRecordAdded} onSendToChat={onSendToChat_FeatureSource} featureDataSources={featureDataSources} onCycleScope={_cycleFeatureScope} @@ -970,7 +1089,7 @@ const SourcesTab: React.FC = ({ context, onSourcesChanged, onSe ); }; -/* ─── TreeNodeView (recursive) ───────────────────────────────────────── */ +/* ─── TreeNodeView (recursive — Browse Sources side) ─────────────────── */ function _findDs(dataSources: UdbDataSource[], node: TreeNode): UdbDataSource | undefined { const expectedSourceType = node.service ? (_SERVICE_TO_SOURCE_TYPE[node.service] || node.service) : undefined; @@ -1192,9 +1311,34 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ ); }; -/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ +/* ─── Feature-side action props (shared) ─────────────────────────────── */ -interface _FdsActionProps { +interface _FeatureActionContext { + featureExpandedPaths: Set; + featureRecordsByPath: Record; + featureLoadingPath: string | null; + addingRecordPath: string | null; + onToggleFeaturePath: (pathKey: string) => void; + onLoadRecordsAtPath: ( + node: FeatureConnectionNode, + table: FeatureTableNode, + parentPathSegments: string[], + ) => void; + onAddRecordWithChildren: ( + node: FeatureConnectionNode, + parentTable: FeatureTableNode, + record: ParentRecordNode, + pathSegments: string[], + ) => void; + isRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; + onAddFeatureTable: ( + node: FeatureConnectionNode, + table: FeatureTableNode, + extra?: { recordFilter?: Record; labelOverride?: string }, + ) => Promise; + isTableAdded: (featureInstanceId: string, tableName: string) => boolean; + addingKey: string | null; + onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; featureDataSources: UdbFeatureDataSource[]; onCycleScope: (fds: UdbFeatureDataSource) => void; onToggleNeutralize: (fds: UdbFeatureDataSource) => void; @@ -1203,30 +1347,16 @@ interface _FdsActionProps { featureTree: MandateGroupNode[]; } -interface _MandateGroupViewProps extends _FdsActionProps { +/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ + +interface _MandateGroupViewProps extends _FeatureActionContext { group: MandateGroupNode; onToggleGroup: (mandateId: string) => void; onToggleFeature: (node: FeatureConnectionNode) => void; - onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; - isTableAdded: (featureInstanceId: string, tableName: string) => boolean; - addingKey: string | null; - onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void; - onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void; - onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void; - isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; - expandedParentGroups: Set; - loadingParentGroup: string | null; - addingParentKey: string | null; - onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; } -const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ - group, onToggleGroup, onToggleFeature, onEnsureFds, isTableAdded, addingKey, - onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, - expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, - featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, - onRemoveFds, featureTree, -}) => { +const _MandateGroupView: React.FC<_MandateGroupViewProps> = (props) => { + const { group, onToggleGroup, onToggleFeature, ...ctx } = props; const [hovered, setHovered] = useState(false); const chevron = group.expanded ? '\u25BE' : '\u25B8'; @@ -1259,23 +1389,7 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ key={fNode.featureInstanceId} node={fNode} onToggle={onToggleFeature} - onEnsureFds={onEnsureFds} - isTableAdded={isTableAdded} - addingKey={addingKey} - onToggleParentGroup={onToggleParentGroup} - onToggleParentRecord={onToggleParentRecord} - onAddParentRecord={onAddParentRecord} - isParentRecordAdded={isParentRecordAdded} - expandedParentGroups={expandedParentGroups} - loadingParentGroup={loadingParentGroup} - addingParentKey={addingParentKey} - onSendToChat={onSendToChat} - featureDataSources={featureDataSources} - onCycleScope={onCycleScope} - onToggleNeutralize={onToggleNeutralize} - onToggleNeutralizeField={onToggleNeutralizeField} - onRemoveFds={onRemoveFds} - featureTree={featureTree} + {...ctx} /> ))} @@ -1284,41 +1398,27 @@ const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ ); }; -/* ─── FeatureNodeView (feature instance + tables) ────────────────────── */ +/* ─── FeatureNodeView (feature instance + recursive items) ───────────── */ -interface _FeatureNodeViewProps extends _FdsActionProps { +interface _FeatureNodeViewProps extends _FeatureActionContext { node: FeatureConnectionNode; onToggle: (node: FeatureConnectionNode) => void; - onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; - isTableAdded: (featureInstanceId: string, tableName: string) => boolean; - addingKey: string | null; - onToggleParentGroup: (node: FeatureConnectionNode, parentTableName: string) => void; - onToggleParentRecord: (featureInstanceId: string, parentTableName: string, recordId: string) => void; - onAddParentRecord: (node: FeatureConnectionNode, record: ParentRecordNode, allTables: FeatureTableNode[]) => void; - isParentRecordAdded: (featureInstanceId: string, parentTableName: string, recordId: string) => boolean; - expandedParentGroups: Set; - loadingParentGroup: string | null; - addingParentKey: string | null; - onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; } -const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ - node, onToggle, onEnsureFds, - onToggleParentGroup, onToggleParentRecord, onAddParentRecord, isParentRecordAdded, - expandedParentGroups, loadingParentGroup, addingParentKey, onSendToChat, - featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, - onRemoveFds, featureTree, -}) => { +const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = (props) => { + const { node, onToggle, ...ctx } = props; const { t } = useLanguage(); const [hovered, setHovered] = useState(false); const chevron = node.expanded ? '\u25BE' : '\u25B8'; - const wildcardFds = featureDataSources.find( + const wildcardFds = ctx.featureDataSources.find( f => f.featureInstanceId === node.featureInstanceId && f.tableName === '*' && !f.recordFilter, ); - const parentTables = (node.tables || []).filter(t => t.isParent); - const standaloneTables = (node.tables || []).filter(t => !t.isParent && !t.parentTable); + const topItems = useMemo( + () => _buildTopFeatureTree(node.tables || []), + [node.tables], + ); return (
@@ -1363,10 +1463,9 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ {node.tableCount} {t('Tabellen')} - {/* Dynamic Remove (left of stable trio) */} {wildcardFds && ( )} - {/* ── Stable trio: chat | scope | neutralize ── */}
- {node.expanded && node.tables && node.tables.length > 0 && ( + {node.expanded && topItems.length > 0 && (
- {/* Parent table groups (hierarchical) */} - {parentTables.map(pt => { - const groupKey = `${node.featureInstanceId}-${pt.tableName}`; - const isGroupExpanded = expandedParentGroups.has(groupKey); - const isGroupLoading = loadingParentGroup === groupKey; - const records = node.parentRecords[pt.tableName]; - const childTables = (node.tables || []).filter(t => t.parentTable === pt.tableName); - const ptLabel = pt.label || pt.tableName; - - return ( - <_ParentGroupView - key={groupKey} - featureNode={node} - parentTable={pt} - label={ptLabel} - expanded={isGroupExpanded} - loading={isGroupLoading} - records={records || null} - childTables={childTables} - allTables={node.tables!} - onToggleGroup={() => onToggleParentGroup(node, pt.tableName)} - onToggleRecord={(recordId) => onToggleParentRecord(node.featureInstanceId, pt.tableName, recordId)} - onAddRecord={(record) => onAddParentRecord(node, record, node.tables!)} - isRecordAdded={(recordId) => isParentRecordAdded(node.featureInstanceId, pt.tableName, recordId)} - addingParentKey={addingParentKey} - onSendToChat={onSendToChat} - featureDataSources={featureDataSources} - onCycleScope={onCycleScope} - onToggleNeutralize={onToggleNeutralize} - onToggleNeutralizeField={onToggleNeutralizeField} - onRemoveFds={onRemoveFds} - featureTree={featureTree} - inheritedScope={wildcardFds?.scope} - inheritedNeutralize={wildcardFds?.neutralize} - /> - ); - })} - - {/* Standalone tables (not part of any hierarchy) */} - {standaloneTables.map(table => { - const fds = featureDataSources.find( - f => f.featureInstanceId === node.featureInstanceId && f.tableName === table.tableName && !f.recordFilter, - ); - return ( - <_FeatureTableRow - key={table.objectKey} - featureNode={node} - table={table} - onEnsureFds={onEnsureFds} - onSendToChat={onSendToChat} - fds={fds} - onCycleScope={onCycleScope} - onToggleNeutralize={onToggleNeutralize} - onToggleNeutralizeField={onToggleNeutralizeField} - onRemoveFds={onRemoveFds} - featureTree={featureTree} - inheritedScope={wildcardFds?.scope} - inheritedNeutralize={wildcardFds?.neutralize} - /> - ); - })} + {topItems.map((item, idx) => ( + <_FeatureItemView + key={_itemKey(item, idx)} + featureNode={node} + item={item} + pathSegments={[]} + depth={1} + inheritedScope={wildcardFds?.scope} + inheritedNeutralize={wildcardFds?.neutralize} + {...ctx} + /> + ))}
)} - {node.expanded && node.tables && node.tables.length === 0 && !node.loading && ( + {node.expanded && (node.tables?.length ?? 0) === 0 && !node.loading && (
{t('(keine Tabellen)')}
@@ -1500,26 +1549,441 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ ); }; -/* ─── FeatureTableRow ────────────────────────────────────────────────── */ +function _itemKey(item: FeatureItem, idx: number): string { + if (item.kind === 'group') return `g:${item.objectKey}-${idx}`; + if (item.kind === 'parentGroup') return `p:${item.table.tableName}-${idx}`; + return `t:${item.table.tableName}-${idx}`; +} + +/* ─── FeatureItemView (recursive — handles group / parentGroup / table) ── */ + +interface _FeatureItemViewProps extends _FeatureActionContext { + featureNode: FeatureConnectionNode; + item: FeatureItem; + pathSegments: string[]; + depth: number; + inheritedScope?: string; + inheritedNeutralize?: boolean; +} + +const _FeatureItemView: React.FC<_FeatureItemViewProps> = (props) => { + const { featureNode, item, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; + + if (item.kind === 'group') { + return ( + <_GroupFolderView + featureNode={featureNode} + objectKey={item.objectKey} + label={item.label} + items={item.items} + pathSegments={pathSegments} + depth={depth} + inheritedScope={inheritedScope} + inheritedNeutralize={inheritedNeutralize} + {...ctx} + /> + ); + } + if (item.kind === 'parentGroup') { + return ( + <_ParentGroupView + featureNode={featureNode} + table={item.table} + pathSegments={pathSegments} + depth={depth} + inheritedScope={inheritedScope} + inheritedNeutralize={inheritedNeutralize} + {...ctx} + /> + ); + } + return ( + <_FeatureTableRow + featureNode={featureNode} + table={item.table} + depth={depth} + onAddFeatureTable={ctx.onAddFeatureTable} + onSendToChat={ctx.onSendToChat} + featureDataSources={ctx.featureDataSources} + onCycleScope={ctx.onCycleScope} + onToggleNeutralize={ctx.onToggleNeutralize} + onToggleNeutralizeField={ctx.onToggleNeutralizeField} + onRemoveFds={ctx.onRemoveFds} + featureTree={ctx.featureTree} + inheritedScope={inheritedScope} + inheritedNeutralize={inheritedNeutralize} + /> + ); +}; + +/* ─── GroupFolderView (categorical folder) ───────────────────────────── */ + +interface _GroupFolderViewProps extends _FeatureActionContext { + featureNode: FeatureConnectionNode; + objectKey: string; + label: string; + items: FeatureItem[]; + pathSegments: string[]; + depth: number; + inheritedScope?: string; + inheritedNeutralize?: boolean; +} + +const _GroupFolderView: React.FC<_GroupFolderViewProps> = (props) => { + const { featureNode, objectKey, label, items, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; + const [hovered, setHovered] = useState(false); + + const segments = [...pathSegments, `g:${objectKey}`]; + const pathKey = _pathKey(featureNode.featureInstanceId, segments); + const expanded = ctx.featureExpandedPaths.has(pathKey); + const chevron = expanded ? '\u25BE' : '\u25B8'; + + return ( +
+
ctx.onToggleFeaturePath(pathKey)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {chevron} + + {'\uD83D\uDCC1'} + + {label} + +
+ + {expanded && items.length > 0 && ( +
+ {items.map((sub, idx) => ( + <_FeatureItemView + key={_itemKey(sub, idx)} + featureNode={featureNode} + item={sub} + pathSegments={segments} + depth={depth + 1} + inheritedScope={inheritedScope} + inheritedNeutralize={inheritedNeutralize} + {...ctx} + /> + ))} +
+ )} +
+ ); +}; + +/* ─── ParentGroupView (parent table → list of records) ───────────────── */ + +interface _ParentGroupViewProps extends _FeatureActionContext { + featureNode: FeatureConnectionNode; + table: FeatureTableNode; + pathSegments: string[]; + depth: number; + inheritedScope?: string; + inheritedNeutralize?: boolean; +} + +const _ParentGroupView: React.FC<_ParentGroupViewProps> = (props) => { + const { featureNode, table, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; + const { t } = useLanguage(); + const [hovered, setHovered] = useState(false); + + const segments = [...pathSegments, `p:${table.tableName}`]; + const pathKey = _pathKey(featureNode.featureInstanceId, segments); + const expanded = ctx.featureExpandedPaths.has(pathKey); + const loading = ctx.featureLoadingPath === pathKey; + const records = ctx.featureRecordsByPath[pathKey]; + const chevron = expanded ? '\u25BE' : '\u25B8'; + + const childTables = (featureNode.tables || []).filter(c => c.parentTable === table.tableName); + + const _onToggle = () => { + const willExpand = !expanded; + ctx.onToggleFeaturePath(pathKey); + if (willExpand && !records) { + ctx.onLoadRecordsAtPath(featureNode, table, pathSegments); + } + }; + + return ( +
+
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {loading ? _Spinner() : chevron} + + {'\uD83D\uDCC2'} + + {table.label || table.tableName} + + {childTables.length > 0 && ( + + +{childTables.length} {t('Tabellen')} + + )} +
+ + {expanded && records && records.length > 0 && ( +
+ {records.map(record => ( + <_RecordRowView + key={record.id} + featureNode={featureNode} + parentTable={table} + record={record} + pathSegments={segments} + depth={depth + 1} + inheritedScope={inheritedScope} + inheritedNeutralize={inheritedNeutralize} + {...ctx} + /> + ))} +
+ )} + + {expanded && records && records.length === 0 && !loading && ( +
+ {t('(keine Einträge)')} +
+ )} +
+ ); +}; + +/* ─── RecordRowView (single record + recursive children when expanded) ── */ + +interface _RecordRowViewProps extends _FeatureActionContext { + featureNode: FeatureConnectionNode; + parentTable: FeatureTableNode; + record: ParentRecordNode; + pathSegments: string[]; + depth: number; + inheritedScope?: string; + inheritedNeutralize?: boolean; +} + +const _RecordRowView: React.FC<_RecordRowViewProps> = (props) => { + const { featureNode, parentTable, record, pathSegments, depth, inheritedScope, inheritedNeutralize, ...ctx } = props; + const { t } = useLanguage(); + const [hovered, setHovered] = useState(false); + + const segments = [...pathSegments, `r:${parentTable.tableName}:${record.id}`]; + const pathKey = _pathKey(featureNode.featureInstanceId, segments); + const expanded = ctx.featureExpandedPaths.has(pathKey); + const chevron = expanded ? '\u25BE' : '\u25B8'; + + const fds = ctx.featureDataSources.find( + f => f.featureInstanceId === featureNode.featureInstanceId + && f.tableName === parentTable.tableName + && f.recordFilter?.id === record.id, + ); + + const childItems = useMemo( + () => _childrenForRecord(featureNode.tables || [], parentTable.tableName), + [featureNode.tables, parentTable.tableName], + ); + + const isAdded = ctx.isRecordAdded(featureNode.featureInstanceId, parentTable.tableName, record.id); + const addingKey = `${_pathKey(featureNode.featureInstanceId, pathSegments)}|r:${parentTable.tableName}:${record.id}`; + const isAdding = ctx.addingRecordPath === addingKey; + + const _chatPayload = { + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + tableName: parentTable.tableName, + objectKey: parentTable.objectKey, + label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`, + }; + + return ( +
+
ctx.onToggleFeaturePath(pathKey)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + draggable + onDragStart={(e) => { + e.stopPropagation(); + const payload = JSON.stringify({ + featureInstanceId: featureNode.featureInstanceId, + featureCode: featureNode.featureCode, + objectKey: parentTable.objectKey, + label: `${parentTable.label || parentTable.tableName}: ${record.displayLabel}`, + }); + e.dataTransfer.setData('application/feature-source', payload); + e.dataTransfer.setData('text/plain', record.displayLabel); + e.dataTransfer.effectAllowed = 'copy'; + }} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: fds + ? (hovered ? '#ede7f6' : '#7b1fa208') + : (hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent'), + transition: 'background 0.1s', userSelect: 'none', + }} + title={Object.entries(record.fields).map(([k, v]) => `${k}: ${v}`).join(', ')} + > + + {chevron} + + {'\uD83D\uDCCB'} + + {record.displayLabel} + + + {/* Add record + direct children as data sources (only when not already added). */} + {!fds && !isAdded && ( + + )} + + {fds && ( + + )} + + + {fds ? ( + + ) : ( + + {_SCOPE_ICONS[inheritedScope || 'personal']} + + )} + {fds ? ( + + ) : ( + + {'\uD83D\uDD12'} + + )} +
+ + {expanded && childItems.length > 0 && ( +
+ {childItems.map((sub, idx) => ( + <_FeatureItemView + key={_itemKey(sub, idx)} + featureNode={featureNode} + item={sub} + pathSegments={segments} + depth={depth + 1} + inheritedScope={fds?.scope ?? inheritedScope} + inheritedNeutralize={fds?.neutralize ?? inheritedNeutralize} + {...ctx} + /> + ))} +
+ )} +
+ ); +}; + +/* ─── FeatureTableRow (leaf table) ───────────────────────────────────── */ interface _FeatureTableRowProps { featureNode: FeatureConnectionNode; table: FeatureTableNode; - onEnsureFds: (node: FeatureConnectionNode, table: FeatureTableNode) => Promise; + depth: number; + onAddFeatureTable: ( + node: FeatureConnectionNode, + table: FeatureTableNode, + extra?: { recordFilter?: Record; labelOverride?: string }, + ) => Promise; onSendToChat?: (params: { featureInstanceId: string; featureCode: string; tableName?: string; objectKey: string; label: string; fieldName?: string }) => void; - fds?: UdbFeatureDataSource; - onCycleScope?: (fds: UdbFeatureDataSource) => void; - onToggleNeutralize?: (fds: UdbFeatureDataSource) => void; - onToggleNeutralizeField?: (fds: UdbFeatureDataSource, fieldName: string) => void; - onRemoveFds?: (fdsId: string) => void; - featureTree?: MandateGroupNode[]; + featureDataSources: UdbFeatureDataSource[]; + onCycleScope: (fds: UdbFeatureDataSource) => void; + onToggleNeutralize: (fds: UdbFeatureDataSource) => void; + onToggleNeutralizeField: (fds: UdbFeatureDataSource, fieldName: string) => void; + onRemoveFds: (fdsId: string) => void; + featureTree: MandateGroupNode[]; inheritedScope?: string; inheritedNeutralize?: boolean; } const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ - featureNode, table, onEnsureFds, onSendToChat, - fds, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, + featureNode, table, depth, onAddFeatureTable, onSendToChat, + featureDataSources, onCycleScope, onToggleNeutralize, onToggleNeutralizeField, onRemoveFds, featureTree, inheritedScope, inheritedNeutralize, }) => { const { t } = useLanguage(); @@ -1527,6 +1991,12 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ const [fieldsExpanded, setFieldsExpanded] = useState(false); const tableLabel = table.label || table.tableName; + const fds = featureDataSources.find( + f => f.featureInstanceId === featureNode.featureInstanceId + && f.tableName === table.tableName + && !f.recordFilter, + ); + const effectiveScope = fds?.scope ?? inheritedScope; const effectiveNeutralize = fds?.neutralize ?? inheritedNeutralize ?? false; const _chatPayload = { @@ -1534,10 +2004,12 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ featureCode: featureNode.featureCode, tableName: table.tableName, objectKey: table.objectKey, - label: table.label || table.tableName, + label: tableLabel, }; - const resolvedFields = featureTree ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName) : table.fields; + const resolvedFields = featureTree + ? _findTableFields(featureTree, featureNode.featureInstanceId, table.tableName) + : table.fields; const neutralizedCount = fds?.neutralizeFields?.length ?? 0; return ( @@ -1554,7 +2026,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ }} style={{ display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + paddingLeft: depth * 16 + 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, borderRadius: 3, background: fds ? (hovered ? '#ede7f6' : '#7b1fa208') @@ -1580,8 +2052,7 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ )} - {/* Dynamic Remove (left of stable trio) */} - {fds && onRemoveFds && ( + {fds && ( )} - {/* ── Stable trio: chat | scope | neutralize ── */} - )} - - {/* ── Stable trio: chat | scope | neutralize ── */} - - {fds && onCycleScope ? ( - - ) : ( - - {_SCOPE_ICONS[inheritedScope || 'personal']} - - )} - {fds && onToggleNeutralize ? ( - - ) : ( - - {'\uD83D\uDD12'} - - )} - - - - {record.expanded && ( -
- {childTables.map(ct => { - const ctLabel = ct.label || ct.tableName; - return ( -
- {'\uD83D\uDCC4'} - {ctLabel} - ({ct.parentKey}) -
- ); - })} -
- )} - - ); -}; - export default SourcesTab; diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts index 785a519..de8eb7d 100644 --- a/src/hooks/useBilling.ts +++ b/src/hooks/useBilling.ts @@ -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) { diff --git a/src/hooks/usePeriod.ts b/src/hooks/usePeriod.ts new file mode 100644 index 0000000..cb12f2c --- /dev/null +++ b/src/hooks/usePeriod.ts @@ -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 }; +} diff --git a/src/hooks/useTrustee.ts b/src/hooks/useTrustee.ts index de367fd..83e0823 100644 --- a/src/hooks/useTrustee.ts +++ b/src/hooks/useTrustee.ts @@ -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( + entityName: string, + fetchAll: TrusteeEntityConfig['fetchAll'] +): TrusteeEntityConfig { + return { + entityName, + fetchAll, + fetchById: async () => null, + create: _readOnlyMutator as any, + update: _readOnlyMutator as any, + deleteItem: _readOnlyMutator as any, + }; +} + +export const useTrusteeDataAccounts = _createTrusteeEntityHook( + _buildReadOnlyConfig('TrusteeDataAccount', fetchDataAccountsApi) +); +export const useTrusteeDataJournalEntries = _createTrusteeEntityHook( + _buildReadOnlyConfig('TrusteeDataJournalEntry', fetchDataJournalEntriesApi) +); +export const useTrusteeDataJournalLines = _createTrusteeEntityHook( + _buildReadOnlyConfig('TrusteeDataJournalLine', fetchDataJournalLinesApi) +); +export const useTrusteeDataContacts = _createTrusteeEntityHook( + _buildReadOnlyConfig('TrusteeDataContact', fetchDataContactsApi) +); +export const useTrusteeDataAccountBalances = _createTrusteeEntityHook( + _buildReadOnlyConfig('TrusteeDataAccountBalance', fetchDataAccountBalancesApi) +); +export const useTrusteeAccountingConfigs = _createTrusteeEntityHook( + _buildReadOnlyConfig('TrusteeAccountingConfig', fetchAccountingConfigsApi) +); +export const useTrusteeAccountingSyncs = _createTrusteeEntityHook( + _buildReadOnlyConfig('TrusteeAccountingSync', fetchAccountingSyncsApi) +); + +export type { + TrusteeDataAccount, + TrusteeDataJournalEntry, + TrusteeDataJournalLine, + TrusteeDataContact, + TrusteeDataAccountBalance, + TrusteeAccountingConfigRecord, + TrusteeAccountingSyncRecord, +}; + diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx index b4043a0..7d70bce 100644 --- a/src/pages/ComplianceAuditPage.tsx +++ b/src/pages/ComplianceAuditPage.tsx @@ -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 = { @@ -153,7 +160,10 @@ export const ComplianceAuditPage: React.FC = () => { // ── Tab C state ── const [stats, setStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); - const [statsRange, setStatsRange] = useState(30); + const [statsPeriod, setStatsPeriod] = useState(() => { + 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([]); @@ -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' && (
- {[7, 30, 90].map(d => ( - - ))} + { + 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', + ]} + />
{statsLoading ? ( diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index aa1baac..03c4b45 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -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> = { 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, diff --git a/src/pages/admin/AdminDatabaseHealthPage.tsx b/src/pages/admin/AdminDatabaseHealthPage.tsx index f781f22..49270ec 100644 --- a/src/pages/admin/AdminDatabaseHealthPage.tsx +++ b/src/pages/admin/AdminDatabaseHealthPage.tsx @@ -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([]); const [loading, setLoading] = useState(false); const [cleaning, setCleaning] = useState(null); + const [downloading, setDownloading] = useState(null); const [cleaningAll, setCleaningAll] = useState(false); const [onlyProblems, setOnlyProblems] = useState(true); const [dbFilter, setDbFilter] = useState(''); @@ -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 => { 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 = () => { {t('Scan')} {totalOrphans > 0 && ( - )} @@ -579,6 +670,14 @@ const OrphansTab: React.FC = () => { pageSize={50} selectable={false} customActions={[ + { + id: 'download', + icon: , + 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: , diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index d0a835e..6bbe742 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -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 = ({ mandateId, crea const [amount, setAmount] = useState(''); const [busy, setBusy] = useState(false); const [localMsg, setLocalMsg] = useState(null); + const [allowedAmounts, setAllowedAmounts] = useState([]); + + 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 = ({ 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 = ({ mandateId, crea
- setAmount(e.target.value)} - placeholder={t('z. B. 50 oder -20')} - min="0.01" - step="0.01" - required - /> + {allowedAmounts.length > 0 ? ( + + ) : ( + + )}
diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx index c20a27d..fa6fb7c 100644 --- a/src/pages/billing/BillingDashboard.tsx +++ b/src/pages/billing/BillingDashboard.tsx @@ -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 = ({ 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(() => { + 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(() => _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), [t]); return (
@@ -216,34 +235,30 @@ export const BillingDashboard: React.FC = () => {

{t('Nutzungsstatistik')}

- { + setBucketSize(e.target.value as BillingBucketSize); + setBucketUserOverridden(true); + }} className={styles.select} + aria-label={t('Gruppierung')} > - - + + + - - {selectedPeriod === 'month' && ( - - )}
diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index d0ca931..9b94162 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -13,12 +13,32 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport'; import type { ReportSection, ReportFilterState, ReportChartDataPoint } 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; // ============================================================================ @@ -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(() => _initialStatsPeriod()); + const [statsBucketSize, setStatsBucketSize] = useState(() => { + 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 = { period, year, ..._scopeParams }; - if (period === 'day' && month) { - params.month = month; - } + const params: Record = { + 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(() => ({ + enabled: true, + direction: 'past' as const, + defaultPresetKind: 'thisMonth' as const, + enabledPresets: [ + 'thisMonth', 'lastMonth', 'thisQuarter', 'lastQuarter', + 'ytd', 'lastYear', 'last12Months', 'lastN', 'custom', + ] as const, }), []); // Build scope options from balances (mandates the user has access to) @@ -627,8 +675,24 @@ export const BillingDataView: React.FC = () => {
+
+ + +
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(null); const isPollingRef = useRef(false); + const [period, setPeriod] = useState(() => { + 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 (
@@ -242,7 +266,14 @@ export const TrusteeAbschlussView: React.FC = () => { {_tabDescription(activeTab, t)}

- {workflowsLoading ? ( + {isComingSoon ? ( +
+

+ {t('In Kürze verfügbar.')}{' '} + {t('Diese Funktion befindet sich in Vorbereitung.')} +

+
+ ) : workflowsLoading ? (

{t('Workflows werden geladen…')}

) : !currentWorkflow ? (
@@ -260,6 +291,18 @@ export const TrusteeAbschlussView: React.FC = () => {
+
+ + +
+ + ))} + - {existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && ( + {activeTab === 'settings' && ( + <> +

+ {t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')} +

+ + {existingConfig?.configured && ( +
+ {t('Verbunden:')} {existingConfig.displayLabel || existingConfig.connectorType} +
+ )} + + {existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
0
@@ -366,12 +413,23 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
)} - {/* Step 4: Import Accounting Data */} - {existingConfig?.configured && ( -
-
4
-
-

{t('Buchhaltungsdaten importieren')}

+ + )} + + {activeTab === 'import-data' && ( + <> + {!existingConfig?.configured && ( +
+

+ {t('Bevor Sie Daten importieren können, richten Sie zuerst die Verbindung zum Buchhaltungssystem im Tab «Verbindungseinstellungen» ein.')} +

+
+ )} + {existingConfig?.configured && ( +
+
0
+
+

{t('Buchhaltungsdaten importieren')}

{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}

@@ -425,40 +483,16 @@ export const TrusteeAccountingSettingsView: React.FC = () => { ); })()} -
+
- - setDateFrom(e.target.value)} style={{ width: '160px' }} /> + +
-
- - setDateTo(e.target.value)} style={{ width: '160px' }} /> -
-
-
- {[ - { 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 => ( - - ))}
@@ -472,8 +506,8 @@ export const TrusteeAccountingSettingsView: React.FC = () => { setImportJobId(null); try { const body: Record = {}; - 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 = () => { )}
)} -
-
+
+
+ )} + )}
diff --git a/src/pages/views/trustee/TrusteeAnalyseView.tsx b/src/pages/views/trustee/TrusteeAnalyseView.tsx index 2194842..b3752d4 100644 --- a/src/pages/views/trustee/TrusteeAnalyseView.tsx +++ b/src/pages/views/trustee/TrusteeAnalyseView.tsx @@ -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(null); + // One PeriodValue per tab, defaults derived from `_periodConfigForTab`. + const [periodByTab, setPeriodByTab] = useState>(() => { + const initial: Record = {}; + 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 = { workflowId: wf.id }; + const payload: Record = { + 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 = () => {
)} +
+ + +
+ + ))} + + +
+ +
+ + ); +}; + +export default TrusteeDataTablesView; diff --git a/src/pages/views/trustee/TrusteeExpenseImportView.tsx b/src/pages/views/trustee/TrusteeExpenseImportView.tsx index 12804ce..042d2b1 100644 --- a/src/pages/views/trustee/TrusteeExpenseImportView.tsx +++ b/src/pages/views/trustee/TrusteeExpenseImportView.tsx @@ -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 = ({ embedded = false }) => { const { t } = useLanguage(); const { instanceId, mandateId } = useCurrentInstance(); const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections(); @@ -464,10 +468,9 @@ export const TrusteeExpenseImportView: React.FC = () => { } }; - return ( -
-
-

{t('Einrichtung des Ausgabenimports')}

+ const content = ( + <> + {!embedded &&

{t('Einrichtung des Ausgabenimports')}

}

{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.')} {

)} - + + ); + + if (embedded) { + return <>{content}; + } + + return ( +
+
+ {content}
); diff --git a/src/pages/views/trustee/TrusteeImportProcessView.tsx b/src/pages/views/trustee/TrusteeImportProcessView.tsx new file mode 100644 index 0000000..3cffa0f --- /dev/null +++ b/src/pages/views/trustee/TrusteeImportProcessView.tsx @@ -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 ( +
+
+

{t('Import & Verarbeitung')}

+ +
+ {_TABS.map((tab) => ( + + ))} +
+ +

+ {_tabDescription(activeTab, t)} +

+ + {activeTab === 'receipts' && } + {activeTab === 'upload' && } + {activeTab === 'sync' && ( +
+

{t('Weiterleitung zu den Buchhaltungs-Einstellungen…')}

+
+ )} +
+
+ ); +}; + +export default TrusteeImportProcessView; diff --git a/src/pages/views/trustee/TrusteeScanUploadView.tsx b/src/pages/views/trustee/TrusteeScanUploadView.tsx index 6147c2b..4fe2dfb 100644 --- a/src/pages/views/trustee/TrusteeScanUploadView.tsx +++ b/src/pages/views/trustee/TrusteeScanUploadView.tsx @@ -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 = ({ embedded = false }) => { const { t } = useLanguage(); const { instanceId } = useCurrentInstance(); const { showSuccess, showError } = useToast(); @@ -262,12 +266,11 @@ export const TrusteeScanUploadView: React.FC = () => { ); } - return ( -
-
-

{t('Scan-Upload')}

+ const content = ( + <> + {!embedded &&

{t('Scan-Upload')}

}

- 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.')}

{error &&
{error}
} {pipelineState !== 'idle' && ( @@ -369,6 +372,17 @@ export const TrusteeScanUploadView: React.FC = () => { )} + + ); + + if (embedded) { + return <>{content}; + } + + return ( +
+
+ {content}
); diff --git a/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx new file mode 100644 index 0000000..04735f5 --- /dev/null +++ b/src/pages/views/trustee/dataTables/TrusteeDataTab.tsx @@ -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 = ({ + 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 = 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(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 ( +
+
+ ⚠️ +

+ {t('Fehler beim Laden: {detail}', { detail: String(error) })} +

+ +
+
+ ); + } + + 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 ( +
+
+ +
+ +
+ +
+ + {editingRow && canUpdate && ( +
+
+
+

+ {entityLabel + ? t('{label} bearbeiten', { label: entityLabel }) + : t('Bearbeiten')} +

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ {t('Lade Formular')} +
+ ) : ( + setEditingRow(null)} + submitButtonText={t('Speichern')} + cancelButtonText={t('Abbrechen')} + instanceId={instanceId || undefined} + /> + )} +
+
+
+ )} +
+ ); +}; + +export default TrusteeDataTab; diff --git a/src/pages/views/trustee/index.ts b/src/pages/views/trustee/index.ts index a3d5ca6..625ce78 100644 --- a/src/pages/views/trustee/index.ts +++ b/src/pages/views/trustee/index.ts @@ -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'; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index de47569..6444c9a 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -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 = ({ instanceId: _instanceId, +export const WorkspaceInput: React.FC = ({ instanceId, onSend, isProcessing, onStop, @@ -76,6 +90,10 @@ export const WorkspaceInput: React.FC = ({ 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 = ({ 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 = ({ 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 = [ diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index d973a82..b94d96d 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -539,6 +539,10 @@ export const WorkspacePage: React.FC = ({ persistentInstance onPasteAsFile={_uploadAndAttach} draftAppend={draftAppend} onDraftAppendConsumed={() => setDraftAppend('')} + workflowId={workspace.workflowId} + loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds} + loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds} + loadedNonce={workspace.loadedNonce} /> diff --git a/src/pages/views/workspace/useWorkspace.ts b/src/pages/views/workspace/useWorkspace.ts index 21884a8..f79fd54 100644 --- a/src/pages/views/workspace/useWorkspace.ts +++ b/src/pages/views/workspace/useWorkspace.ts @@ -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(null); const [workflowVersion, setWorkflowVersion] = useState(0); const [dataSourceAccesses, setDataSourceAccesses] = useState([]); + const [loadedAttachedDataSourceIds, setLoadedAttachedDataSourceIds] = useState([]); + const [loadedAttachedFeatureDataSourceIds, setLoadedAttachedFeatureDataSourceIds] = useState([]); + 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, }; } diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 10de5dd..fa6ec17 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -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 = { 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' }, ] diff --git a/src/utils/mandateBillingFormMerge.ts b/src/utils/mandateBillingFormMerge.ts index 4cc916f..1c9b9b3 100644 --- a/src/utils/mandateBillingFormMerge.ts +++ b/src/utils/mandateBillingFormMerge.ts @@ -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 ): { mandatePayload: Record; 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 (