Merge pull request #23 from valueonag/feat/service-subscription
Feat/service subscription
This commit is contained in:
commit
ab127abacf
42 changed files with 2518 additions and 428 deletions
|
|
@ -38,10 +38,10 @@ import { SettingsPage } from './pages/Settings';
|
|||
import { GDPRPage } from './pages/GDPR';
|
||||
import StorePage from './pages/Store';
|
||||
import { FeatureViewPage } from './pages/FeatureView';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminAutomationLogsPage, AdminLogsPage } from './pages/admin';
|
||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView } from './pages/billing';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
useEffect(() => {
|
||||
|
|
@ -156,6 +156,8 @@ function App() {
|
|||
|
||||
{/* Workspace + Automation2 Editor */}
|
||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
||||
|
||||
{/* Automation2 Workflows & Tasks */}
|
||||
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
|
||||
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
||||
|
|
@ -192,7 +194,9 @@ function App() {
|
|||
<Route index element={<BillingAdmin />} />
|
||||
<Route path="mandates" element={<BillingMandateView />} />
|
||||
</Route>
|
||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
||||
<Route path="automation-logs" element={<AdminAutomationLogsPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||
|
|
|
|||
|
|
@ -254,17 +254,26 @@ export async function fetchAutomationAttributes(
|
|||
* Endpoint: GET /api/automation-templates
|
||||
*/
|
||||
export async function fetchAutomationTemplates(
|
||||
request: ApiRequestFunction
|
||||
): Promise<AutomationTemplate[]> {
|
||||
const data = await request({
|
||||
url: '/api/automation-templates',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
if (data?.items && Array.isArray(data.items)) {
|
||||
return data.items;
|
||||
request: ApiRequestFunction,
|
||||
params?: any
|
||||
): Promise<any> {
|
||||
const requestParams: Record<string, string> = {};
|
||||
if (params && typeof params === 'object') {
|
||||
const paginationObj: any = {};
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
return await request({
|
||||
url: '/api/automation-templates',
|
||||
method: 'get',
|
||||
params: requestParams,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
143
src/api/subscriptionApi.ts
Normal file
143
src/api/subscriptionApi.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES — aligned with State Machine (wiki/concepts/Subscription-State-Machine.md)
|
||||
// ============================================================================
|
||||
|
||||
export type SubscriptionStatus = 'PENDING' | 'SCHEDULED' | 'TRIALING' | 'ACTIVE' | 'PAST_DUE' | 'EXPIRED';
|
||||
export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE';
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
planKey: string;
|
||||
selectableByUser: boolean;
|
||||
title: Record<string, string>;
|
||||
description: Record<string, string>;
|
||||
currency: string;
|
||||
billingPeriod: BillingPeriod;
|
||||
pricePerUserCHF: number;
|
||||
pricePerFeatureInstanceCHF: number;
|
||||
autoRenew: boolean;
|
||||
maxUsers: number | null;
|
||||
maxFeatureInstances: number | null;
|
||||
trialDays: number | null;
|
||||
successorPlanKey: string | null;
|
||||
}
|
||||
|
||||
export interface MandateSubscription {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
planKey: string;
|
||||
status: SubscriptionStatus;
|
||||
recurring: boolean;
|
||||
startedAt: string;
|
||||
effectiveFrom: string | null;
|
||||
endedAt: string | null;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
trialEndsAt: string | null;
|
||||
snapshotPricePerUserCHF: number;
|
||||
snapshotPricePerInstanceCHF: number;
|
||||
stripeSubscriptionId: string | null;
|
||||
}
|
||||
|
||||
export interface SubscriptionStatusResponse {
|
||||
active: boolean;
|
||||
subscription: MandateSubscription | null;
|
||||
plan: SubscriptionPlan | null;
|
||||
scheduled: MandateSubscription | null;
|
||||
}
|
||||
|
||||
export interface ActivatePlanResponse {
|
||||
redirectUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
function _mandateConfig(mandateId?: string): Record<string, any> {
|
||||
if (!mandateId) return {};
|
||||
return { headers: { 'X-Mandate-Id': mandateId } };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function fetchSelectablePlans(
|
||||
request: ApiRequestFunction,
|
||||
mandateId?: string,
|
||||
): Promise<SubscriptionPlan[]> {
|
||||
return await request({
|
||||
url: '/api/subscription/plans',
|
||||
method: 'get',
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSubscriptionStatus(
|
||||
request: ApiRequestFunction,
|
||||
mandateId?: string,
|
||||
): Promise<SubscriptionStatusResponse> {
|
||||
return await request({
|
||||
url: '/api/subscription/status',
|
||||
method: 'get',
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function activatePlan(
|
||||
request: ApiRequestFunction,
|
||||
planKey: string,
|
||||
mandateId?: string,
|
||||
returnUrl?: string,
|
||||
): Promise<ActivatePlanResponse> {
|
||||
return await request({
|
||||
url: '/api/subscription/activate',
|
||||
method: 'post',
|
||||
data: { planKey, returnUrl: returnUrl || '' },
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelSubscription(
|
||||
request: ApiRequestFunction,
|
||||
subscriptionId: string,
|
||||
mandateId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await request({
|
||||
url: '/api/subscription/cancel',
|
||||
method: 'post',
|
||||
data: { subscriptionId },
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reactivateSubscription(
|
||||
request: ApiRequestFunction,
|
||||
subscriptionId: string,
|
||||
mandateId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await request({
|
||||
url: '/api/subscription/reactivate',
|
||||
method: 'post',
|
||||
data: { subscriptionId },
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyCheckout(
|
||||
request: ApiRequestFunction,
|
||||
sessionId: string,
|
||||
mandateId?: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
return await request({
|
||||
url: '/api/subscription/checkout/verify',
|
||||
method: 'post',
|
||||
data: { sessionId },
|
||||
additionalConfig: _mandateConfig(mandateId),
|
||||
});
|
||||
}
|
||||
|
|
@ -100,11 +100,10 @@ export function FormGeneratorControls({
|
|||
onPageChange,
|
||||
onPageSizeChange,
|
||||
supportsBackendPagination = false,
|
||||
hookData: _hookData, // Reserved for future use
|
||||
hookData,
|
||||
onCsvExport,
|
||||
csvExporting = false
|
||||
}: FormGeneratorControlsProps) {
|
||||
void _hookData; // Suppress unused variable warning
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Check if all items are selected
|
||||
|
|
@ -290,9 +289,8 @@ export function FormGeneratorControls({
|
|||
»»
|
||||
</button>
|
||||
|
||||
{/* Total items count - always show actual displayed data length */}
|
||||
<span className={styles.paginationInfo}>
|
||||
({loading ? '...' : displayData.length.toString()} {t('formgen.pagination.items', 'items')})
|
||||
({loading ? '...' : (hookData?.pagination?.totalItems ?? displayData.length).toString()} {t('formgen.pagination.items', 'items')})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
position: relative;
|
||||
overflow-x: hidden; /* Horizontal scroll handled by topScrollbar */
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
background: var(--color-bg);
|
||||
/* Fill remaining space but constrain to available height */
|
||||
flex: 1 1 0;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,58 @@
|
|||
/**
|
||||
* FormGeneratorTable — Backend-driven data table.
|
||||
*
|
||||
* ARCHITECTURE:
|
||||
* This table does NO client-side filtering, sorting, or pagination.
|
||||
* All data processing is delegated to the backend via hookData.refetch().
|
||||
* The `data` prop is rendered as-is (displayData = data).
|
||||
*
|
||||
* REQUIRED CONTRACT for interactive features (search, filter, sort, pagination):
|
||||
*
|
||||
* hookData={{
|
||||
* refetch, // (params?: PaginationParams) => Promise<void>
|
||||
* // Called on every search/filter/sort/page change.
|
||||
* // Must fetch from backend with pagination query param
|
||||
* // and update the data + pagination states.
|
||||
* pagination, // { currentPage, pageSize, totalItems, totalPages } | null
|
||||
* // Drives pagination controls. Comes from backend response.
|
||||
* fetchFilterValues, // (columnKey: string) => Promise<string[]> (Optional)
|
||||
* // If provided, called when a filter dropdown opens.
|
||||
* // If NOT provided but apiEndpoint is set, the table
|
||||
* // auto-fetches from `{apiEndpoint}/filter-values?column=xxx`.
|
||||
* }}
|
||||
*
|
||||
* Without hookData.refetch, interactive controls (sort, filter, search,
|
||||
* pagination) are inert — the table renders data but actions have no effect.
|
||||
*
|
||||
* FILTER VALUES (autofilter):
|
||||
* When a filterable column's dropdown opens, distinct values are loaded from:
|
||||
* 1. column.filterOptions (static enum — used as-is, no backend call)
|
||||
* 2. hookData.fetchFilterValues(columnKey) if provided
|
||||
* 3. GET {apiEndpoint}/filter-values?column=xxx&pagination={currentFilters}
|
||||
* Cross-filtering is supported: changing a filter invalidates the cache,
|
||||
* so re-opening another column's dropdown re-fetches with current filters.
|
||||
* Boolean columns render as "Ja"/"Nein"; date columns render as range picker.
|
||||
*
|
||||
* BACKEND RESPONSE FORMAT (for refetch):
|
||||
* { items: T[], pagination: PaginationMetadata | null }
|
||||
*
|
||||
* BACKEND RESPONSE FORMAT (for filter-values):
|
||||
* string[]
|
||||
*
|
||||
* EXAMPLE (minimal integration):
|
||||
*
|
||||
* const { data, pagination, loading, refetch } = useMyEntityHook();
|
||||
*
|
||||
* <FormGeneratorTable
|
||||
* data={data}
|
||||
* columns={columns}
|
||||
* loading={loading}
|
||||
* hookData={{ refetch, pagination }}
|
||||
* apiEndpoint="/api/my-entity/" // enables CSV export + auto filter values
|
||||
* />
|
||||
*
|
||||
* See useOrgUsers / AdminUsersPage for a full reference implementation.
|
||||
*/
|
||||
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||
import type { IconType } from 'react-icons';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
|
@ -175,6 +230,67 @@ export interface FormGeneratorTableProps<T = any> {
|
|||
onRowDragStart?: (e: React.DragEvent<HTMLTableRowElement>, row: T) => void;
|
||||
}
|
||||
|
||||
const _FILTER_PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Renders a scrollable list of filter values with IntersectionObserver-based lazy loading.
|
||||
* Shows _FILTER_PAGE_SIZE items initially, loads more as the user scrolls.
|
||||
*/
|
||||
function FilterValuesList({
|
||||
columnKey,
|
||||
allValues,
|
||||
activeFilter,
|
||||
onSelect,
|
||||
}: {
|
||||
columnKey: string;
|
||||
allValues: string[];
|
||||
activeFilter: any;
|
||||
onSelect: (value: string) => void;
|
||||
}) {
|
||||
const [displayCount, setDisplayCount] = useState(_FILTER_PAGE_SIZE);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayCount(_FILTER_PAGE_SIZE);
|
||||
}, [columnKey, allValues.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || displayCount >= allValues.length) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
setDisplayCount(prev => Math.min(prev + _FILTER_PAGE_SIZE, allValues.length));
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [displayCount, allValues.length]);
|
||||
|
||||
const visibleValues = allValues.slice(0, displayCount);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleValues.map(value => (
|
||||
<div
|
||||
key={value}
|
||||
className={`${styles.filterOption} ${activeFilter === value ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => onSelect(value)}
|
||||
title={value}
|
||||
>
|
||||
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
||||
</div>
|
||||
))}
|
||||
{displayCount < allValues.length && (
|
||||
<div ref={sentinelRef} style={{ height: 1, opacity: 0 }} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormGeneratorTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns: providedColumns,
|
||||
|
|
@ -294,8 +410,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, 300); // 300ms debounce
|
||||
setDebouncedSearchTerm(prev => {
|
||||
if (prev !== searchTerm) setCurrentPage(1);
|
||||
return searchTerm;
|
||||
});
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
|
@ -718,21 +837,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const existingIndex = current.findIndex(sc => sc.key === key);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
// Column not in sort list → add as ascending (lowest priority)
|
||||
return [...current, { key, direction: 'asc' }];
|
||||
}
|
||||
|
||||
const existing = current[existingIndex];
|
||||
if (existing.direction === 'asc') {
|
||||
// Ascending → change to descending (keep same position)
|
||||
const newConfigs = [...current];
|
||||
newConfigs[existingIndex] = { key, direction: 'desc' };
|
||||
return newConfigs;
|
||||
}
|
||||
|
||||
// Descending → remove from sort list
|
||||
return current.filter(sc => sc.key !== key);
|
||||
});
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Get sort info for a column (returns { direction, position } or null)
|
||||
|
|
@ -743,7 +860,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
}, [sortConfigs]);
|
||||
|
||||
// Handle filtering
|
||||
const handleFilter = (key: string, value: any) => {
|
||||
const handleFilter = (key: string, value: any, keepOpen = false) => {
|
||||
setFilters(prev => {
|
||||
const newFilters = { ...prev };
|
||||
if (value === undefined || value === '' || value === null) {
|
||||
|
|
@ -753,8 +870,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
}
|
||||
return newFilters;
|
||||
});
|
||||
setCurrentPage(1); // Reset to first page when filtering
|
||||
setOpenFilterColumn(null); // Close filter dropdown
|
||||
setCurrentPage(1);
|
||||
if (!keepOpen) {
|
||||
setOpenFilterColumn(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle filter input focus
|
||||
|
|
@ -782,22 +901,17 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
}, [filters]);
|
||||
|
||||
// Track which filter columns show all values (expanded beyond initial 100)
|
||||
const [expandedFilterColumns, setExpandedFilterColumns] = useState<Set<string>>(new Set());
|
||||
// Async-loaded filter values per column (from backend via hookData.fetchFilterValues)
|
||||
const [asyncFilterValues, setAsyncFilterValues] = useState<Record<string, string[]>>({});
|
||||
const [filterValuesLoading, setFilterValuesLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
const _toggleFilterExpand = useCallback((columnKey: string) => {
|
||||
setExpandedFilterColumns(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(columnKey)) {
|
||||
next.delete(columnKey);
|
||||
} else {
|
||||
next.add(columnKey);
|
||||
// Invalidate cached filter values when filters change (cross-filtering)
|
||||
const filtersRef = useRef(filters);
|
||||
useEffect(() => {
|
||||
if (filtersRef.current !== filters) {
|
||||
filtersRef.current = filters;
|
||||
setAsyncFilterValues({});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
}, [filters]);
|
||||
|
||||
// Load filter values on-demand when a filter dropdown is opened
|
||||
useEffect(() => {
|
||||
|
|
@ -811,58 +925,61 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
// Skip if already loaded or currently loading
|
||||
if (asyncFilterValues[openFilterColumn] || filterValuesLoading[openFilterColumn]) return;
|
||||
|
||||
// If the hook provides fetchFilterValues, use it (backend distinct query)
|
||||
const _fetchValues = async (columnKey: string) => {
|
||||
setFilterValuesLoading(prev => ({ ...prev, [columnKey]: true }));
|
||||
try {
|
||||
let values: string[];
|
||||
if (hookData?.fetchFilterValues && typeof hookData.fetchFilterValues === 'function') {
|
||||
setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: true }));
|
||||
hookData.fetchFilterValues(openFilterColumn).then((values: string[]) => {
|
||||
setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: values }));
|
||||
}).catch(() => {
|
||||
// On error, fall back to current page data (set empty to prevent re-fetch)
|
||||
setAsyncFilterValues(prev => ({ ...prev, [openFilterColumn]: [] }));
|
||||
}).finally(() => {
|
||||
setFilterValuesLoading(prev => ({ ...prev, [openFilterColumn]: false }));
|
||||
});
|
||||
values = await hookData.fetchFilterValues(columnKey);
|
||||
} else if (apiEndpoint && supportsBackendPagination) {
|
||||
const endpoint = apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint;
|
||||
const params: Record<string, string> = { column: columnKey };
|
||||
if (Object.keys(filters).length > 0) {
|
||||
params.pagination = JSON.stringify({ filters });
|
||||
}
|
||||
}, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData]);
|
||||
const response = await api.get(`${endpoint}/filter-values`, { params });
|
||||
values = Array.isArray(response.data) ? response.data : [];
|
||||
} else {
|
||||
values = [];
|
||||
}
|
||||
setAsyncFilterValues(prev => ({ ...prev, [columnKey]: values }));
|
||||
} catch {
|
||||
setAsyncFilterValues(prev => ({ ...prev, [columnKey]: [] }));
|
||||
} finally {
|
||||
setFilterValuesLoading(prev => ({ ...prev, [columnKey]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
_fetchValues(openFilterColumn);
|
||||
}, [openFilterColumn, detectedColumns, asyncFilterValues, filterValuesLoading, hookData, apiEndpoint, supportsBackendPagination, filters]);
|
||||
|
||||
// Get unique values for a column (for filter dropdown)
|
||||
// Priority: 1) column.filterOptions (static enum)
|
||||
// 2) asyncFilterValues (loaded from backend)
|
||||
// 3) data (current page - fallback)
|
||||
// Sources: 1) column.filterOptions (static enum)
|
||||
// 2) asyncFilterValues (loaded from backend via hookData.fetchFilterValues)
|
||||
// 3) data — ONLY when no backend pagination (data = full dataset)
|
||||
// With backend pagination, data is a single page, so extracting filter
|
||||
// values from it would be incomplete and misleading.
|
||||
const getUniqueValuesForColumn = useCallback((columnKey: string): string[] => {
|
||||
const column = detectedColumns.find(c => c.key === columnKey);
|
||||
|
||||
// Static enum options defined in the column config
|
||||
if (column?.filterOptions && column.filterOptions.length > 0) {
|
||||
return column.filterOptions;
|
||||
}
|
||||
|
||||
// Values loaded asynchronously from the backend (all data, not just page)
|
||||
if (asyncFilterValues[columnKey] && asyncFilterValues[columnKey].length > 0) {
|
||||
return asyncFilterValues[columnKey];
|
||||
}
|
||||
|
||||
// Fallback: extract from current page data
|
||||
const values = new Set<string>();
|
||||
data.forEach(row => {
|
||||
const value = row[columnKey];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (isTextMultilingual(value)) {
|
||||
const text = value.en || Object.values(value)[0];
|
||||
if (text) values.add(String(text));
|
||||
} else {
|
||||
values.add(JSON.stringify(value));
|
||||
if (!apiEndpoint && !hookData?.fetchFilterValues) {
|
||||
console.warn(
|
||||
`FormGeneratorTable: Column "${columnKey}" is filterable ` +
|
||||
`but has no filterOptions, no hookData.fetchFilterValues, and no apiEndpoint. ` +
|
||||
`Filter dropdown will be empty. Provide apiEndpoint (auto-fetches /filter-values) ` +
|
||||
`or add filterOptions to the column config.`
|
||||
);
|
||||
}
|
||||
} else if (typeof value === 'boolean') {
|
||||
values.add(value ? 'true' : 'false');
|
||||
} else {
|
||||
values.add(String(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(values).sort();
|
||||
}, [data, detectedColumns, asyncFilterValues]);
|
||||
return [];
|
||||
}, [detectedColumns, asyncFilterValues, apiEndpoint, hookData]);
|
||||
|
||||
// Close filter dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
|
|
@ -1131,7 +1248,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
topScrollbar.removeEventListener('scroll', syncTopToContainer);
|
||||
tableContainer.removeEventListener('scroll', syncContainerToTop);
|
||||
};
|
||||
}, [displayData, detectedColumns, columnWidths]); // Re-run when data or columns change
|
||||
}, [detectedColumns, columnWidths]); // ResizeObserver handles data-driven size changes
|
||||
|
||||
// Track which cells are currently being updated (for loading state)
|
||||
const [updatingCells, setUpdatingCells] = useState<Set<string>>(new Set());
|
||||
|
|
@ -1828,54 +1945,104 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
)}
|
||||
</div>
|
||||
<div className={styles.filterDropdownOptions}>
|
||||
{/* "All" option to clear filter */}
|
||||
{(() => {
|
||||
const colType = column.type || 'text';
|
||||
const isBool = isCheckboxType(colType as AttributeType);
|
||||
const isDate = isDateTimeType(colType as AttributeType);
|
||||
|
||||
if (isBool) {
|
||||
const currentVal = filters[column.key];
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.filterOption} ${!currentVal ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => clearFilter(column.key)}
|
||||
>
|
||||
({t('formgen.filter.all', 'Alle')})
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.filterOption} ${currentVal === 'true' ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => handleFilter(column.key, 'true')}
|
||||
>
|
||||
{t('formgen.filter.yes', 'Ja')}
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.filterOption} ${currentVal === 'false' ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => handleFilter(column.key, 'false')}
|
||||
>
|
||||
{t('formgen.filter.no', 'Nein')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDate) {
|
||||
const rangeVal = (typeof filters[column.key] === 'object' && filters[column.key]?.value) || {};
|
||||
return (
|
||||
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<div
|
||||
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => clearFilter(column.key)}
|
||||
>
|
||||
({t('formgen.filter.all', 'All')})
|
||||
({t('formgen.filter.all', 'Alle')})
|
||||
</div>
|
||||
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||
{t('formgen.filter.from', 'Von')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={rangeVal.from || ''}
|
||||
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
|
||||
onChange={(e) => {
|
||||
const from = e.target.value;
|
||||
const to = rangeVal.to || '';
|
||||
if (!from && !to) {
|
||||
clearFilter(column.key);
|
||||
} else {
|
||||
handleFilter(column.key, { operator: 'between', value: { from, to } }, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
|
||||
{t('formgen.filter.to', 'Bis')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={rangeVal.to || ''}
|
||||
style={{ width: '100%', padding: '4px 6px', fontSize: '12px', border: '1px solid var(--color-border, #ddd)', borderRadius: '4px' }}
|
||||
onChange={(e) => {
|
||||
const to = e.target.value;
|
||||
const from = rangeVal.from || '';
|
||||
if (!from && !to) {
|
||||
clearFilter(column.key);
|
||||
} else {
|
||||
handleFilter(column.key, { operator: 'between', value: { from, to } }, true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.filterOption} ${!filters[column.key] ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => clearFilter(column.key)}
|
||||
>
|
||||
({t('formgen.filter.all', 'Alle')})
|
||||
</div>
|
||||
{/* Filter values - loaded from backend or static filterOptions */}
|
||||
{filterValuesLoading[column.key] ? (
|
||||
<div className={styles.filterOptionMore} style={{ textAlign: 'center', padding: '8px' }}>
|
||||
{t('formgen.filter.loading', 'Lade Filterwerte...')}
|
||||
</div>
|
||||
) : (() => {
|
||||
const allValues = getUniqueValuesForColumn(column.key);
|
||||
const isExpanded = expandedFilterColumns.has(column.key);
|
||||
const displayLimit = isExpanded ? allValues.length : 100;
|
||||
const visibleValues = allValues.slice(0, displayLimit);
|
||||
const remaining = allValues.length - displayLimit;
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleValues.map(value => (
|
||||
<div
|
||||
key={value}
|
||||
className={`${styles.filterOption} ${filters[column.key] === value ? styles.filterOptionSelected : ''}`}
|
||||
onClick={() => handleFilter(column.key, value)}
|
||||
title={value}
|
||||
>
|
||||
{value.length > 30 ? value.substring(0, 30) + '...' : value}
|
||||
</div>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<div
|
||||
className={styles.filterOptionMore}
|
||||
onClick={() => _toggleFilterExpand(column.key)}
|
||||
style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }}
|
||||
>
|
||||
+ {remaining} {t('formgen.filter.more', 'weitere anzeigen')}
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && allValues.length > 100 && (
|
||||
<div
|
||||
className={styles.filterOptionMore}
|
||||
onClick={() => _toggleFilterExpand(column.key)}
|
||||
style={{ cursor: 'pointer', color: 'var(--primary-blue, #003d7a)' }}
|
||||
>
|
||||
{t('formgen.filter.less', 'Weniger anzeigen')}
|
||||
</div>
|
||||
) : (
|
||||
<FilterValuesList
|
||||
columnKey={column.key}
|
||||
allValues={getUniqueValuesForColumn(column.key)}
|
||||
activeFilter={filters[column.key]}
|
||||
onSelect={(value) => handleFilter(column.key, value)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||
FaFileContract,
|
||||
} from 'react-icons/fa';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -66,8 +67,11 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.admin.user-access-overview': <FaUserShield />,
|
||||
'page.admin.userAccessOverview': <FaUserShield />,
|
||||
'page.admin.billing': <FaMoneyBillAlt />,
|
||||
'page.admin.subscriptions': <FaFileContract />,
|
||||
'page.admin.automationEvents': <FaClock />,
|
||||
'page.admin.automation-events': <FaClock />,
|
||||
'page.admin.automationLogs': <FaClipboardList />,
|
||||
'page.admin.automation-logs': <FaClipboardList />,
|
||||
'page.admin.logs': <FaFileAlt />,
|
||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||
'page.admin.mandateWizard': <FaHatWizard />,
|
||||
|
|
|
|||
84
src/hooks/useAdminSubscriptions.ts
Normal file
84
src/hooks/useAdminSubscriptions.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
|
||||
interface PaginationParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
interface PaginationState {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
const _STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: 'Ausstehend',
|
||||
SCHEDULED: 'Geplant',
|
||||
TRIALING: 'Testphase',
|
||||
ACTIVE: 'Aktiv',
|
||||
PAST_DUE: 'Überfällig',
|
||||
EXPIRED: 'Abgelaufen',
|
||||
};
|
||||
|
||||
export function useAdminSubscriptions() {
|
||||
const [subscriptions, setSubscriptions] = useState<any[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationState | null>(null);
|
||||
const { request, isLoading: loading, error } = useApiRequest();
|
||||
|
||||
const refetch = useCallback(async (params?: PaginationParams) => {
|
||||
try {
|
||||
const requestParams: Record<string, string> = {};
|
||||
|
||||
if (params) {
|
||||
const paginationObj: any = {};
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await request({
|
||||
url: '/api/subscription/admin/all',
|
||||
method: 'get',
|
||||
params: requestParams,
|
||||
});
|
||||
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
setSubscriptions(items.map(_enrichRow));
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} else {
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
setSubscriptions(items.map(_enrichRow));
|
||||
setPagination(null);
|
||||
}
|
||||
} catch {
|
||||
setSubscriptions([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => { refetch(); }, [refetch]);
|
||||
|
||||
return { data: subscriptions, pagination, loading, error, refetch };
|
||||
}
|
||||
|
||||
function _enrichRow(row: any): any {
|
||||
return {
|
||||
...row,
|
||||
_rawStatus: row.status,
|
||||
status: _STATUS_LABELS[row.status] || row.status,
|
||||
};
|
||||
}
|
||||
|
|
@ -472,22 +472,30 @@ export function useAutomationOperations() {
|
|||
export function useAutomationTemplates() {
|
||||
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [pagination, setPagination] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { request } = useApiRequest();
|
||||
const { checkPermission } = usePermissions();
|
||||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
const fetchTemplates = useCallback(async (params?: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchTemplatesApi(request);
|
||||
setTemplates(data);
|
||||
const data = await fetchTemplatesApi(request, params);
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
setTemplates(Array.isArray(data.items) ? data.items : []);
|
||||
if (data.pagination) setPagination(data.pagination);
|
||||
} else {
|
||||
setTemplates(Array.isArray(data) ? data : []);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Error fetching templates:', e);
|
||||
setError(e.message || 'Failed to fetch templates');
|
||||
setTemplates([]);
|
||||
setPagination(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -555,11 +563,12 @@ export function useAutomationTemplates() {
|
|||
|
||||
return {
|
||||
templates,
|
||||
data: templates, // Alias for FormGenerator compatibility
|
||||
data: templates,
|
||||
attributes,
|
||||
loading,
|
||||
error,
|
||||
permissions,
|
||||
pagination,
|
||||
refetch,
|
||||
fetchTemplates,
|
||||
fetchAttributes,
|
||||
|
|
|
|||
133
src/hooks/useConfirm.tsx
Normal file
133
src/hooks/useConfirm.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* useConfirm — application-level confirm dialog replacing native browser confirm().
|
||||
*
|
||||
* Usage:
|
||||
* const { confirm, ConfirmDialog } = useConfirm();
|
||||
* const ok = await confirm('Wirklich löschen?', { confirmLabel: 'Löschen', variant: 'danger' });
|
||||
* // Render <ConfirmDialog /> once in the component tree.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'primary' | 'danger';
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
message: string;
|
||||
options: Required<ConfirmOptions>;
|
||||
resolve: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const _defaults: Required<ConfirmOptions> = {
|
||||
title: 'Bestätigung',
|
||||
confirmLabel: 'Bestätigen',
|
||||
cancelLabel: 'Abbrechen',
|
||||
variant: 'primary',
|
||||
};
|
||||
|
||||
export function useConfirm() {
|
||||
const [state, setState] = useState<ConfirmState | null>(null);
|
||||
const resolveRef = useRef<((v: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((message: string, options?: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setState({
|
||||
message,
|
||||
options: { ..._defaults, ...options },
|
||||
resolve,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const _handleConfirm = useCallback(() => {
|
||||
resolveRef.current?.(true);
|
||||
resolveRef.current = null;
|
||||
setState(null);
|
||||
}, []);
|
||||
|
||||
const _handleCancel = useCallback(() => {
|
||||
resolveRef.current?.(false);
|
||||
resolveRef.current = null;
|
||||
setState(null);
|
||||
}, []);
|
||||
|
||||
const ConfirmDialog: React.FC = useCallback(() => {
|
||||
if (!state) return null;
|
||||
|
||||
const { message, options } = state;
|
||||
const isDanger = options.variant === 'danger';
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={_handleCancel}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 10000,
|
||||
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(2px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--surface-color, #1a1a2e)',
|
||||
border: '1px solid var(--color-border, #333)',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
minWidth: 340, maxWidth: 480,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
||||
display: 'flex', flexDirection: 'column', gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={{
|
||||
margin: 0, fontSize: '1.05rem', fontWeight: 600,
|
||||
color: 'var(--text-primary, #e0e0e0)',
|
||||
}}>
|
||||
{options.title}
|
||||
</h3>
|
||||
|
||||
<p style={{
|
||||
margin: 0, fontSize: '0.9rem', lineHeight: 1.5,
|
||||
color: 'var(--text-secondary, #999)',
|
||||
}}>
|
||||
{message}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.75rem' }}>
|
||||
<button
|
||||
onClick={_handleCancel}
|
||||
style={{
|
||||
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 500,
|
||||
border: '1px solid var(--color-border, #444)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-secondary, #aaa)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{options.cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={_handleConfirm}
|
||||
autoFocus
|
||||
style={{
|
||||
padding: '8px 18px', borderRadius: '6px', fontSize: '0.875rem', fontWeight: 600,
|
||||
border: 'none',
|
||||
background: isDanger ? '#ef4444' : 'var(--color-primary, #3b82f6)',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{options.confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [state, _handleConfirm, _handleCancel]);
|
||||
|
||||
return { confirm, ConfirmDialog };
|
||||
}
|
||||
161
src/hooks/useSubscription.ts
Normal file
161
src/hooks/useSubscription.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* useSubscription Hook — state-machine-aligned subscription management.
|
||||
*
|
||||
* Exposes the operative subscription, any scheduled successor, available plans,
|
||||
* and ID-based mutation functions (activate, cancel, reactivate).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import {
|
||||
fetchSelectablePlans,
|
||||
fetchSubscriptionStatus,
|
||||
activatePlan as activatePlanApi,
|
||||
cancelSubscription as cancelSubscriptionApi,
|
||||
reactivateSubscription as reactivateSubscriptionApi,
|
||||
verifyCheckout as verifyCheckoutApi,
|
||||
type SubscriptionPlan,
|
||||
type MandateSubscription,
|
||||
type SubscriptionStatusResponse,
|
||||
} from '../api/subscriptionApi';
|
||||
|
||||
export interface UseSubscriptionReturn {
|
||||
plans: SubscriptionPlan[];
|
||||
subscription: MandateSubscription | null;
|
||||
plan: SubscriptionPlan | null;
|
||||
scheduled: MandateSubscription | null;
|
||||
active: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
loadPlans: () => Promise<void>;
|
||||
loadStatus: () => Promise<void>;
|
||||
activatePlan: (planKey: string) => Promise<void>;
|
||||
cancelSubscription: (subscriptionId: string) => Promise<void>;
|
||||
reactivateSubscription: (subscriptionId: string) => Promise<void>;
|
||||
verifyCheckout: (sessionId: string) => Promise<{ status: string; message: string }>;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSubscription(mandateId?: string): UseSubscriptionReturn {
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [subscription, setSubscription] = useState<MandateSubscription | null>(null);
|
||||
const [plan, setPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [scheduled, setScheduled] = useState<MandateSubscription | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const { request, isLoading: loading, error: apiError } = useApiRequest();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPlans = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchSelectablePlans(request, mandateId);
|
||||
setPlans(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error('Error loading plans:', err);
|
||||
setPlans([]);
|
||||
}
|
||||
}, [request, mandateId]);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const data: SubscriptionStatusResponse = await fetchSubscriptionStatus(request, mandateId);
|
||||
setActive(data.active);
|
||||
setSubscription(data.subscription ?? null);
|
||||
setPlan(data.plan ?? null);
|
||||
setScheduled(data.scheduled ?? null);
|
||||
} catch (err) {
|
||||
console.error('Error loading subscription status:', err);
|
||||
setActive(false);
|
||||
setSubscription(null);
|
||||
setPlan(null);
|
||||
setScheduled(null);
|
||||
}
|
||||
}, [request, mandateId]);
|
||||
|
||||
const activatePlan = useCallback(async (planKey: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.delete('success');
|
||||
currentUrl.searchParams.delete('canceled');
|
||||
currentUrl.searchParams.delete('session_id');
|
||||
currentUrl.searchParams.set('tab', 'subscription');
|
||||
if (mandateId) currentUrl.searchParams.set('mandate', mandateId);
|
||||
const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`;
|
||||
|
||||
const result = await activatePlanApi(request, planKey, mandateId, returnUrl);
|
||||
if (result?.redirectUrl) {
|
||||
window.location.href = result.redirectUrl;
|
||||
return;
|
||||
}
|
||||
await loadStatus();
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren';
|
||||
setError(msg);
|
||||
throw err;
|
||||
}
|
||||
}, [request, mandateId, loadStatus]);
|
||||
|
||||
const cancelSub = useCallback(async (subscriptionId: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
await cancelSubscriptionApi(request, subscriptionId, mandateId);
|
||||
await loadStatus();
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Kündigen';
|
||||
setError(msg);
|
||||
throw err;
|
||||
}
|
||||
}, [request, mandateId, loadStatus]);
|
||||
|
||||
const reactivateSub = useCallback(async (subscriptionId: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
await reactivateSubscriptionApi(request, subscriptionId, mandateId);
|
||||
await loadStatus();
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren';
|
||||
setError(msg);
|
||||
throw err;
|
||||
}
|
||||
}, [request, mandateId, loadStatus]);
|
||||
|
||||
const verifyCheckout = useCallback(async (sessionId: string) => {
|
||||
const result = await verifyCheckoutApi(request, sessionId, mandateId);
|
||||
await loadStatus();
|
||||
return result;
|
||||
}, [request, mandateId, loadStatus]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
await Promise.all([loadPlans(), loadStatus()]);
|
||||
}, [loadPlans, loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mandateId) {
|
||||
loadPlans();
|
||||
loadStatus();
|
||||
} else {
|
||||
setPlans([]);
|
||||
setSubscription(null);
|
||||
setPlan(null);
|
||||
setScheduled(null);
|
||||
setActive(false);
|
||||
}
|
||||
}, [mandateId]);
|
||||
|
||||
return {
|
||||
plans,
|
||||
subscription,
|
||||
plan,
|
||||
scheduled,
|
||||
active,
|
||||
loading,
|
||||
error: error || (apiError ? String(apiError) : null),
|
||||
loadPlans,
|
||||
loadStatus,
|
||||
activatePlan,
|
||||
cancelSubscription: cancelSub,
|
||||
reactivateSubscription: reactivateSub,
|
||||
verifyCheckout,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
|
|||
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||
|
||||
// Automation Views
|
||||
import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation';
|
||||
import { AutomationDefinitionsView, AutomationTemplatesView } from './views/automation';
|
||||
|
||||
// Automation2 Views
|
||||
import { Automation2Page } from './views/automation2/Automation2Page';
|
||||
|
|
@ -40,6 +40,7 @@ import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2Wo
|
|||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||
import { WorkspaceSettingsPage } from './views/workspace/WorkspaceSettingsPage';
|
||||
import { WorkspaceRagInsightsPage } from './views/workspace/WorkspaceRagInsightsPage';
|
||||
|
||||
// Teamsbot Views
|
||||
import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||
|
|
@ -132,7 +133,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
automation: {
|
||||
definitions: AutomationDefinitionsView,
|
||||
templates: AutomationTemplatesView,
|
||||
logs: AutomationLogsView,
|
||||
},
|
||||
automation2: {
|
||||
editor: Automation2Page,
|
||||
|
|
@ -142,6 +142,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
workspace: {
|
||||
dashboard: WorkspacePage,
|
||||
editor: WorkspaceEditorPage,
|
||||
'rag-insights': WorkspaceRagInsightsPage,
|
||||
settings: WorkspaceSettingsPage,
|
||||
},
|
||||
teamsbot: {
|
||||
|
|
@ -214,7 +215,7 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
|||
|
||||
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
|
||||
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
|
||||
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor') {
|
||||
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,18 +41,37 @@ const _formatNextRun = (nextRunTime: string | null): string => {
|
|||
|
||||
export const AdminAutomationEventsPage: React.FC = () => {
|
||||
const [events, setEvents] = useState<AutomationEvent[]>([]);
|
||||
const [pagination, setPagination] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<string | null>(null);
|
||||
|
||||
const _fetchEvents = useCallback(async () => {
|
||||
const _fetchEvents = useCallback(async (params?: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await api.get('/api/admin/automation-events');
|
||||
// Map eventId to id for FormGeneratorTable compatibility
|
||||
setEvents(response.data.map((e: any) => ({ ...e, id: e.eventId })));
|
||||
const requestParams: Record<string, string> = {};
|
||||
if (params && typeof params === 'object') {
|
||||
const paginationObj: any = {};
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
const response = await api.get('/api/admin/automation-events', { params: requestParams });
|
||||
const data = response.data;
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
setEvents((data.items || []).map((e: any) => ({ ...e, id: e.eventId })));
|
||||
if (data.pagination) setPagination(data.pagination);
|
||||
} else {
|
||||
setEvents((Array.isArray(data) ? data : []).map((e: any) => ({ ...e, id: e.eventId })));
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Fehler beim Laden der Events');
|
||||
} finally {
|
||||
|
|
@ -196,6 +215,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
|
|||
<FormGeneratorTable
|
||||
data={events}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/admin/automation-events"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
|
|
@ -212,6 +232,7 @@ export const AdminAutomationEventsPage: React.FC = () => {
|
|||
hookData={{
|
||||
handleDelete: _handleDelete,
|
||||
refetch: _fetchEvents,
|
||||
pagination,
|
||||
}}
|
||||
emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren."
|
||||
/>
|
||||
|
|
|
|||
223
src/pages/admin/AdminAutomationLogsPage.tsx
Normal file
223
src/pages/admin/AdminAutomationLogsPage.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* AdminAutomationLogsPage
|
||||
*
|
||||
* SysAdmin-only page for viewing consolidated automation execution logs
|
||||
* across all mandates and feature instances.
|
||||
* Uses FormGeneratorTable with backend-driven pagination.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { FaSync, FaCheck, FaExclamationCircle, FaTimes } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
|
||||
interface AutomationLogEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
automationId: string;
|
||||
automationLabel: string;
|
||||
mandateName: string;
|
||||
featureInstanceName: string;
|
||||
executedBy: string;
|
||||
status: string;
|
||||
workflowId: string;
|
||||
messages: string;
|
||||
}
|
||||
|
||||
const _formatTimestamp = (ts: unknown): React.ReactNode => {
|
||||
if (!ts || typeof ts !== 'number') return <span style={{ color: 'var(--text-tertiary, #999)' }}>–</span>;
|
||||
return new Date(ts * 1000).toLocaleString('de-CH', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const _formatStatus = (value: unknown): React.ReactNode => {
|
||||
const status = String(value || '');
|
||||
const map: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
|
||||
completed: { icon: <FaCheck style={{ marginRight: 4 }} />, color: 'var(--success-color, #16a34a)', label: 'Abgeschlossen' },
|
||||
error: { icon: <FaExclamationCircle style={{ marginRight: 4 }} />, color: 'var(--error-color, #dc2626)', label: 'Fehler' },
|
||||
failed: { icon: <FaExclamationCircle style={{ marginRight: 4 }} />, color: 'var(--error-color, #dc2626)', label: 'Fehlgeschlagen' },
|
||||
stopped: { icon: <FaTimes style={{ marginRight: 4 }} />, color: 'var(--warning-color, #d97706)', label: 'Gestoppt' },
|
||||
};
|
||||
const entry = map[status];
|
||||
if (!entry) return status || '–';
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', color: entry.color, fontWeight: 500 }}>
|
||||
{entry.icon}{entry.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminAutomationLogsPage: React.FC = () => {
|
||||
const [logs, setLogs] = useState<AutomationLogEntry[]>([]);
|
||||
const [pagination, setPagination] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const _fetchLogs = useCallback(async (params?: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const requestParams: Record<string, string> = {};
|
||||
if (params && typeof params === 'object') {
|
||||
const paginationObj: any = {};
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
const response = await api.get('/api/admin/automation-logs', { params: requestParams });
|
||||
const data = response.data;
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
setLogs(data.items || []);
|
||||
if (data.pagination) setPagination(data.pagination);
|
||||
} else {
|
||||
setLogs(Array.isArray(data) ? data : []);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Fehler beim Laden der Ausführungsprotokolle');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { _fetchLogs(); }, [_fetchLogs]);
|
||||
|
||||
const columns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'timestamp',
|
||||
label: 'Zeitpunkt',
|
||||
type: 'number' as const,
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
width: 170,
|
||||
minWidth: 140,
|
||||
formatter: _formatTimestamp,
|
||||
},
|
||||
{
|
||||
key: 'automationLabel',
|
||||
label: 'Automatisierung',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
minWidth: 130,
|
||||
},
|
||||
{
|
||||
key: 'mandateName',
|
||||
label: 'Mandant',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 150,
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
key: 'featureInstanceName',
|
||||
label: 'Feature-Instanz',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 150,
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
key: 'executedBy',
|
||||
label: 'Ausgeführt von',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 140,
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 140,
|
||||
minWidth: 100,
|
||||
formatter: _formatStatus,
|
||||
},
|
||||
{
|
||||
key: 'workflowId',
|
||||
label: 'Workflow-ID',
|
||||
type: 'string' as const,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
width: 120,
|
||||
minWidth: 80,
|
||||
formatter: (v: unknown) =>
|
||||
v ? <code style={{ fontSize: '0.8em', color: 'var(--text-secondary)' }}>{String(v).slice(0, 8)}…</code> : '–',
|
||||
},
|
||||
{
|
||||
key: 'messages',
|
||||
label: 'Meldungen',
|
||||
type: 'string' as const,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: true,
|
||||
width: 300,
|
||||
minWidth: 150,
|
||||
maxWidth: 500,
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Ausführungsprotokolle</h1>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Konsolidierte Automation-Logs über alle Mandanten
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => _fetchLogs()}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormGeneratorTable
|
||||
data={logs}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/admin/automation-logs"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
hookData={{
|
||||
refetch: _fetchLogs,
|
||||
pagination,
|
||||
}}
|
||||
emptyMessage="Keine Ausführungsprotokolle vorhanden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminAutomationLogsPage;
|
||||
|
|
@ -73,8 +73,10 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
|
||||
}, []);
|
||||
|
||||
const [pagination, setPagination] = useState<any>(null);
|
||||
|
||||
// Load roles when feature changes
|
||||
const fetchRoles = useCallback(async () => {
|
||||
const fetchRoles = useCallback(async (params?: any) => {
|
||||
if (!selectedFeatureCode) {
|
||||
setRoles([]);
|
||||
return;
|
||||
|
|
@ -83,15 +85,32 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get(`/api/features/templates/roles`, {
|
||||
params: { featureCode: selectedFeatureCode }
|
||||
});
|
||||
const roleList = response.data || [];
|
||||
setRoles(Array.isArray(roleList) ? roleList : []);
|
||||
const requestParams: Record<string, string> = { featureCode: selectedFeatureCode };
|
||||
if (params && typeof params === 'object') {
|
||||
const paginationObj: any = {};
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
const response = await api.get(`/api/features/templates/roles`, { params: requestParams });
|
||||
const data = response.data;
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
setRoles(Array.isArray(data.items) ? data.items : []);
|
||||
if (data.pagination) setPagination(data.pagination);
|
||||
} else {
|
||||
setRoles(Array.isArray(data) ? data : []);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error loading feature roles:', err);
|
||||
setError('Fehler beim Laden der Feature-Rollen');
|
||||
setRoles([]);
|
||||
setPagination(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -383,6 +402,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
onDelete={handleDeleteRole}
|
||||
hookData={{
|
||||
refetch: fetchRoles,
|
||||
pagination,
|
||||
handleDelete: handleDeleteRole,
|
||||
}}
|
||||
emptyMessage="Keine Feature-Rollen gefunden"
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
]}
|
||||
hookData={{
|
||||
handleDelete: handleDeleteInvitation,
|
||||
refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
||||
refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
||||
pagination,
|
||||
}}
|
||||
emptyMessage="Keine Einladungen gefunden"
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
* User × Role matrix with inline toggles and edit/remove actions.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { FaEdit, FaTrash } from 'react-icons/fa';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
|
||||
import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess';
|
||||
import styles from './Admin.module.css';
|
||||
|
|
@ -29,15 +30,20 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
|
|||
disabled = false,
|
||||
}) => {
|
||||
const [removingId, setRemovingId] = useState<string | null>(null);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const handleRemove = (user: FeatureAccessUser) => {
|
||||
const handleRemove = useCallback(async (user: FeatureAccessUser) => {
|
||||
if (removingId) return;
|
||||
if (window.confirm(`"${user.username}" aus dieser Instanz entfernen?`)) {
|
||||
const ok = await confirm(`"${user.username}" aus dieser Instanz entfernen?`, {
|
||||
title: 'Benutzer entfernen',
|
||||
confirmLabel: 'Entfernen',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (!ok) return;
|
||||
setRemovingId(user.userId);
|
||||
onRemoveUser(user);
|
||||
setRemovingId(null);
|
||||
}
|
||||
};
|
||||
}, [removingId, confirm, onRemoveUser]);
|
||||
|
||||
if (roles.length === 0) {
|
||||
return (
|
||||
|
|
@ -135,6 +141,7 @@ export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
|
|||
+ Benutzer hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
|
|||
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
|
||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';
|
||||
export { AdminAutomationLogsPage } from './AdminAutomationLogsPage';
|
||||
export { AdminLogsPage } from './AdminLogsPage';
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const FilesPage: React.FC = () => {
|
|||
loading,
|
||||
error,
|
||||
refetch,
|
||||
pagination,
|
||||
fetchFileById,
|
||||
updateFileOptimistically,
|
||||
} = useUserFiles();
|
||||
|
|
@ -479,6 +480,7 @@ export const FilesPage: React.FC = () => {
|
|||
onDeleteMultiple={handleDeleteMultiple}
|
||||
hookData={{
|
||||
refetch: _tableRefetch,
|
||||
pagination,
|
||||
permissions,
|
||||
handleDelete: handleFileDelete,
|
||||
handleInlineUpdate,
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export const PromptsPage: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Prompts</h1>
|
||||
|
|
@ -194,28 +194,6 @@ export const PromptsPage: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!prompts || prompts.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Prompts...</span>
|
||||
</div>
|
||||
) : !prompts || prompts.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaFileAlt className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Prompts vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Erstellen Sie einen neuen Prompt, um loszulegen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<FaPlus /> Ersten Prompt erstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={prompts}
|
||||
columns={columns}
|
||||
|
|
@ -255,7 +233,6 @@ export const PromptsPage: React.FC = () => {
|
|||
}}
|
||||
emptyMessage="Keine Prompts gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
|
|
|
|||
78
src/pages/billing/AdminSubscriptionsPage.tsx
Normal file
78
src/pages/billing/AdminSubscriptionsPage.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { useAdminSubscriptions } from '../../hooks/useAdminSubscriptions';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import api from '../../api';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
const _TERMINAL_STATUSES = new Set(['EXPIRED']);
|
||||
|
||||
const _COLUMNS: ColumnConfig[] = [
|
||||
{ key: 'mandateName', label: 'Mandant', type: 'text', sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'planTitle', label: 'Plan', type: 'text', sortable: true, filterable: true, width: 180 },
|
||||
{ key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 110 },
|
||||
{ key: 'recurring', label: 'Wiederkehrend', type: 'boolean', sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'activeUsers', label: 'User', type: 'number', sortable: true, width: 70 },
|
||||
{ key: 'activeInstances', label: 'Instanzen', type: 'number', sortable: true, width: 90 },
|
||||
{ key: 'monthlyRevenueCHF', label: 'Revenue/Mt (CHF)', type: 'number', sortable: true, width: 140 },
|
||||
{ key: 'startedAt', label: 'Gestartet', type: 'date', sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'currentPeriodEnd', label: 'Periodenende', type: 'date', sortable: true, filterable: true, width: 130 },
|
||||
{ key: 'snapshotPricePerUserCHF', label: 'Preis/User', type: 'number', sortable: true, width: 100 },
|
||||
{ key: 'snapshotPricePerInstanceCHF', label: 'Preis/Instanz', type: 'number', sortable: true, width: 110 },
|
||||
];
|
||||
|
||||
const AdminSubscriptionsPage: React.FC = () => {
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const { data: subscriptions, pagination, loading, refetch } = useAdminSubscriptions();
|
||||
|
||||
const _handleForceCancel = useCallback(async (row: any) => {
|
||||
const ok = await confirm(
|
||||
`Subscription «${row.planTitle}» für Mandant «${row.mandateName}» sofort kündigen? Dies wird auch auf Stripe sofort storniert.`,
|
||||
{ confirmLabel: 'Sofort kündigen', cancelLabel: 'Abbrechen', variant: 'danger' },
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await api.post('/api/subscription/force-cancel', { subscriptionId: row.id });
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
console.error('Force cancel failed:', err);
|
||||
}
|
||||
}, [confirm, refetch]);
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard} style={{ minHeight: 0 }}>
|
||||
<header className={styles.pageHeader} style={{ flexShrink: 0 }}>
|
||||
<h1>Subscription-Übersicht</h1>
|
||||
<p className={styles.subtitle}>Alle Abonnements aller Mandanten</p>
|
||||
</header>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
<FormGeneratorTable
|
||||
data={subscriptions}
|
||||
columns={_COLUMNS}
|
||||
apiEndpoint="/api/subscription/admin/all"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={50}
|
||||
selectable={false}
|
||||
hookData={{ refetch, pagination }}
|
||||
customActions={[
|
||||
{
|
||||
id: 'forceCancel',
|
||||
title: 'Sofort kündigen',
|
||||
icon: '✕',
|
||||
onClick: (row: any) => _handleForceCancel(row),
|
||||
visible: (row: any) => !_TERMINAL_STATUSES.has(row._rawStatus),
|
||||
},
|
||||
]}
|
||||
emptyMessage="Keine Subscriptions vorhanden."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSubscriptionsPage;
|
||||
|
|
@ -8,15 +8,20 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useBillingAdmin, type BillingSettings, type AccountSummary, type MandateUserSummary } from '../../hooks/useBilling';
|
||||
import type { CheckoutCreateRequest } from '../../api/billingApi';
|
||||
import { useUserMandates, type Mandate as UserMandateRow } from '../../hooks/useUserMandates';
|
||||
import { useCurrentUser } from '../../hooks/useUsers';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { SubscriptionTab } from './SubscriptionTab';
|
||||
import api from '../../api';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
type AdminTabType = 'settings' | 'credit' | 'subscription' | 'transactions';
|
||||
|
||||
const _formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
|
|
@ -206,7 +211,7 @@ interface CreditAdderProps {
|
|||
const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, onAddCredit }) => {
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [amount, setAmount] = useState<string>('');
|
||||
const [description, setDescription] = useState<string>('Manuelles Aufladen durch Admin');
|
||||
const [description, setDescription] = useState<string>('Manuelle Buchung durch Admin');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
|
|
@ -222,8 +227,8 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
const _handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const numAmount = parseFloat(amount);
|
||||
if (!numAmount || numAmount <= 0) {
|
||||
setMessage({ type: 'error', text: 'Betrag muss positiv sein' });
|
||||
if (!numAmount || numAmount === 0) {
|
||||
setMessage({ type: 'error', text: 'Betrag darf nicht null sein' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -232,10 +237,13 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
|
||||
try {
|
||||
await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description);
|
||||
setMessage({ type: 'success', text: `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` });
|
||||
const label = numAmount > 0
|
||||
? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.`
|
||||
: `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`;
|
||||
setMessage({ type: 'success', text: label });
|
||||
setAmount('');
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.message || 'Fehler beim Aufladen' });
|
||||
setMessage({ type: 'error', text: err.message || 'Fehler bei der Buchung' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -243,7 +251,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
|
||||
return (
|
||||
<div className={styles.adminSection}>
|
||||
<h3>Guthaben manuell aufladen</h3>
|
||||
<h3>Guthaben manuell verwalten</h3>
|
||||
|
||||
{message && (
|
||||
<div className={message.type === 'success' ? styles.successMessage : styles.errorMessage}>
|
||||
|
|
@ -285,8 +293,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
className={styles.input}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="z.B. 50"
|
||||
min="0.01"
|
||||
placeholder="z.B. 50 oder -20"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
|
|
@ -308,7 +315,7 @@ const CreditAdder: React.FC<CreditAdderProps> = ({ settings, accounts, users, on
|
|||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||
disabled={saving || (isPrepayUser && !selectedUserId) || !amount}
|
||||
>
|
||||
{saving ? 'Wird gutgeschrieben...' : 'Manuell aufladen'}
|
||||
{saving ? 'Wird verbucht...' : (parseFloat(amount) < 0 ? 'Guthaben abziehen' : 'Guthaben aufladen')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -456,6 +463,94 @@ const MandateStripeTopUp: React.FC<MandateStripeTopUpProps> = ({ mandateId, crea
|
|||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MANDATE TRANSACTIONS TAB (FormGeneratorTable with filters, search, export)
|
||||
// ============================================================================
|
||||
|
||||
const _mandateTxColumns: ColumnConfig[] = [
|
||||
{ key: 'createdAt', label: 'Datum', type: 'timestamp' as any, sortable: true, width: 160 },
|
||||
{ key: 'userName', label: 'Benutzer', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'transactionType', label: 'Typ', type: 'text' as any, sortable: true, filterable: true, width: 100 },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'text' as any, searchable: true, width: 250 },
|
||||
{ key: 'aicoreProvider', label: 'Anbieter', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'aicoreModel', label: 'Modell', type: 'text' as any, sortable: true, filterable: true, width: 150 },
|
||||
{ key: 'featureCode', label: 'Feature', type: 'text' as any, sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'amount', label: 'Betrag (CHF)', type: 'number' as any, sortable: true, width: 120 },
|
||||
];
|
||||
|
||||
interface MandateTransactionsTabProps {
|
||||
mandateId: string;
|
||||
}
|
||||
|
||||
const MandateTransactionsTab: React.FC<MandateTransactionsTabProps> = ({ mandateId }) => {
|
||||
const { request, isLoading: loading } = useApiRequest();
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [pagination, setPagination] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const _loadTransactions = useCallback(async (params?: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
const requestParams: Record<string, string> = {};
|
||||
if (params) {
|
||||
const paginationObj: any = {};
|
||||
if (params.page !== undefined) paginationObj.page = params.page;
|
||||
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params.sort) paginationObj.sort = params.sort;
|
||||
if (params.filters) paginationObj.filters = params.filters;
|
||||
if (params.search) paginationObj.search = params.search;
|
||||
if (Object.keys(paginationObj).length > 0) {
|
||||
requestParams.pagination = JSON.stringify(paginationObj);
|
||||
}
|
||||
}
|
||||
const data = await request({
|
||||
url: `/api/billing/admin/transactions/${mandateId}`,
|
||||
method: 'get',
|
||||
params: requestParams,
|
||||
});
|
||||
if (data && typeof data === 'object' && 'items' in data) {
|
||||
setTransactions(Array.isArray(data.items) ? data.items : []);
|
||||
if (data.pagination) setPagination(data.pagination);
|
||||
} else {
|
||||
setTransactions(Array.isArray(data) ? data : []);
|
||||
setPagination(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.detail || err.message || 'Fehler beim Laden');
|
||||
setTransactions([]);
|
||||
setPagination(null);
|
||||
}
|
||||
}, [request, mandateId]);
|
||||
|
||||
useEffect(() => {
|
||||
_loadTransactions();
|
||||
}, [_loadTransactions]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: '400px' }}>
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 1rem 0' }}>
|
||||
AI-Verbrauch und Guthaben-Transaktionen. Subscription-Gebühren werden separat über Stripe abgerechnet.
|
||||
</p>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
<FormGeneratorTable
|
||||
data={transactions}
|
||||
columns={_mandateTxColumns}
|
||||
apiEndpoint={`/api/billing/admin/transactions/${mandateId}`}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
emptyMessage="Keine Transaktionen für diesen Mandanten"
|
||||
onRefresh={() => _loadTransactions()}
|
||||
hookData={{ refetch: _loadTransactions, pagination }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
|
@ -465,7 +560,9 @@ export const BillingAdmin: React.FC = () => {
|
|||
const { user: currentUser } = useCurrentUser();
|
||||
const isSysAdmin = currentUser?.isSysAdmin === true;
|
||||
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(null);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string | null>(
|
||||
searchParams.get('mandate') || null
|
||||
);
|
||||
const [mandateList, setMandateList] = useState<UserMandateRow[]>([]);
|
||||
const [mandatesLoading, setMandatesLoading] = useState(true);
|
||||
|
||||
|
|
@ -530,7 +627,14 @@ export const BillingAdmin: React.FC = () => {
|
|||
const canceledParam = searchParams.get('canceled');
|
||||
const sessionIdParam = searchParams.get('session_id');
|
||||
|
||||
const _initialAdminTab = (searchParams.get('tab') as AdminTabType) || 'settings';
|
||||
const [adminTab, setAdminTab] = useState<AdminTabType>(
|
||||
['settings', 'credit', 'subscription', 'transactions'].includes(_initialAdminTab) ? _initialAdminTab : 'settings'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (adminTab === 'subscription' || searchParams.get('tab') === 'subscription') return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const _confirmCheckoutIfNeeded = async () => {
|
||||
|
|
@ -580,34 +684,42 @@ export const BillingAdmin: React.FC = () => {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
||||
}, [adminTab, successParam, canceledParam, sessionIdParam, selectedMandateId, loadAccounts]);
|
||||
|
||||
const _clearStripeParams = useCallback(() => {
|
||||
searchParams.delete('success');
|
||||
searchParams.delete('canceled');
|
||||
searchParams.delete('session_id');
|
||||
searchParams.delete('mandate');
|
||||
setSearchParams(searchParams, { replace: true });
|
||||
setStripeReturnMessage(null);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const showStripeForMandateAdmin = !isSysAdmin && !!selectedMandateId && !!settings;
|
||||
const showStripeForMandateAdmin = !!selectedMandateId && !!settings;
|
||||
|
||||
const _tabStyle = (isActive: boolean) => ({
|
||||
padding: '8px 16px',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: isActive ? 'var(--color-primary, #3b82f6)' : 'transparent',
|
||||
color: isActive ? 'white' : 'var(--color-text, #e0e0e0)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard}>
|
||||
<header className={styles.pageHeader}>
|
||||
<h1>Billing Administration</h1>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h1>Billing-Verwaltung</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{isSysAdmin
|
||||
? 'Verwaltung von Abrechnungseinstellungen und Guthaben'
|
||||
: 'Guthaben und Konten für Ihre Mandanten'}
|
||||
Abrechnungseinstellungen, Guthaben und Abonnement pro Mandant
|
||||
</p>
|
||||
{isSysAdmin && (
|
||||
<p style={{ marginTop: '8px' }}>
|
||||
<Link to="/admin/billing/mandates" style={{ color: 'var(--color-primary)' }}>
|
||||
Mandanten-Übersicht (Balances & Transaktionen)
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{stripeReturnMessage && (
|
||||
|
|
@ -635,9 +747,30 @@ export const BillingAdmin: React.FC = () => {
|
|||
/>
|
||||
</section>
|
||||
|
||||
{selectedMandateId && (
|
||||
{selectedMandateId ? (
|
||||
<>
|
||||
{isSysAdmin && (
|
||||
<nav style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginBottom: '24px',
|
||||
borderBottom: '1px solid var(--color-border, #333)',
|
||||
paddingBottom: '8px',
|
||||
}}>
|
||||
<button onClick={() => setAdminTab('settings')} style={_tabStyle(adminTab === 'settings')}>
|
||||
Einstellungen
|
||||
</button>
|
||||
<button onClick={() => setAdminTab('credit')} style={_tabStyle(adminTab === 'credit')}>
|
||||
Guthaben
|
||||
</button>
|
||||
<button onClick={() => setAdminTab('subscription')} style={_tabStyle(adminTab === 'subscription')}>
|
||||
Abonnement
|
||||
</button>
|
||||
<button onClick={() => setAdminTab('transactions')} style={_tabStyle(adminTab === 'transactions')}>
|
||||
Transaktionen
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{adminTab === 'settings' && (
|
||||
<SettingsEditor
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
|
|
@ -645,6 +778,8 @@ export const BillingAdmin: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{adminTab === 'credit' && (
|
||||
<>
|
||||
{isSysAdmin && (
|
||||
<CreditAdder
|
||||
settings={settings}
|
||||
|
|
@ -662,7 +797,15 @@ export const BillingAdmin: React.FC = () => {
|
|||
</>
|
||||
)}
|
||||
|
||||
{!selectedMandateId && (
|
||||
{adminTab === 'subscription' && (
|
||||
<SubscriptionTab mandateId={selectedMandateId} />
|
||||
)}
|
||||
|
||||
{adminTab === 'transactions' && (
|
||||
<MandateTransactionsTab mandateId={selectedMandateId} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.noData}>Bitte wählen Sie einen Mandanten aus.</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -489,10 +489,16 @@ export const BillingDataView: React.FC = () => {
|
|||
setTransactionsError(null);
|
||||
|
||||
const params: any = {};
|
||||
// Only serialize if it's a plain pagination object (not a React event or other non-serializable object)
|
||||
if (paginationParams && typeof paginationParams === 'object' && 'page' in paginationParams) {
|
||||
const { page, pageSize, sortBy, sortDirection, search, filters } = paginationParams;
|
||||
params.pagination = JSON.stringify({ page, pageSize, sortBy, sortDirection, search, filters });
|
||||
const pObj: any = {};
|
||||
if (paginationParams.page !== undefined) pObj.page = paginationParams.page;
|
||||
if (paginationParams.pageSize !== undefined) pObj.pageSize = paginationParams.pageSize;
|
||||
if (paginationParams.sort) pObj.sort = paginationParams.sort;
|
||||
if (paginationParams.filters) pObj.filters = paginationParams.filters;
|
||||
if (paginationParams.search) pObj.search = paginationParams.search;
|
||||
if (Object.keys(pObj).length > 0) {
|
||||
params.pagination = JSON.stringify(pObj);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await api.get('/api/billing/view/users/transactions', { params });
|
||||
|
|
@ -526,10 +532,7 @@ export const BillingDataView: React.FC = () => {
|
|||
// hookData for FormGeneratorTable
|
||||
const transactionsHookData = useMemo(() => ({
|
||||
refetch: _loadTransactions,
|
||||
pagination: transactionsPagination ? {
|
||||
totalPages: transactionsPagination.totalPages,
|
||||
totalItems: transactionsPagination.totalItems,
|
||||
} : undefined,
|
||||
pagination: transactionsPagination || undefined,
|
||||
}), [_loadTransactions, transactionsPagination]);
|
||||
|
||||
// Table column definitions
|
||||
|
|
@ -741,6 +744,7 @@ export const BillingDataView: React.FC = () => {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -175,7 +175,11 @@ const TransactionTable: React.FC<TransactionTableProps> = ({ transactions }) =>
|
|||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export const BillingMandateView: React.FC = () => {
|
||||
interface BillingMandateViewProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export const BillingMandateView: React.FC<BillingMandateViewProps> = ({ embedded = false }) => {
|
||||
const { request, isLoading: loading } = useApiRequest();
|
||||
const [balances, setBalances] = useState<MandateBalance[]>([]);
|
||||
const [transactions, setTransactions] = useState<BillingTransaction[]>([]);
|
||||
|
|
@ -212,13 +216,17 @@ export const BillingMandateView: React.FC = () => {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={styles.billingDashboard}>
|
||||
<div className={embedded ? '' : styles.billingDashboard}>
|
||||
{!embedded && (
|
||||
<>
|
||||
<header className={styles.pageHeader}>
|
||||
<h1>Mandanten-Billing</h1>
|
||||
<p className={styles.subtitle}>Guthaben und Transaktionen pro Mandant</p>
|
||||
</header>
|
||||
|
||||
<BillingNav />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mandate Balances */}
|
||||
<section className={styles.section}>
|
||||
|
|
|
|||
481
src/pages/billing/SubscriptionTab.tsx
Normal file
481
src/pages/billing/SubscriptionTab.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
/**
|
||||
* SubscriptionTab — State-machine-aligned subscription management UI.
|
||||
*
|
||||
* Shows:
|
||||
* - Current operative subscription with status, recurring flag, and period info
|
||||
* - Scheduled successor (if plan switch in progress)
|
||||
* - Available plans as cards
|
||||
* - ID-based actions: cancel, reactivate, activate
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
import type { SubscriptionPlan, MandateSubscription } from '../../api/subscriptionApi';
|
||||
import styles from './Billing.module.css';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const _lang = (): string =>
|
||||
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en';
|
||||
|
||||
const _t = (dict: Record<string, string> | undefined): string => {
|
||||
if (!dict) return '';
|
||||
const l = _lang();
|
||||
return dict[l] || dict['en'] || dict['de'] || Object.values(dict)[0] || '';
|
||||
};
|
||||
|
||||
const _formatCurrency = (amount: number) =>
|
||||
new Intl.NumberFormat('de-CH', { style: 'currency', currency: 'CHF' }).format(amount);
|
||||
|
||||
const _formatDate = (iso: string | null | undefined): string => {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
const _statusLabel: Record<string, { label: string; color: string }> = {
|
||||
PENDING: { label: 'Zahlung ausstehend', color: '#f59e0b' },
|
||||
SCHEDULED: { label: 'Geplant', color: '#8b5cf6' },
|
||||
ACTIVE: { label: 'Aktiv', color: '#22c55e' },
|
||||
TRIALING: { label: 'Testphase', color: '#3b82f6' },
|
||||
PAST_DUE: { label: 'Zahlung ausstehend', color: '#f59e0b' },
|
||||
EXPIRED: { label: 'Abgelaufen', color: '#6b7280' },
|
||||
};
|
||||
|
||||
const _periodLabel: Record<string, string> = {
|
||||
MONTHLY: 'Monatlich',
|
||||
YEARLY: 'Jährlich',
|
||||
NONE: '—',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Plan Card
|
||||
// ============================================================================
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: SubscriptionPlan;
|
||||
isCurrent: boolean;
|
||||
onActivate: (planKey: string) => void;
|
||||
activatingPlanKey: string | null;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrent, onActivate, activatingPlanKey }) => {
|
||||
const activating = activatingPlanKey === plan.planKey;
|
||||
const isFreePlan = plan.pricePerUserCHF === 0 && plan.pricePerFeatureInstanceCHF === 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
border: isCurrent ? '2px solid var(--color-primary, #3b82f6)' : '1px solid var(--color-border, #333)',
|
||||
borderRadius: '8px',
|
||||
padding: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
background: isCurrent ? 'rgba(59,130,246,0.06)' : 'var(--color-surface, #1a1a2e)',
|
||||
minWidth: 220,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong style={{ fontSize: '1rem' }}>{_t(plan.title)}</strong>
|
||||
{isCurrent && (
|
||||
<span style={{
|
||||
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
||||
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
|
||||
}}>Aktuell</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: 0 }}>
|
||||
{_t(plan.description)}
|
||||
</p>
|
||||
|
||||
{!isFreePlan && (
|
||||
<div style={{ fontSize: '0.85rem' }}>
|
||||
<div>User: <strong>{_formatCurrency(plan.pricePerUserCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||
<div>Instanz: <strong>{_formatCurrency(plan.pricePerFeatureInstanceCHF)}</strong> / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFreePlan && plan.trialDays && (
|
||||
<div style={{ fontSize: '0.85rem' }}>
|
||||
{plan.trialDays} Tage kostenlos
|
||||
{plan.maxUsers && <> · max. {plan.maxUsers} User</>}
|
||||
{plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={() => onActivate(plan.planKey)}
|
||||
disabled={!!activatingPlanKey}
|
||||
style={{
|
||||
marginTop: 'auto', padding: '8px 16px', borderRadius: '6px', border: 'none',
|
||||
background: 'var(--color-primary, #3b82f6)', color: '#fff', fontWeight: 600,
|
||||
cursor: activatingPlanKey ? 'wait' : 'pointer',
|
||||
opacity: activatingPlanKey ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{activating
|
||||
? 'Weiterleitung...'
|
||||
: (!isFreePlan && !plan.trialDays) ? 'Kostenpflichtig abonnieren' : 'Auswählen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Subscription Info Card
|
||||
// ============================================================================
|
||||
|
||||
interface SubInfoProps {
|
||||
sub: MandateSubscription;
|
||||
plan: SubscriptionPlan | null;
|
||||
label: string;
|
||||
onCancel?: (id: string) => void;
|
||||
onReactivate?: (id: string) => void;
|
||||
cancelling: boolean;
|
||||
reactivating: boolean;
|
||||
justPaid?: boolean;
|
||||
}
|
||||
|
||||
const SubInfoCard: React.FC<SubInfoProps> = ({ sub, plan, label, onCancel, onReactivate, cancelling, reactivating, justPaid }) => {
|
||||
const statusInfo = _statusLabel[sub.status] || _statusLabel.EXPIRED;
|
||||
const isActive = sub.status === 'ACTIVE';
|
||||
const isPending = sub.status === 'PENDING';
|
||||
const isScheduled = sub.status === 'SCHEDULED';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--color-border, #333)',
|
||||
borderRadius: '8px',
|
||||
padding: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
}}>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #888)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong style={{ fontSize: '1.1rem' }}>{plan ? _t(plan.title) : sub.planKey}</strong>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
{isActive && !sub.recurring && (
|
||||
<span style={{
|
||||
fontSize: '0.7rem', padding: '2px 8px', borderRadius: '4px',
|
||||
background: '#ef4444', color: '#fff', fontWeight: 600,
|
||||
}}>Gekündigt</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: '0.75rem', padding: '2px 10px', borderRadius: '4px',
|
||||
background: statusInfo.color, color: '#fff', fontWeight: 600,
|
||||
}}>{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<div style={{
|
||||
padding: '0.6rem 0.8rem', borderRadius: '6px',
|
||||
background: justPaid ? 'rgba(34,197,94,0.1)' : 'rgba(245,158,11,0.1)',
|
||||
border: `1px solid ${justPaid ? 'rgba(34,197,94,0.3)' : 'rgba(245,158,11,0.3)'}`,
|
||||
color: justPaid ? '#22c55e' : '#f59e0b', fontSize: '0.85rem',
|
||||
}}>
|
||||
{justPaid
|
||||
? 'Zahlung erfolgreich. Abonnement wird aktiviert — bitte warten...'
|
||||
: 'Die Zahlung wurde noch nicht abgeschlossen. Sie können den Checkout abbrechen oder erneut starten.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isScheduled && sub.effectiveFrom && (
|
||||
<div style={{
|
||||
padding: '0.6rem 0.8rem', borderRadius: '6px',
|
||||
background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)',
|
||||
color: '#8b5cf6', fontSize: '0.85rem',
|
||||
}}>
|
||||
Dieses Abonnement wird am {_formatDate(sub.effectiveFrom)} aktiv, wenn das aktuelle Abonnement ausläuft.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPending && !isScheduled && (
|
||||
<div style={{
|
||||
fontSize: '0.85rem', color: 'var(--text-secondary, #888)',
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.25rem 1rem',
|
||||
}}>
|
||||
<span>Gestartet: {_formatDate(sub.startedAt)}</span>
|
||||
{plan && <span>Periode: {_periodLabel[plan.billingPeriod] || '—'}</span>}
|
||||
{sub.currentPeriodEnd && <span>Periodenende: {_formatDate(sub.currentPeriodEnd)}</span>}
|
||||
{sub.trialEndsAt && <span>Trial endet: {_formatDate(sub.trialEndsAt)}</span>}
|
||||
{isActive && !sub.recurring && sub.currentPeriodEnd && (
|
||||
<span style={{ color: '#ef4444' }}>Läuft aus am: {_formatDate(sub.currentPeriodEnd)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
|
||||
{isActive && !sub.recurring && onReactivate && (
|
||||
<button
|
||||
onClick={() => onReactivate(sub.id)}
|
||||
disabled={reactivating}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: '6px', border: 'none',
|
||||
background: 'var(--color-primary, #3b82f6)', color: '#fff',
|
||||
fontWeight: 600, cursor: reactivating ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{reactivating ? 'Wird reaktiviert...' : 'Reaktivieren'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isActive && sub.recurring && onCancel && (
|
||||
<button
|
||||
onClick={() => onCancel(sub.id)}
|
||||
disabled={cancelling}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: '6px',
|
||||
border: '1px solid #ef4444', background: 'transparent',
|
||||
color: '#ef4444', fontWeight: 500,
|
||||
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{cancelling ? 'Wird gekündigt...' : 'Kündigen'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(isPending || isScheduled) && onCancel && (
|
||||
<button
|
||||
onClick={() => onCancel(sub.id)}
|
||||
disabled={cancelling}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: '6px',
|
||||
border: '1px solid #ef4444', background: 'transparent',
|
||||
color: '#ef4444', fontWeight: 500,
|
||||
cursor: cancelling ? 'wait' : 'pointer', fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{cancelling ? 'Wird abgebrochen...' : 'Abbrechen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Subscription Tab
|
||||
// ============================================================================
|
||||
|
||||
interface SubscriptionTabProps {
|
||||
mandateId: string;
|
||||
}
|
||||
|
||||
export const SubscriptionTab: React.FC<SubscriptionTabProps> = ({ mandateId }) => {
|
||||
const {
|
||||
plans,
|
||||
subscription,
|
||||
plan: currentPlan,
|
||||
scheduled,
|
||||
loading,
|
||||
error,
|
||||
activatePlan,
|
||||
cancelSubscription,
|
||||
reactivateSubscription,
|
||||
verifyCheckout,
|
||||
} = useSubscription(mandateId);
|
||||
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const [activatingPlanKey, setActivatingPlanKey] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [reactivating, setReactivating] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'info'; text: string } | null>(null);
|
||||
const [justPaid, setJustPaid] = useState(false);
|
||||
const verifyCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('success') === 'true') {
|
||||
const sessionId = params.get('session_id') || '';
|
||||
setCheckoutMessage({ type: 'success', text: 'Zahlung erfolgreich — Abonnement wird aktiviert...' });
|
||||
setJustPaid(true);
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('success');
|
||||
url.searchParams.delete('session_id');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
|
||||
if (sessionId && !verifyCalledRef.current) {
|
||||
verifyCalledRef.current = true;
|
||||
verifyCheckout(sessionId)
|
||||
.then((result) => {
|
||||
if (result.status === 'activated') {
|
||||
setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' });
|
||||
setJustPaid(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} else if (params.get('canceled') === 'true') {
|
||||
setCheckoutMessage({ type: 'info', text: 'Checkout abgebrochen. Ihr bestehendes Abonnement bleibt aktiv.' });
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('canceled');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!justPaid) return;
|
||||
if (subscription && subscription.status !== 'PENDING') {
|
||||
setJustPaid(false);
|
||||
setCheckoutMessage({ type: 'success', text: 'Abonnement wurde aktiviert.' });
|
||||
}
|
||||
}, [justPaid, subscription]);
|
||||
|
||||
const _handleActivate = useCallback(async (planKey: string) => {
|
||||
setActivatingPlanKey(planKey);
|
||||
setActionError(null);
|
||||
try {
|
||||
await activatePlan(planKey);
|
||||
} catch (err: any) {
|
||||
setActionError(err?.response?.data?.detail || err.message || 'Fehler beim Aktivieren');
|
||||
} finally {
|
||||
setActivatingPlanKey(null);
|
||||
}
|
||||
}, [activatePlan]);
|
||||
|
||||
const _handleCancel = useCallback(async (subscriptionId: string) => {
|
||||
const sub = subscription?.id === subscriptionId ? subscription : scheduled;
|
||||
const isPendingOrScheduled = sub?.status === 'PENDING' || sub?.status === 'SCHEDULED';
|
||||
const ok = await confirm(
|
||||
isPendingOrScheduled
|
||||
? 'Diesen Vorgang abbrechen?'
|
||||
: 'Abonnement kündigen? Es bleibt bis zum Periodenende aktiv.',
|
||||
{
|
||||
title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen',
|
||||
confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen',
|
||||
cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined,
|
||||
variant: 'danger',
|
||||
},
|
||||
);
|
||||
if (!ok) return;
|
||||
setCancelling(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await cancelSubscription(subscriptionId);
|
||||
setCheckoutMessage(null);
|
||||
} catch (err: any) {
|
||||
setActionError(err?.response?.data?.detail || err.message || 'Fehler');
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
}, [cancelSubscription, subscription, scheduled]);
|
||||
|
||||
const _handleReactivate = useCallback(async (subscriptionId: string) => {
|
||||
setReactivating(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await reactivateSubscription(subscriptionId);
|
||||
} catch (err: any) {
|
||||
setActionError(err?.response?.data?.detail || err.message || 'Fehler beim Reaktivieren');
|
||||
} finally {
|
||||
setReactivating(false);
|
||||
}
|
||||
}, [reactivateSubscription]);
|
||||
|
||||
if (loading && !subscription) {
|
||||
return <div className={styles.loadingPlaceholder}>Lade Abonnement-Daten...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Checkout feedback */}
|
||||
{checkoutMessage && (
|
||||
<div style={{
|
||||
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '6px',
|
||||
background: checkoutMessage.type === 'success' ? 'rgba(34,197,94,0.12)' : 'rgba(59,130,246,0.12)',
|
||||
border: `1px solid ${checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6'}`,
|
||||
color: checkoutMessage.type === 'success' ? '#22c55e' : '#3b82f6',
|
||||
fontSize: '0.9rem',
|
||||
}}>
|
||||
{checkoutMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{(error || actionError) && (
|
||||
<div className={styles.errorMessage} style={{ marginBottom: '1rem' }}>
|
||||
{actionError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current subscription */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Aktuelles Abonnement</h2>
|
||||
{subscription ? (
|
||||
<SubInfoCard
|
||||
sub={subscription}
|
||||
plan={currentPlan}
|
||||
label={subscription.status === 'PENDING'
|
||||
? (justPaid ? 'Zahlung wird verarbeitet' : 'Checkout in Bearbeitung')
|
||||
: 'Operatives Abonnement'}
|
||||
onCancel={_handleCancel}
|
||||
onReactivate={_handleReactivate}
|
||||
cancelling={cancelling}
|
||||
reactivating={reactivating}
|
||||
justPaid={justPaid}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.noData}>
|
||||
Kein aktives Abonnement. Wählen Sie unten einen Plan.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Scheduled successor */}
|
||||
{scheduled && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Geplanter Nachfolger</h2>
|
||||
<SubInfoCard
|
||||
sub={scheduled}
|
||||
plan={null}
|
||||
label="Startet nach Ablauf des aktuellen Abonnements"
|
||||
onCancel={_handleCancel}
|
||||
cancelling={cancelling}
|
||||
reactivating={false}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Available plans */}
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Verfügbare Pläne</h2>
|
||||
{plans.length === 0 ? (
|
||||
<div className={styles.noData}>Keine Pläne verfügbar</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: '1rem',
|
||||
}}>
|
||||
{plans.map((p) => (
|
||||
<PlanCard
|
||||
key={p.planKey}
|
||||
plan={p}
|
||||
isCurrent={subscription?.planKey === p.planKey && subscription?.status === 'ACTIVE'}
|
||||
onActivate={_handleActivate}
|
||||
activatingPlanKey={activatingPlanKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -11,3 +11,4 @@ export { BillingNav } from './BillingNav';
|
|||
export { BillingTransactions } from './BillingTransactions';
|
||||
export { BillingMandateView } from './BillingMandateView';
|
||||
export { BillingUserView } from './BillingUserView';
|
||||
export { default as AdminSubscriptionsPage } from './AdminSubscriptionsPage';
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
/**
|
||||
* AutomationLogsView
|
||||
*
|
||||
* Placeholder view for automation execution logs.
|
||||
*/
|
||||
import React from 'react';
|
||||
import styles from '../../FeatureView.module.css';
|
||||
|
||||
export const AutomationLogsView: React.FC = () => (
|
||||
<div className={styles.placeholder}>
|
||||
<h2>Execution Logs</h2>
|
||||
<p>Automatisierungs-Ausführungsprotokolle</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -23,6 +23,8 @@ export const AutomationTemplatesView: React.FC = () => {
|
|||
error,
|
||||
permissions,
|
||||
refetch,
|
||||
fetchTemplates,
|
||||
pagination,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
|
|
@ -176,7 +178,7 @@ export const AutomationTemplatesView: React.FC = () => {
|
|||
{ type: 'delete' as const, title: 'Löschen', disabled: (row: any) => row.isSystem && !isSysAdmin ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin gelöscht werden' } : !canDelete ? { disabled: true, message: 'Keine Berechtigung' } : false },
|
||||
]}
|
||||
onDelete={(template) => handleDelete(template.id)}
|
||||
hookData={{ refetch, handleDelete, attributes }}
|
||||
hookData={{ refetch: fetchTemplates, pagination, handleDelete, attributes }}
|
||||
emptyMessage="Keine Vorlagen gefunden"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@
|
|||
|
||||
export { AutomationDefinitionsView } from './AutomationDefinitionsView';
|
||||
export { AutomationTemplatesView } from './AutomationTemplatesView';
|
||||
export { AutomationLogsView } from './AutomationLogsView';
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
* Similar to trustee views but hardcoded for chatbot feature.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useChatbot } from '../../../hooks/useChatbot';
|
||||
import { useConfirm } from '../../../hooks/useConfirm';
|
||||
import { TextField } from '../../../components/UiComponents/TextField';
|
||||
import { Button } from '../../../components/UiComponents/Button';
|
||||
import { AutoScroll } from '../../../components/UiComponents/AutoScroll';
|
||||
|
|
@ -40,6 +41,7 @@ export const ChatbotConversationsView: React.FC = () => {
|
|||
} = useChatbot();
|
||||
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -76,17 +78,21 @@ export const ChatbotConversationsView: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDeleteThread = async (e: React.MouseEvent, workflowId: string) => {
|
||||
const handleDeleteThread = useCallback(async (e: React.MouseEvent, workflowId: string) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Möchten Sie diese Konversation wirklich löschen?')) {
|
||||
const ok = await confirm('Möchten Sie diese Konversation wirklich löschen?', {
|
||||
title: 'Konversation löschen',
|
||||
confirmLabel: 'Löschen',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (!ok) return;
|
||||
setDeletingId(workflowId);
|
||||
try {
|
||||
await deleteThread(workflowId);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [confirm, deleteThread]);
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return '';
|
||||
|
|
@ -269,6 +275,7 @@ export const ChatbotConversationsView: React.FC = () => {
|
|||
)}
|
||||
</form>
|
||||
</main>
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<p className={styles.pageSubtitle}>Parzellen verwalten</p>
|
||||
|
|
@ -163,25 +163,6 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!parcels || parcels.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Parzellen...</span>
|
||||
</div>
|
||||
) : !parcels || parcels.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaMapMarkerAlt className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Parzellen vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Erstellen Sie eine neue Parzelle, um zu beginnen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||
+ Neue Parzelle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={parcels}
|
||||
columns={columns}
|
||||
|
|
@ -223,7 +204,6 @@ export const RealEstateParcelsView: React.FC = () => {
|
|||
}}
|
||||
emptyMessage="Keine Parzellen gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(editingParcel || isCreateMode) && (
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<p className={styles.pageSubtitle}>Projekte verwalten</p>
|
||||
|
|
@ -149,23 +149,6 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!projects || projects.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Projekte...</span>
|
||||
</div>
|
||||
) : !projects || projects.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaBuilding className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Projekte vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>Erstellen Sie ein neues Projekt, um zu beginnen.</p>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={handleCreateClick}>
|
||||
+ Neues Projekt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={projects}
|
||||
columns={columns}
|
||||
|
|
@ -184,7 +167,6 @@ export const RealEstateProjectsView: React.FC = () => {
|
|||
hookData={{ refetch, permissions, pagination, handleDelete, handleInlineUpdate, updateOptimistically }}
|
||||
emptyMessage="Keine Projekte gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(editingProject || isCreateMode) && (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { useConfirm } from '../../../hooks/useConfirm';
|
||||
import {
|
||||
fetchAccountingConnectors,
|
||||
fetchAccountingConfig,
|
||||
|
|
@ -42,6 +43,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const mountedRef = useRef(true);
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
|
||||
useEffect(() => {
|
||||
if (!importDone) return;
|
||||
|
|
@ -145,7 +147,12 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
|
||||
const handleRemove = async () => {
|
||||
if (!instanceId) return;
|
||||
if (!window.confirm('Remove the accounting integration? This does not delete synced data.')) return;
|
||||
const ok = await confirm('Remove the accounting integration? This does not delete synced data.', {
|
||||
title: 'Remove Integration',
|
||||
confirmLabel: 'Remove',
|
||||
variant: 'danger',
|
||||
});
|
||||
if (!ok) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteAccountingConfig(request, instanceId);
|
||||
|
|
@ -421,6 +428,7 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<p className={styles.pageSubtitle}>Belege und Dokumente verwalten</p>
|
||||
|
|
@ -203,28 +203,6 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!documents || documents.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Dokumente...</span>
|
||||
</div>
|
||||
) : !documents || documents.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaFileAlt className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Dokumente vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Erstellen Sie ein neues Dokument, um zu beginnen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neues Dokument
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={documents}
|
||||
columns={columns}
|
||||
|
|
@ -268,7 +246,6 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
}}
|
||||
emptyMessage="Keine Dokumente gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<p className={styles.pageSubtitle}>Belege mit Buchungspositionen verknüpfen</p>
|
||||
|
|
@ -171,28 +171,6 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!links || links.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Verknüpfungen...</span>
|
||||
</div>
|
||||
) : !links || links.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaLink className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Verknüpfungen vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Verknüpfen Sie Belege mit Buchungspositionen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neue Verknüpfung
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={links}
|
||||
columns={columns}
|
||||
|
|
@ -227,7 +205,6 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
}}
|
||||
emptyMessage="Keine Verknüpfungen gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
|||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaReceipt, FaDownload } from 'react-icons/fa';
|
||||
import { FaSync, FaDownload } from 'react-icons/fa';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import api from '../../../api';
|
||||
import { fetchSyncStatus, syncPositionsToAccounting, type AccountingSyncStatus } from '../../../api/trusteeApi';
|
||||
|
|
@ -293,19 +293,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
return col;
|
||||
});
|
||||
|
||||
const createdAtCol = {
|
||||
key: '_createdAt',
|
||||
label: 'Erstellt am',
|
||||
type: 'timestamp' as any,
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
searchable: false,
|
||||
width: 150,
|
||||
minWidth: 120,
|
||||
maxWidth: 200,
|
||||
};
|
||||
|
||||
const allColumns = [...attrColumns, belegeColumn, syncStatusColumn, createdAtCol];
|
||||
const allColumns = [...attrColumns, belegeColumn, syncStatusColumn];
|
||||
const byKey = new Map(allColumns.map(c => [c.key, c]));
|
||||
|
||||
const ordered: typeof allColumns = [];
|
||||
|
|
@ -412,7 +400,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<p className={styles.pageSubtitle}>Buchungspositionen verwalten</p>
|
||||
|
|
@ -437,28 +425,6 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!positions || positions.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Positionen...</span>
|
||||
</div>
|
||||
) : !positions || positions.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaReceipt className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Positionen vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Erstellen Sie eine neue Position, um zu beginnen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neue Position
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={positions}
|
||||
columns={columns}
|
||||
|
|
@ -510,7 +476,6 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
}}
|
||||
emptyMessage="Keine Positionen gefunden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
|
|
|
|||
155
src/pages/views/workspace/WorkspaceGeneralSettings.tsx
Normal file
155
src/pages/views/workspace/WorkspaceGeneralSettings.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* WorkspaceGeneralSettings -- Per-user workspace settings (e.g. max agent rounds).
|
||||
*
|
||||
* The user can override the instance default. Setting a field to null reverts to the default.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import styles from './WorkspaceSettings.module.css';
|
||||
|
||||
interface GeneralSettingsProps {
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
interface MaxAgentRoundsInfo {
|
||||
effective: number;
|
||||
userOverride: number | null;
|
||||
instanceDefault: number;
|
||||
}
|
||||
|
||||
export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ instanceId }) => {
|
||||
const { request } = useApiRequest();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [maxRoundsInfo, setMaxRoundsInfo] = useState<MaxAgentRoundsInfo>({
|
||||
effective: 25,
|
||||
userOverride: null,
|
||||
instanceDefault: 25,
|
||||
});
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
|
||||
const _loadSettings = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/workspace/${instanceId}/settings/general`,
|
||||
method: 'get',
|
||||
});
|
||||
const info = (data as any)?.maxAgentRounds;
|
||||
if (info) {
|
||||
setMaxRoundsInfo(info);
|
||||
setInputValue(info.userOverride != null ? String(info.userOverride) : '');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Fehler beim Laden der Einstellungen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
_loadSettings();
|
||||
}, [_loadSettings]);
|
||||
|
||||
const _handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
const val = inputValue.trim() === '' ? null : parseInt(inputValue, 10);
|
||||
if (val !== null && (isNaN(val) || val < 1 || val > 100)) {
|
||||
setError('Wert muss zwischen 1 und 100 liegen.');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const data = await request({
|
||||
url: `/api/workspace/${instanceId}/settings/general`,
|
||||
method: 'put',
|
||||
data: { maxAgentRounds: val },
|
||||
});
|
||||
const info = (data as any)?.maxAgentRounds;
|
||||
if (info) {
|
||||
setMaxRoundsInfo(info);
|
||||
setInputValue(info.userOverride != null ? String(info.userOverride) : '');
|
||||
}
|
||||
setSuccess('Einstellungen gespeichert.');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Fehler beim Speichern');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const _handleReset = () => {
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Lade Einstellungen...</div>;
|
||||
}
|
||||
|
||||
const hasOverride = inputValue.trim() !== '';
|
||||
|
||||
return (
|
||||
<div className={styles.settings}>
|
||||
<h2 className={styles.heading}>Generelle Einstellungen</h2>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
{success && <div className={styles.success}>{success}</div>}
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Agenten-Konfiguration</h3>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>
|
||||
Max. Agenten-Runden
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
className={styles.input}
|
||||
style={{ maxWidth: 120 }}
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
placeholder={String(maxRoundsInfo.instanceDefault)}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={_handleReset}
|
||||
title="Auf Standard zurücksetzen"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', marginTop: 4, display: 'block' }}>
|
||||
Standard der Instanz: {maxRoundsInfo.instanceDefault}.
|
||||
{maxRoundsInfo.userOverride != null && (
|
||||
<> Ihr Override: {maxRoundsInfo.userOverride}.</>
|
||||
)}
|
||||
{' '}Effektiv: {maxRoundsInfo.effective}.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.saveBtn}
|
||||
onClick={_handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary, #666);
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color, #e8e8e8);
|
||||
}
|
||||
|
||||
.kpiGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.kpiCard {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.kpiValue {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.kpiLabel {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chartBlock {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.row2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #c62828;
|
||||
padding: 1rem;
|
||||
}
|
||||
273
src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
Normal file
273
src/pages/views/workspace/WorkspaceRagInsightsPage.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* WorkspaceRagInsightsPage — Aggregierte, nicht personenbezogene Kennzahlen zum
|
||||
* Knowledge Store / RAG dieser Workspace-Instanz (Präsentationen, Monitoring).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import styles from './WorkspaceRagInsightsPage.module.css';
|
||||
|
||||
const MIME_LABELS: Record<string, string> = {
|
||||
pdf: 'PDF',
|
||||
office_doc: 'Office (Text)',
|
||||
office_sheet: 'Office (Tabellen)',
|
||||
office_slides: 'Office (Folien)',
|
||||
text: 'Text',
|
||||
image: 'Bild',
|
||||
html: 'HTML',
|
||||
other: 'Sonstige',
|
||||
};
|
||||
|
||||
const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828'];
|
||||
|
||||
function _formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||
}
|
||||
|
||||
interface RagKpis {
|
||||
indexedDocuments: number;
|
||||
indexedBytesTotal: number;
|
||||
contributorUsers: number;
|
||||
contentChunks: number;
|
||||
chunksWithEmbedding: number;
|
||||
embeddingCoveragePercent: number;
|
||||
workflowEntities: number;
|
||||
}
|
||||
|
||||
interface RagStatsResponse {
|
||||
error?: string;
|
||||
scope?: {
|
||||
featureInstanceId?: string;
|
||||
mandateScopedShared?: boolean;
|
||||
workspaceFileIdsResolved?: number;
|
||||
};
|
||||
kpis?: RagKpis;
|
||||
indexedDocumentsByStatus?: Record<string, number>;
|
||||
documentsByMimeCategory?: Record<string, number>;
|
||||
chunksByContentType?: Record<string, number>;
|
||||
timelineIndexedDocuments?: Array<{ date: string; indexedDocuments: number }>;
|
||||
generatedAtUtc?: string;
|
||||
}
|
||||
|
||||
export const WorkspaceRagInsightsPage: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
const { request } = useApiRequest();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<RagStatsResponse | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = (await request({
|
||||
url: `/api/workspace/${instanceId}/rag-statistics`,
|
||||
method: 'get',
|
||||
})) as RagStatsResponse;
|
||||
if (data?.error) {
|
||||
setError(String(data.error));
|
||||
setStats(null);
|
||||
} else {
|
||||
setStats(data ?? null);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen');
|
||||
setStats(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||
Keine Workspace-Instanz ausgewählt.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.wrap} style={{ padding: 24 }}>Lade Kennzahlen …</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className={styles.error}>{error}</div>;
|
||||
}
|
||||
|
||||
const kpis = stats?.kpis;
|
||||
const timeline = stats?.timelineIndexedDocuments ?? [];
|
||||
const mimeRows = Object.entries(stats?.documentsByMimeCategory ?? {}).map(([key, value]) => ({
|
||||
name: MIME_LABELS[key] ?? key,
|
||||
value,
|
||||
}));
|
||||
const statusRows = Object.entries(stats?.indexedDocumentsByStatus ?? {}).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
const chunkTypeRows = Object.entries(stats?.chunksByContentType ?? {}).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<p className={styles.disclaimer}>
|
||||
Dargestellt sind ausschliesslich aggregierte technische Masszahlen dieser Instanz (Anzahl
|
||||
Dokumente, Fragmente, Speicherumfang, Verteilungen). Es werden keine Inhalte, Dateinamen
|
||||
oder personenbezogene Angaben ausgewiesen. Geeignet für interne Berichte und Präsentationen.
|
||||
</p>
|
||||
|
||||
{stats?.scope?.workspaceFileIdsResolved !== undefined && (
|
||||
<p className={styles.meta} style={{ marginTop: 0 }}>
|
||||
Zuordnung Knowledge ↔ Dateien: {stats.scope.workspaceFileIdsResolved} Datei-ID(s) mit
|
||||
dieser Feature-Instanz in der Dateiverwaltung. Neu indexierte Uploads erhalten die
|
||||
Instanz automatisch; ältere Einträge ohne Zuordnung erscheinen erst nach erneuter
|
||||
Indexierung.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{kpis && (
|
||||
<div className={styles.kpiGrid}>
|
||||
<div className={styles.kpiCard}>
|
||||
<p className={styles.kpiValue}>{kpis.indexedDocuments}</p>
|
||||
<p className={styles.kpiLabel}>Indexierte Dokumente</p>
|
||||
</div>
|
||||
<div className={styles.kpiCard}>
|
||||
<p className={styles.kpiValue}>{_formatBytes(kpis.indexedBytesTotal)}</p>
|
||||
<p className={styles.kpiLabel}>Indexiertes Datenvolumen (geschätzt)</p>
|
||||
</div>
|
||||
<div className={styles.kpiCard}>
|
||||
<p className={styles.kpiValue}>{kpis.contentChunks}</p>
|
||||
<p className={styles.kpiLabel}>Inhalts-Fragmente (Chunks)</p>
|
||||
</div>
|
||||
<div className={styles.kpiCard}>
|
||||
<p className={styles.kpiValue}>
|
||||
{kpis.embeddingCoveragePercent}%
|
||||
</p>
|
||||
<p className={styles.kpiLabel}>Anteil Fragmente mit Embedding</p>
|
||||
</div>
|
||||
<div className={styles.kpiCard}>
|
||||
<p className={styles.kpiValue}>{kpis.contributorUsers}</p>
|
||||
<p className={styles.kpiLabel}>Beitragende Benutzer (Anzahl)</p>
|
||||
</div>
|
||||
<div className={styles.kpiCard}>
|
||||
<p className={styles.kpiValue}>{kpis.workflowEntities}</p>
|
||||
<p className={styles.kpiLabel}>Workflow-Entitäten (Cache)</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.chartBlock}>
|
||||
<h3 className={styles.chartTitle}>Neu indexierte Dokumente pro Tag (letzte Wochen)</h3>
|
||||
{timeline.length === 0 ? (
|
||||
<p className={styles.meta}>Keine Zeitreihen-Daten für den gewählten Zeitraum.</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={timeline}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="indexedDocuments" name="Dokumente" stroke="#1976d2" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<div className={styles.chartBlock}>
|
||||
<h3 className={styles.chartTitle}>Dokumente nach Format-Kategorie</h3>
|
||||
{mimeRows.length === 0 ? (
|
||||
<p className={styles.meta}>Keine Daten.</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={mimeRows} layout="vertical" margin={{ left: 8, right: 16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" name="Anzahl" fill="#00897b" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartBlock}>
|
||||
<h3 className={styles.chartTitle}>Index-Status</h3>
|
||||
{statusRows.length === 0 ? (
|
||||
<p className={styles.meta}>Keine Daten.</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusRows}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={88}
|
||||
label={({ name, percent }) =>
|
||||
`${name ?? ''} ${percent != null ? (percent * 100).toFixed(0) : '0'}%`}
|
||||
>
|
||||
{statusRows.map((_, i) => (
|
||||
<Cell key={`st-${i}`} fill={CHART_COLORS[i % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartBlock}>
|
||||
<h3 className={styles.chartTitle}>Fragmente nach Inhaltstyp</h3>
|
||||
{chunkTypeRows.length === 0 ? (
|
||||
<p className={styles.meta}>Keine Chunk-Daten.</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={chunkTypeRows}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" name="Fragmente" fill="#6a1b9a" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats?.generatedAtUtc && (
|
||||
<p className={styles.meta}>Stand (UTC): {stats.generatedAtUtc}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,21 +8,23 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { WorkspaceSettings } from './WorkspaceSettings';
|
||||
import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings';
|
||||
|
||||
type SettingsTab = 'voice';
|
||||
type SettingsTab = 'general' | 'voice';
|
||||
|
||||
const _TABS: { key: SettingsTab; label: string }[] = [
|
||||
{ key: 'general', label: 'Generelle Einstellungen' },
|
||||
{ key: 'voice', label: 'Sprache & Stimme' },
|
||||
];
|
||||
|
||||
export const WorkspaceSettingsPage: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('voice');
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
|
||||
Keine Workspace-Instanz ausgewaehlt.
|
||||
Keine Workspace-Instanz ausgewählt.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -61,6 +63,9 @@ export const WorkspaceSettingsPage: React.FC = () => {
|
|||
</nav>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
||||
{activeTab === 'general' && (
|
||||
<WorkspaceGeneralSettings instanceId={instanceId} />
|
||||
)}
|
||||
{activeTab === 'voice' && (
|
||||
<WorkspaceSettings instanceId={instanceId} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -358,7 +358,18 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
|||
setIsProcessing(false);
|
||||
const item = event.item as Record<string, unknown> | undefined;
|
||||
let msg = event.content || 'Unknown error';
|
||||
if (item && item.error === 'INSUFFICIENT_BALANCE') {
|
||||
const subscriptionErrors = new Set([
|
||||
'SUBSCRIPTION_INACTIVE',
|
||||
'SUBSCRIPTION_PAYMENT_REQUIRED',
|
||||
'SUBSCRIPTION_PAYMENT_PENDING',
|
||||
'SUBSCRIPTION_EXPIRED',
|
||||
]);
|
||||
if (item && typeof item.error === 'string' && subscriptionErrors.has(item.error)) {
|
||||
msg = typeof item.message === 'string' ? item.message : msg;
|
||||
if (typeof item.subscriptionUiPath === 'string') {
|
||||
msg += `\n\n→ ${item.subscriptionUiPath}`;
|
||||
}
|
||||
} else if (item && item.error === 'INSUFFICIENT_BALANCE') {
|
||||
const preferDe =
|
||||
typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de');
|
||||
const de = typeof item.messageDe === 'string' ? item.messageDe : '';
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
views: [
|
||||
{ code: 'dashboard', label: { de: 'Dashboard', en: 'Dashboard', fr: 'Tableau de bord' }, path: 'dashboard' },
|
||||
{ code: 'editor', label: { de: 'Editor', en: 'Editor', fr: 'Editeur' }, path: 'editor' },
|
||||
{ code: 'rag-insights', label: { de: 'Wissens-Insights', en: 'Knowledge insights', fr: 'Aperçu des connaissances' }, path: 'rag-insights' },
|
||||
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings', fr: 'Parametres' }, path: 'settings' },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue