streamlined bootstrap and initial config
This commit is contained in:
parent
15c93b3bf0
commit
75125e3f58
12 changed files with 231 additions and 173 deletions
|
|
@ -143,6 +143,9 @@
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionDescription {
|
.sectionDescription {
|
||||||
|
|
@ -199,8 +202,8 @@
|
||||||
|
|
||||||
.chartWrapperSmall {
|
.chartWrapperSmall {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 220px;
|
height: 250px;
|
||||||
min-height: 220px;
|
min-height: 250px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ const _renderBarChart = (section: ReportSectionBarChart, currencyCode: string):
|
||||||
return (
|
return (
|
||||||
<div className={styles.chartWrapper}>
|
<div className={styles.chartWrapper}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
<BarChart data={chartData} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
|
|
@ -198,7 +198,7 @@ const _renderLineChart = (section: ReportSectionLineChart, currencyCode: string)
|
||||||
return (
|
return (
|
||||||
<div className={styles.chartWrapper}>
|
<div className={styles.chartWrapper}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={section.data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
<LineChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
|
|
@ -244,7 +244,7 @@ const _renderAreaChart = (section: ReportSectionAreaChart, currencyCode: string)
|
||||||
return (
|
return (
|
||||||
<div className={styles.chartWrapper}>
|
<div className={styles.chartWrapper}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={section.data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
|
<AreaChart data={section.data} margin={{ top: 15, right: 10, left: 10, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border-color, #333)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
|
|
@ -302,13 +302,13 @@ const _renderPieChart = (section: ReportSectionPieChart, currencyCode: string):
|
||||||
return (
|
return (
|
||||||
<div className={styles.chartWrapperSmall}>
|
<div className={styles.chartWrapperSmall}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart margin={{ top: 20, right: 10, left: 10, bottom: 5 }}>
|
||||||
<Pie
|
<Pie
|
||||||
data={chartData}
|
data={chartData}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
innerRadius={section.donut ? '55%' : 0}
|
innerRadius={section.donut ? '45%' : 0}
|
||||||
outerRadius="80%"
|
outerRadius="65%"
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
label={_renderLabel}
|
label={_renderLabel}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ interface ProviderMultiSelectProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
|
excludeByDefault?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
|
|
@ -112,8 +113,10 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
label = 'AI-Provider',
|
label = 'AI-Provider',
|
||||||
showLabel = true,
|
showLabel = true,
|
||||||
defaultExpanded = false,
|
defaultExpanded = false,
|
||||||
|
excludeByDefault = [],
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
const [initialExcludeApplied, setInitialExcludeApplied] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
const { allowedProviders, loadAllowedProviders, loading } = useBilling();
|
||||||
|
|
||||||
|
|
@ -123,6 +126,25 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Apply default exclusions when providers first load
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!initialExcludeApplied &&
|
||||||
|
allowedProviders.length > 0 &&
|
||||||
|
excludeByDefault.length > 0 &&
|
||||||
|
selectedProviders.length === 0
|
||||||
|
) {
|
||||||
|
const initialSelection = allowedProviders.filter(
|
||||||
|
(p) => !excludeByDefault.includes(p)
|
||||||
|
);
|
||||||
|
// Only apply if there's actually something to exclude
|
||||||
|
if (initialSelection.length < allowedProviders.length) {
|
||||||
|
onChange(initialSelection);
|
||||||
|
}
|
||||||
|
setInitialExcludeApplied(true);
|
||||||
|
}
|
||||||
|
}, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]);
|
||||||
|
|
||||||
// Click outside handler
|
// Click outside handler
|
||||||
const handleClickOutside = useCallback((event: MouseEvent) => {
|
const handleClickOutside = useCallback((event: MouseEvent) => {
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
|
@ -137,37 +159,49 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
}
|
}
|
||||||
}, [isExpanded, handleClickOutside]);
|
}, [isExpanded, handleClickOutside]);
|
||||||
|
|
||||||
// Check if all providers are explicitly selected
|
// Effective selection: empty array = all providers active (no restriction)
|
||||||
const isAllSelected = allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length;
|
const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders;
|
||||||
|
|
||||||
// Check if no providers are selected (= no restriction, all allowed by default)
|
// "Alle" is active when no restriction is set (empty array) OR all explicitly selected
|
||||||
const isNoneSelected = selectedProviders.length === 0;
|
const isAllSelected = selectedProviders.length === 0 ||
|
||||||
|
(allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length);
|
||||||
|
|
||||||
const handleToggle = (provider: string) => {
|
const handleToggle = (provider: string) => {
|
||||||
if (selectedProviders.includes(provider)) {
|
if (selectedProviders.length === 0) {
|
||||||
|
// Currently "all active" (no restriction) -> make explicit: all except the toggled one
|
||||||
|
onChange(allowedProviders.filter((p) => p !== provider));
|
||||||
|
} else if (selectedProviders.includes(provider)) {
|
||||||
// Deactivate: remove from selection
|
// Deactivate: remove from selection
|
||||||
onChange(selectedProviders.filter((p) => p !== provider));
|
const remaining = selectedProviders.filter((p) => p !== provider);
|
||||||
|
// If removing leaves all others selected, reset to [] (= all, no restriction)
|
||||||
|
if (remaining.length === allowedProviders.length) {
|
||||||
|
onChange([]);
|
||||||
|
} else {
|
||||||
|
onChange(remaining);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Activate: add to selection
|
// Activate: add to selection
|
||||||
onChange([...selectedProviders, provider]);
|
const updated = [...selectedProviders, provider];
|
||||||
|
// If all are now selected, reset to [] (= all, no restriction)
|
||||||
|
if (updated.length === allowedProviders.length) {
|
||||||
|
onChange([]);
|
||||||
|
} else {
|
||||||
|
onChange(updated);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
onChange([...allowedProviders]);
|
onChange([]); // Empty = all active, no restriction
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectNone = () => {
|
|
||||||
onChange([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Summary icon for button
|
// Summary icon for button
|
||||||
const summaryIcon = useMemo(() => {
|
const summaryIcon = useMemo(() => {
|
||||||
if (selectedProviders.length === 1) {
|
if (effectiveSelection.length === 1) {
|
||||||
return PROVIDER_ICONS[selectedProviders[0]] || '🔌';
|
return PROVIDER_ICONS[effectiveSelection[0]] || '🔌';
|
||||||
}
|
}
|
||||||
return '🤖';
|
return '🤖';
|
||||||
}, [selectedProviders]);
|
}, [effectiveSelection]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -199,14 +233,6 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
>
|
>
|
||||||
Alle
|
Alle
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSelectNone}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`${styles.actionButton} ${isNoneSelected ? styles.active : ''}`}
|
|
||||||
>
|
|
||||||
Keine
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -220,7 +246,7 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedProviders.includes(provider)}
|
checked={effectiveSelection.includes(provider)}
|
||||||
onChange={() => handleToggle(provider)}
|
onChange={() => handleToggle(provider)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|
@ -233,9 +259,9 @@ export const ProviderMultiSelect: React.FC<ProviderMultiSelectProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedProviders.length === 0 && !loading && (
|
{isAllSelected && !loading && (
|
||||||
<div className={styles.hint}>
|
<div className={styles.hint}>
|
||||||
Alle Provider aktiv
|
Alle Provider aktiv (kein Filter)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type { Workflow, WorkflowMessage } from '../../api/workflowApi';
|
||||||
import { useWorkflowLifecycle } from './useWorkflowLifecycle';
|
import { useWorkflowLifecycle } from './useWorkflowLifecycle';
|
||||||
import { useWorkflows } from './useWorkflows';
|
import { useWorkflows } from './useWorkflows';
|
||||||
import { useDashboardLogTree } from './useDashboardLogTree';
|
import { useDashboardLogTree } from './useDashboardLogTree';
|
||||||
import { extractFileIdsFromMessage, convertFilesToDocuments, sortMessages } from './playgroundUtils';
|
import { convertFilesToDocuments, sortMessages } from './playgroundUtils';
|
||||||
import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes';
|
import type { WorkflowLog as LogTypesWorkflowLog } from '../../components/UiComponents/Log/LogTypes';
|
||||||
|
|
||||||
export interface WorkflowFile {
|
export interface WorkflowFile {
|
||||||
|
|
@ -279,44 +279,21 @@ export function useDashboardInputForm(instanceId: string) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!messages || messages.length === 0) return;
|
if (!messages || messages.length === 0) return;
|
||||||
|
if (!optimisticMessage) return;
|
||||||
|
|
||||||
const messageTexts = new Set<string>();
|
// Clear optimistic message when backend's "first" user message arrives via polling.
|
||||||
messages.forEach((message: WorkflowMessage) => {
|
// The backend message contains the normalizedRequest (which differs from the original prompt),
|
||||||
if (message.message) {
|
// so we match by status="first" instead of content comparison.
|
||||||
messageTexts.add(message.message.trim());
|
const hasFirstMessage = messages.some((msg: WorkflowMessage) =>
|
||||||
}
|
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
|
||||||
});
|
);
|
||||||
|
|
||||||
if (optimisticMessage && optimisticMessage.message) {
|
if (hasFirstMessage) {
|
||||||
const optimisticText = optimisticMessage.message.trim();
|
setOptimisticMessage(null);
|
||||||
const optimisticFileIds = extractFileIdsFromMessage(optimisticMessage);
|
|
||||||
|
|
||||||
const matchingMessage = Array.from(messages).find((msg: WorkflowMessage) =>
|
|
||||||
msg.message && msg.message.trim() === optimisticText
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingMessage) {
|
|
||||||
const matchingFileIds = extractFileIdsFromMessage(matchingMessage);
|
|
||||||
|
|
||||||
if (optimisticFileIds.size > 0) {
|
|
||||||
const allFilesConfirmed = Array.from(optimisticFileIds).every(fileId =>
|
|
||||||
matchingFileIds.has(fileId)
|
|
||||||
);
|
|
||||||
if (allFilesConfirmed && matchingFileIds.size > 0) {
|
|
||||||
setOptimisticMessage(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (messageTexts.has(optimisticText)) {
|
|
||||||
setOptimisticMessage(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [messages, optimisticMessage]);
|
}, [messages, optimisticMessage]);
|
||||||
|
|
||||||
const displayMessages = useMemo(() => {
|
const displayMessages = useMemo(() => {
|
||||||
const optimisticText = optimisticMessage?.message?.trim();
|
|
||||||
|
|
||||||
const processedMessages = (messages || []).map((message: WorkflowMessage) => {
|
const processedMessages = (messages || []).map((message: WorkflowMessage) => {
|
||||||
const files = (message as any).files as any[] | undefined;
|
const files = (message as any).files as any[] | undefined;
|
||||||
const documents = (message as any).documents as MessageDocument[] | undefined;
|
const documents = (message as any).documents as MessageDocument[] | undefined;
|
||||||
|
|
@ -331,37 +308,19 @@ export function useDashboardInputForm(instanceId: string) {
|
||||||
return message;
|
return message;
|
||||||
});
|
});
|
||||||
|
|
||||||
let replacedMessageTimestamp: number | undefined;
|
// If optimistic message is still active (backend "first" message not yet polled),
|
||||||
const filteredMessages = processedMessages.filter((message: WorkflowMessage) => {
|
// show the optimistic message instead of any backend user messages to avoid duplicates.
|
||||||
const isUserMessage = message.role?.toLowerCase() === 'user';
|
const allMessages = [...processedMessages];
|
||||||
const messageText = message.message?.trim();
|
|
||||||
|
|
||||||
if (optimisticMessage && optimisticText && isUserMessage && messageText === optimisticText) {
|
|
||||||
const documents = (message as any).documents as MessageDocument[] | undefined;
|
|
||||||
const files = (message as any).files as any[] | undefined;
|
|
||||||
const hasDocuments = documents && Array.isArray(documents) && documents.length > 0;
|
|
||||||
const hasFiles = files && Array.isArray(files) && files.length > 0;
|
|
||||||
|
|
||||||
if (hasDocuments || hasFiles) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.publishedAt !== undefined) {
|
|
||||||
replacedMessageTimestamp = message.publishedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const allMessages = [...filteredMessages];
|
|
||||||
if (optimisticMessage) {
|
if (optimisticMessage) {
|
||||||
const optimisticWithTimestamp = replacedMessageTimestamp !== undefined
|
// Find backend "first" user message to inherit its timestamp for correct ordering
|
||||||
? { ...optimisticMessage, publishedAt: replacedMessageTimestamp }
|
const firstBackendMsg = processedMessages.find((msg: WorkflowMessage) =>
|
||||||
: optimisticMessage;
|
(msg as any).status === 'first' && msg.role?.toLowerCase() === 'user'
|
||||||
allMessages.push(optimisticWithTimestamp);
|
);
|
||||||
|
if (!firstBackendMsg) {
|
||||||
|
// Backend "first" message not yet arrived - show optimistic message
|
||||||
|
allMessages.push(optimisticMessage);
|
||||||
|
}
|
||||||
|
// If firstBackendMsg exists, the useEffect above will clear optimistic on next render
|
||||||
}
|
}
|
||||||
|
|
||||||
return allMessages.sort(sortMessages);
|
return allMessages.sort(sortMessages);
|
||||||
|
|
|
||||||
|
|
@ -256,26 +256,12 @@ export function useChatbot(): ChatbotHookReturn {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For user messages, check if we already have a temporary one with same content
|
// Backend sends the "first" message with the transformed/normalized user prompt
|
||||||
// Only replace if it's the temporary message we just created (by ID match)
|
// Replace the temporary optimistic message with it
|
||||||
if (message.role === 'user' && message.message === inputMessageContent) {
|
if (message.status === 'first') {
|
||||||
// Check if we have the exact temporary message we created
|
return prev.map(m =>
|
||||||
const hasTempMessage = prev.some(m => m.id === tempUserMessageId);
|
m.id === tempUserMessageId ? message : m
|
||||||
if (hasTempMessage) {
|
|
||||||
// Replace the temporary message with the real one from backend
|
|
||||||
return prev.map(m =>
|
|
||||||
m.id === tempUserMessageId ? message : m
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// If no temp message found, check if this is a duplicate of an existing real message
|
|
||||||
const isDuplicate = prev.some(m =>
|
|
||||||
m.role === 'user' &&
|
|
||||||
m.message === inputMessageContent &&
|
|
||||||
!m.id.startsWith('temp-')
|
|
||||||
);
|
);
|
||||||
if (isDuplicate) {
|
|
||||||
return prev; // Don't add duplicate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other messages, check for duplicates by role and content (more lenient check)
|
// For other messages, check for duplicates by role and content (more lenient check)
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export interface InvitationCreate {
|
||||||
email?: string;
|
email?: string;
|
||||||
roleIds: string[];
|
roleIds: string[];
|
||||||
featureInstanceId?: string;
|
featureInstanceId?: string;
|
||||||
|
frontendUrl?: string;
|
||||||
expiresInHours?: number;
|
expiresInHours?: number;
|
||||||
maxUses?: number;
|
maxUses?: number;
|
||||||
}
|
}
|
||||||
|
|
@ -117,6 +118,7 @@ export function useInvitations() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
params.append('frontendUrl', window.location.origin);
|
||||||
if (fetchOptions?.includeUsed) params.append('includeUsed', 'true');
|
if (fetchOptions?.includeUsed) params.append('includeUsed', 'true');
|
||||||
if (fetchOptions?.includeExpired) params.append('includeExpired', 'true');
|
if (fetchOptions?.includeExpired) params.append('includeExpired', 'true');
|
||||||
if (Object.keys(paginationParams).length > 0) {
|
if (Object.keys(paginationParams).length > 0) {
|
||||||
|
|
@ -160,7 +162,11 @@ export function useInvitations() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/api/invitations/', data, {
|
const payload = {
|
||||||
|
...data,
|
||||||
|
frontendUrl: data.frontendUrl || window.location.origin,
|
||||||
|
};
|
||||||
|
const response = await api.post('/api/invitations/', payload, {
|
||||||
headers: { 'X-Mandate-Id': mandateId }
|
headers: { 'X-Mandate-Id': mandateId }
|
||||||
});
|
});
|
||||||
return { success: true, data: response.data };
|
return { success: true, data: response.data };
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export interface Mandate {
|
||||||
name: string | { [key: string]: string };
|
name: string | { [key: string]: string };
|
||||||
code?: string;
|
code?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
isSystem?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -226,7 +227,10 @@ export function useUserMandates() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all available roles (global and mandate-specific, excluding feature-instance roles)
|
* Fetch available roles for a mandate (mandate-instance roles only).
|
||||||
|
* Each mandate has its own instances of system roles (admin, user, viewer)
|
||||||
|
* copied from templates during mandate creation. Only these mandate-bound
|
||||||
|
* roles should be offered for user assignment - NOT global templates.
|
||||||
*/
|
*/
|
||||||
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
|
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -238,15 +242,15 @@ export function useUserMandates() {
|
||||||
roles = response.data;
|
roles = response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to global roles and mandate-specific roles only
|
// Only mandate-instance roles (mandateId matches, no featureInstanceId)
|
||||||
// Exclude feature-instance roles (they have featureInstanceId set)
|
// Global templates (mandateId=null) are NOT assignable to users
|
||||||
if (mandateId) {
|
if (mandateId) {
|
||||||
return roles.filter(r =>
|
return roles.filter(r =>
|
||||||
!r.featureInstanceId && (!r.mandateId || r.mandateId === mandateId)
|
!r.featureInstanceId && r.mandateId === mandateId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Without mandateId, return only global roles (no mandateId and no featureInstanceId)
|
// Without mandateId, no roles available (roles are always mandate-specific)
|
||||||
return roles.filter(r => !r.mandateId && !r.featureInstanceId);
|
return [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error fetching roles:', err);
|
console.error('Error fetching roles:', err);
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export const InvitePage: React.FC = () => {
|
||||||
const [accepting, setAccepting] = useState(false);
|
const [accepting, setAccepting] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [userExists, setUserExists] = useState<boolean | null>(null);
|
||||||
|
|
||||||
// Validate token on mount
|
// Validate token on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -56,7 +57,6 @@ export const InvitePage: React.FC = () => {
|
||||||
|
|
||||||
const result = await validateInvitation(token);
|
const result = await validateInvitation(token);
|
||||||
setValidation(result);
|
setValidation(result);
|
||||||
setValidating(false);
|
|
||||||
|
|
||||||
// If invitation is valid but user is not authenticated,
|
// If invitation is valid but user is not authenticated,
|
||||||
// store the token for later use after login/registration
|
// store the token for later use after login/registration
|
||||||
|
|
@ -64,7 +64,24 @@ export const InvitePage: React.FC = () => {
|
||||||
// (e.g., when user opens password reset email in a new tab)
|
// (e.g., when user opens password reset email in a new tab)
|
||||||
if (result.valid && !isAuthenticated) {
|
if (result.valid && !isAuthenticated) {
|
||||||
localStorage.setItem(PENDING_INVITATION_KEY, token);
|
localStorage.setItem(PENDING_INVITATION_KEY, token);
|
||||||
|
|
||||||
|
// Check if the target username already has an account
|
||||||
|
if (result.targetUsername) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/local/available?username=${encodeURIComponent(result.targetUsername)}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
// available=true means username is free -> user does NOT exist
|
||||||
|
setUserExists(!data.available);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// On error, default to showing both options
|
||||||
|
setUserExists(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setValidating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
validate();
|
validate();
|
||||||
|
|
@ -222,7 +239,7 @@ export const InvitePage: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not authenticated - show login/register options (NO inline registration form)
|
// Not authenticated - show appropriate options based on whether user account exists
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
|
|
@ -254,9 +271,11 @@ export const InvitePage: React.FC = () => {
|
||||||
|
|
||||||
<div className={styles.authPrompt}>
|
<div className={styles.authPrompt}>
|
||||||
<p>
|
<p>
|
||||||
{validation.targetUsername
|
{userExists === true
|
||||||
? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.`
|
? `Bitte melden Sie sich als "${validation.targetUsername}" an, um die Einladung anzunehmen.`
|
||||||
: 'Bitte melden Sie sich an, um die Einladung anzunehmen.'}
|
: userExists === false
|
||||||
|
? 'Bitte erstellen Sie ein Konto, um die Einladung anzunehmen.'
|
||||||
|
: 'Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -267,29 +286,48 @@ export const InvitePage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.authActions}>
|
<div className={styles.authActions}>
|
||||||
<button
|
{userExists === true ? (
|
||||||
className={styles.primaryButton}
|
<button
|
||||||
onClick={handleLoginRedirect}
|
className={styles.primaryButton}
|
||||||
>
|
onClick={handleLoginRedirect}
|
||||||
<FaSignInAlt /> Anmelden
|
>
|
||||||
</button>
|
<FaSignInAlt /> Anmelden
|
||||||
|
</button>
|
||||||
<div className={styles.divider}>
|
) : userExists === false ? (
|
||||||
<span>oder</span>
|
<button
|
||||||
</div>
|
className={styles.primaryButton}
|
||||||
|
onClick={handleRegisterRedirect}
|
||||||
<button
|
>
|
||||||
className={styles.secondaryButton}
|
<FaUserPlus /> Konto erstellen
|
||||||
onClick={handleRegisterRedirect}
|
</button>
|
||||||
>
|
) : (
|
||||||
<FaUserPlus /> Neues Konto erstellen
|
<>
|
||||||
</button>
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleLoginRedirect}
|
||||||
|
>
|
||||||
|
<FaSignInAlt /> Anmelden
|
||||||
|
</button>
|
||||||
|
<div className={styles.divider}>
|
||||||
|
<span>oder</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={handleRegisterRedirect}
|
||||||
|
>
|
||||||
|
<FaUserPlus /> Neues Konto erstellen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.authInfo}>
|
<div className={styles.authInfo}>
|
||||||
<p>
|
<p>
|
||||||
Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen.
|
{userExists === true
|
||||||
Die Einladung wird automatisch nach der Anmeldung akzeptiert.
|
? 'Melden Sie sich mit Ihrem bestehenden Konto an. Die Einladung wird automatisch nach der Anmeldung akzeptiert.'
|
||||||
|
: userExists === false
|
||||||
|
? 'Erstellen Sie ein neues Konto. Die Einladung wird automatisch nach der Registrierung akzeptiert.'
|
||||||
|
: 'Die Einladung wird automatisch nach der Anmeldung akzeptiert.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
// State
|
// State
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
|
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
|
||||||
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
|
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Cleanup state
|
// Cleanup state
|
||||||
|
|
@ -117,19 +117,24 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
setExpandedRoleId(prev => prev === roleId ? null : roleId);
|
setExpandedRoleId(prev => prev === roleId ? null : roleId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if a role is a template (not bound to a specific mandate)
|
||||||
|
const _isTemplateRole = (role: Role): boolean => {
|
||||||
|
return !!role.isSystemRole || !role.mandateId;
|
||||||
|
};
|
||||||
|
|
||||||
// Get scope badge
|
// Get scope badge
|
||||||
const getScopeBadge = (role: Role) => {
|
const getScopeBadge = (role: Role) => {
|
||||||
if (role.isSystemRole) {
|
if (role.isSystemRole) {
|
||||||
return (
|
return (
|
||||||
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
||||||
<FaUserShield style={{ marginRight: 4 }} /> System
|
<FaUserShield style={{ marginRight: 4 }} /> System-Template
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!role.mandateId) {
|
if (!role.mandateId) {
|
||||||
return (
|
return (
|
||||||
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
||||||
<FaGlobe style={{ marginRight: 4 }} /> Global
|
<FaGlobe style={{ marginRight: 4 }} /> Template
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -185,9 +190,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
|
|
||||||
// Filter options for scope
|
// Filter options for scope
|
||||||
const scopeOptions = useMemo(() => [
|
const scopeOptions = useMemo(() => [
|
||||||
{ value: 'all', label: 'Alle Rollen' },
|
{ value: 'mandate', label: 'Mandanten-Rollen' },
|
||||||
{ value: 'mandate', label: 'Nur Mandanten-Rollen' },
|
{ value: 'all', label: 'Alle (inkl. Templates)' },
|
||||||
{ value: 'global', label: 'Nur globale Rollen' },
|
{ value: 'global', label: 'Nur Templates' },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -274,7 +279,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
||||||
<span>
|
<span>
|
||||||
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
|
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
|
||||||
Alle Rollen-Berechtigungen sind bearbeitbar (System-Rollen-Namen sind geschützt).
|
<strong> Template-Rollen</strong> sind schreibgeschützt - Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.
|
||||||
|
<strong> Mandanten-Rollen</strong> sind direkt bearbeitbar.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -293,9 +299,9 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
<p>Keine Rollen gefunden</p>
|
<p>Keine Rollen gefunden</p>
|
||||||
<p className={styles.emptyHint}>
|
<p className={styles.emptyHint}>
|
||||||
{scopeFilter === 'mandate'
|
{scopeFilter === 'mandate'
|
||||||
? 'Es gibt noch keine mandantenspezifischen Rollen.'
|
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
|
||||||
: scopeFilter === 'global'
|
: scopeFilter === 'global'
|
||||||
? 'Es gibt noch keine globalen Rollen.'
|
? 'Es gibt noch keine Rollen-Templates.'
|
||||||
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
|
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -328,11 +334,20 @@ export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||||
{/* Expanded Content - AccessRulesEditor */}
|
{/* Expanded Content - AccessRulesEditor */}
|
||||||
{expandedRoleId === role.id && (
|
{expandedRoleId === role.id && (
|
||||||
<div className={styles.roleContent}>
|
<div className={styles.roleContent}>
|
||||||
|
{_isTemplateRole(role) && (
|
||||||
|
<div className={styles.infoBox} style={{ marginBottom: '0.75rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||||
|
<FaUserShield style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }} />
|
||||||
|
<span>
|
||||||
|
Dies ist eine <strong>Template-Rolle</strong>. Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus.
|
||||||
|
Bestehende Mandanten-Instanzen werden nicht aktualisiert.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<AccessRulesEditor
|
<AccessRulesEditor
|
||||||
roleId={role.id}
|
roleId={role.id}
|
||||||
roleName={role.roleLabel}
|
roleName={role.roleLabel}
|
||||||
isTemplate={false}
|
isTemplate={_isTemplateRole(role)}
|
||||||
readOnly={false} // All AccessRules are editable (access controlled via RBAC)
|
readOnly={false}
|
||||||
apiBasePath="/api/rbac"
|
apiBasePath="/api/rbac"
|
||||||
mandateId={selectedMandateId}
|
mandateId={selectedMandateId}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
|
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
// Store current filter state for refetch
|
// Store current filter state for refetch
|
||||||
|
|
@ -126,14 +126,14 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
if (value === 'system') {
|
if (value === 'system') {
|
||||||
return (
|
return (
|
||||||
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
||||||
<FaUserShield style={{ marginRight: 4 }} /> System
|
<FaUserShield style={{ marginRight: 4 }} /> System-Template
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (value === 'global') {
|
if (value === 'global') {
|
||||||
return (
|
return (
|
||||||
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
||||||
<FaGlobe style={{ marginRight: 4 }} /> Global
|
<FaGlobe style={{ marginRight: 4 }} /> Template
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +164,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
default: 'mandate',
|
default: 'mandate',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'mandate', label: 'Nur dieser Mandant' },
|
{ value: 'mandate', label: 'Nur dieser Mandant' },
|
||||||
{ value: 'global', label: 'Global (alle Mandanten)' },
|
{ value: 'global', label: 'Template (wird bei neuen Mandanten kopiert)' },
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -359,9 +359,9 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
|
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
|
||||||
style={{ minWidth: 150 }}
|
style={{ minWidth: 150 }}
|
||||||
>
|
>
|
||||||
<option value="all">Alle Rollen</option>
|
<option value="mandate">Mandanten-Rollen</option>
|
||||||
<option value="mandate">Nur Mandanten-Rollen</option>
|
<option value="all">Alle (inkl. Templates)</option>
|
||||||
<option value="global">Nur globale Rollen</option>
|
<option value="global">Nur Templates</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -389,9 +389,9 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
<div className={styles.infoBox}>
|
<div className={styles.infoBox}>
|
||||||
<FaUserShield style={{ marginRight: 8 }} />
|
<FaUserShield style={{ marginRight: 8 }} />
|
||||||
<span>
|
<span>
|
||||||
<strong>System-Rollen</strong> (admin, user, viewer) können nicht bearbeitet oder gelöscht werden.
|
<strong>System-Templates</strong> (admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert.
|
||||||
<strong> Globale Rollen</strong> gelten für alle Mandanten.
|
Templates selbst können nicht gelöscht werden.
|
||||||
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten.
|
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -416,9 +416,9 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
|
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
|
||||||
<p className={styles.emptyDescription}>
|
<p className={styles.emptyDescription}>
|
||||||
{scopeFilter === 'mandate'
|
{scopeFilter === 'mandate'
|
||||||
? 'Es gibt noch keine mandantenspezifischen Rollen.'
|
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
|
||||||
: scopeFilter === 'global'
|
: scopeFilter === 'global'
|
||||||
? 'Es gibt noch keine globalen Rollen.'
|
? 'Es gibt noch keine Rollen-Templates.'
|
||||||
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
|
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
|
@ -522,7 +522,7 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
|
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
|
||||||
<FaUserShield style={{ marginRight: 8 }} />
|
<FaUserShield style={{ marginRight: 8 }} />
|
||||||
<span>
|
<span>
|
||||||
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandant-spezifisch' : 'Global'}</strong>
|
Geltungsbereich: <strong>{editingRole.mandateId ? 'Mandanten-Instanz' : 'Template (global)'}</strong>
|
||||||
{' '}(kann nicht geändert werden)
|
{' '}(kann nicht geändert werden)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
|
import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaBuilding, FaUsers } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
export const AdminMandatesPage: React.FC = () => {
|
export const AdminMandatesPage: React.FC = () => {
|
||||||
|
|
@ -42,6 +42,11 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
})) as AttributeDefinition[];
|
})) as AttributeDefinition[];
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Create form attributes - exclude isSystem (only set by system, not user)
|
||||||
|
const createFormAttributes: AttributeDefinition[] = useMemo(() => {
|
||||||
|
return formAttributes.filter(attr => attr.name !== 'isSystem');
|
||||||
|
}, [formAttributes]);
|
||||||
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
|
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
|
||||||
|
|
||||||
|
|
@ -76,7 +81,11 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete (confirmation handled by DeleteActionButton)
|
// Handle delete (confirmation handled by DeleteActionButton)
|
||||||
|
// System mandates (isSystem=true) are protected from deletion
|
||||||
const handleDeleteMandate = async (mandate: Mandate) => {
|
const handleDeleteMandate = async (mandate: Mandate) => {
|
||||||
|
if (mandate.isSystem) {
|
||||||
|
return; // Safety guard - should not be reachable due to disabled button
|
||||||
|
}
|
||||||
await handleDelete(mandate.id);
|
await handleDelete(mandate.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -169,6 +178,9 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
...(canDelete ? [{
|
...(canDelete ? [{
|
||||||
type: 'delete' as const,
|
type: 'delete' as const,
|
||||||
title: 'Löschen',
|
title: 'Löschen',
|
||||||
|
disabled: (row: Mandate) => row.isSystem
|
||||||
|
? { disabled: true, message: 'System-Mandanten können nicht gelöscht werden' }
|
||||||
|
: false
|
||||||
}] : []),
|
}] : []),
|
||||||
]}
|
]}
|
||||||
onDelete={handleDeleteMandate}
|
onDelete={handleDeleteMandate}
|
||||||
|
|
@ -199,14 +211,14 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{formAttributes.length === 0 ? (
|
{createFormAttributes.length === 0 ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
<span>Lade Formular...</span>
|
<span>Lade Formular...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<FormGeneratorForm
|
<FormGeneratorForm
|
||||||
attributes={formAttributes}
|
attributes={createFormAttributes}
|
||||||
mode="create"
|
mode="create"
|
||||||
onSubmit={handleCreateSubmit}
|
onSubmit={handleCreateSubmit}
|
||||||
onCancel={() => setShowCreateModal(false)}
|
onCancel={() => setShowCreateModal(false)}
|
||||||
|
|
@ -233,6 +245,14 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
|
{editingMandate.isSystem && (
|
||||||
|
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||||
|
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||||
|
<span>
|
||||||
|
Dies ist ein <strong>System-Mandant</strong>. Er kann nicht gelöscht werden und der Name sollte nicht geändert werden.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{formAttributes.length === 0 ? (
|
{formAttributes.length === 0 ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.spinner} />
|
<div className={styles.spinner} />
|
||||||
|
|
|
||||||
|
|
@ -774,6 +774,7 @@ export const PlaygroundPage: React.FC = () => {
|
||||||
selectedProviders={selectedProviders}
|
selectedProviders={selectedProviders}
|
||||||
onChange={onProvidersChange}
|
onChange={onProvidersChange}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
|
excludeByDefault={['privatellm']}
|
||||||
/>
|
/>
|
||||||
<VoiceLanguageSelect
|
<VoiceLanguageSelect
|
||||||
value={voiceLanguage}
|
value={voiceLanguage}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue