diff --git a/src/App.tsx b/src/App.tsx
index 163c09d5..d95ce5ee 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -78,6 +78,7 @@ const SupportDashboardScreen = lazy(() => import('./screens/support-dashboard.sc
const SupportDashboardIssueScreen = lazy(() => import('./screens/support-dashboard-issue.screen'));
const SupportDashboardCreateScreen = lazy(() => import('./screens/support-dashboard-create.screen'));
const NotesScreen = lazy(() => import('./screens/notes.screen'));
+const TemplatesScreen = lazy(() => import('./screens/support-templates.screen'));
const RealunitScreen = lazy(() => import('./screens/realunit.screen'));
const RealunitHoldersScreen = lazy(() => import('./screens/realunit-holders.screen'));
const RealunitQuotesScreen = lazy(() => import('./screens/realunit-quotes.screen'));
@@ -94,6 +95,7 @@ const DashboardFinancialHistoryScreen = lazy(() => import('./screens/dashboard-f
const DashboardFinancialLiveScreen = lazy(() => import('./screens/dashboard-financial-live.screen'));
const DashboardFinancialExpensesScreen = lazy(() => import('./screens/dashboard-financial-expenses.screen'));
const DashboardFinancialLiquidityScreen = lazy(() => import('./screens/dashboard-financial-liquidity.screen'));
+const DashboardRealunitTracingScreen = lazy(() => import('./screens/dashboard-realunit-tracing.screen'));
const SitemapScreen = lazy(() => import('./screens/sitemap.screen'));
setupLanguages();
@@ -464,6 +466,10 @@ export const Routes = [
path: 'notes',
element: withSuspense(),
},
+ {
+ path: 'templates',
+ element: withSuspense(),
+ },
{
path: 'realunit',
element: (
@@ -543,6 +549,10 @@ export const Routes = [
},
],
},
+ {
+ path: 'realunit-tracing',
+ element: withSuspense(),
+ },
],
},
],
diff --git a/src/components/compliance/note-list.tsx b/src/components/compliance/note-list.tsx
index 22bcba8f..02e4b5a1 100644
--- a/src/components/compliance/note-list.tsx
+++ b/src/components/compliance/note-list.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react';
+import { MdDelete, MdEdit } from 'react-icons/md';
import { useNavigate } from 'react-router-dom';
import { ConfirmDialog } from 'src/components/confirm-dialog';
import { SupportNoteInfo, useCompliance } from 'src/hooks/compliance.hook';
@@ -101,19 +102,19 @@ export function NoteList({ notes, showUserDataIdLink, emptyMessage, onChange }:
)}
diff --git a/src/components/modal.tsx b/src/components/modal.tsx
index 4609adb6..69ed95e3 100644
--- a/src/components/modal.tsx
+++ b/src/components/modal.tsx
@@ -7,9 +7,17 @@ interface ModalProps extends PropsWithChildren {
onClose?: () => void;
variant?: 'fullscreen' | 'dialog';
className?: string;
+ maxWidthClass?: string;
}
-export function Modal({ isOpen, onClose, children, variant = 'fullscreen', className = '' }: ModalProps): JSX.Element | null {
+export function Modal({
+ isOpen,
+ onClose,
+ children,
+ variant = 'fullscreen',
+ className = '',
+ maxWidthClass = 'max-w-screen-md',
+}: ModalProps): JSX.Element | null {
const [mounted, setMounted] = useState(false);
const [topOffset, setTopOffset] = useState(0);
const { modalRootRef, rootRef } = useLayoutContext();
@@ -61,7 +69,7 @@ export function Modal({ isOpen, onClose, children, variant = 'fullscreen', class
onClick={(e) => e.target === e.currentTarget && onClose?.()}
>
-
{children}
+
{children}
,
rootRef.current,
@@ -74,7 +82,7 @@ export function Modal({ isOpen, onClose, children, variant = 'fullscreen', class
style={{ top: topOffset }}
>
-
{children}
+
{children}
,
rootRef.current,
diff --git a/src/components/support-templates/bilingual-content-editor.tsx b/src/components/support-templates/bilingual-content-editor.tsx
new file mode 100644
index 00000000..9297ff53
--- /dev/null
+++ b/src/components/support-templates/bilingual-content-editor.tsx
@@ -0,0 +1,112 @@
+import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
+import {
+ TEMPLATE_CONTENT_MAX_LENGTH,
+ TEMPLATE_LANGUAGES,
+ TemplateContents,
+ TemplateLanguage,
+} from 'src/hooks/support-templates.hook';
+import { CharRemainingHint } from './char-remaining-hint';
+
+export interface BilingualContentEditorHandle {
+ insertAtActive: (text: string) => void;
+}
+
+interface Props {
+ contents: TemplateContents;
+ onChange: (contents: TemplateContents) => void;
+ disabled?: boolean;
+ placeholderDe?: string;
+ placeholderEn?: string;
+}
+
+const LANG_LABELS: Record = {
+ de: 'Deutsch',
+ en: 'English',
+};
+
+export const BilingualContentEditor = forwardRef(function BilingualContentEditor(
+ { contents, onChange, disabled, placeholderDe, placeholderEn },
+ ref,
+) {
+ const refs = useRef>({ de: null, en: null });
+ const [activeLang, setActiveLang] = useState('de');
+
+ function autoResize(ta: HTMLTextAreaElement | null): void {
+ if (!ta) return;
+ ta.style.height = 'auto';
+ ta.style.height = `${ta.scrollHeight}px`;
+ }
+
+ // Re-fit on every contents change (typing, token insert, external setContents)
+ useEffect(() => {
+ TEMPLATE_LANGUAGES.forEach((lang) => autoResize(refs.current[lang]));
+ }, [contents]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ insertAtActive(text: string) {
+ const ta = refs.current[activeLang];
+ const currentValue = contents[activeLang] ?? '';
+ if (!ta) {
+ onChange({ ...contents, [activeLang]: currentValue + text });
+ return;
+ }
+ const start = ta.selectionStart;
+ const end = ta.selectionEnd;
+ const next = currentValue.slice(0, start) + text + currentValue.slice(end);
+ onChange({ ...contents, [activeLang]: next });
+ requestAnimationFrame(() => {
+ ta.focus();
+ const pos = start + text.length;
+ ta.setSelectionRange(pos, pos);
+ });
+ },
+ }),
+ [activeLang, contents, onChange],
+ );
+
+ function updateLang(lang: TemplateLanguage, value: string): void {
+ if (lang === 'de') onChange({ ...contents, de: value });
+ else onChange({ ...contents, en: value || undefined });
+ }
+
+ return (
+
+ {TEMPLATE_LANGUAGES.map((lang) => {
+ const value = contents[lang] ?? '';
+ const isActive = activeLang === lang;
+ return (
+
+
+
+ {LANG_LABELS[lang]}
+ {lang === 'en' && (optional)}
+
+ {isActive && ● aktiv}
+
+
+ );
+ })}
+
+ );
+});
diff --git a/src/components/support-templates/char-remaining-hint.tsx b/src/components/support-templates/char-remaining-hint.tsx
new file mode 100644
index 00000000..b0ce0dcd
--- /dev/null
+++ b/src/components/support-templates/char-remaining-hint.tsx
@@ -0,0 +1,14 @@
+interface Props {
+ value: string;
+ max: number;
+}
+
+export function CharRemainingHint({ value, max }: Readonly): JSX.Element {
+ const remaining = max - value.length;
+ const isAtLimit = remaining === 0;
+ return (
+
+ noch {remaining} Zeichen
+
+ );
+}
diff --git a/src/components/support-templates/template-array-picker-modal.tsx b/src/components/support-templates/template-array-picker-modal.tsx
new file mode 100644
index 00000000..0cdb7ad7
--- /dev/null
+++ b/src/components/support-templates/template-array-picker-modal.tsx
@@ -0,0 +1,77 @@
+import { TransactionInfo } from 'src/hooks/compliance.hook';
+import { Modal } from 'src/components/modal';
+import { formatDateTime } from 'src/util/compliance-helpers';
+
+interface Props {
+ isOpen: boolean;
+ transactions: TransactionInfo[];
+ onSelect: (transactionId: number) => void;
+ onCancel: () => void;
+}
+
+export function TemplateArrayPickerModal({ isOpen, transactions, onSelect, onCancel }: Readonly): JSX.Element {
+ return (
+
+
+
+
Transaktion auswählen
+
+
+
+ Die Vorlage enthält Platzhalter für eine Transaktion. Bitte wähle aus, welche verwendet werden soll.
+
+
+
+
+
+ | ID |
+ UID |
+ Type |
+ CHF |
+ Input |
+ Created |
+
+
+
+ {transactions.map((tx) => (
+ onSelect(tx.id)}
+ >
+ | {tx.id} |
+ {tx.uid} |
+ {tx.type ?? '-'} |
+
+ {tx.amountInChf != null ? tx.amountInChf.toFixed(2) : '-'}
+ |
+
+ {tx.inputAmount != null ? `${tx.inputAmount} ${tx.inputAsset ?? ''}` : '-'}
+ |
+
+ {formatDateTime(tx.created)}
+ |
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/support-templates/template-composer.tsx b/src/components/support-templates/template-composer.tsx
new file mode 100644
index 00000000..dbcb05ce
--- /dev/null
+++ b/src/components/support-templates/template-composer.tsx
@@ -0,0 +1,92 @@
+import { useRef, useState } from 'react';
+import { TEMPLATE_NAME_MAX_LENGTH, TemplateContents, useTemplates } from 'src/hooks/support-templates.hook';
+import { BilingualContentEditor, BilingualContentEditorHandle } from './bilingual-content-editor';
+import { CharRemainingHint } from './char-remaining-hint';
+import { TokenPickerPanel } from './token-picker-panel';
+
+interface Props {
+ onCreated: () => void;
+}
+
+const EMPTY_CONTENTS: TemplateContents = { de: '', en: undefined };
+
+export function TemplateComposer({ onCreated }: Readonly): JSX.Element {
+ const { createTemplate } = useTemplates();
+ const editorRef = useRef(null);
+
+ const [name, setName] = useState('');
+ const [contents, setContents] = useState(EMPTY_CONTENTS);
+ const [error, setError] = useState();
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [showTokens, setShowTokens] = useState(false);
+
+ function insertToken(key: string): void {
+ editorRef.current?.insertAtActive(`$${key}`);
+ }
+
+ async function handleSubmit(): Promise {
+ if (!name.trim() || !contents.de.trim()) return;
+ setError(undefined);
+ setIsSubmitting(true);
+ try {
+ await createTemplate(name.trim(), {
+ de: contents.de.trim(),
+ en: contents.en?.trim() || undefined,
+ });
+ setName('');
+ setContents(EMPTY_CONTENTS);
+ onCreated();
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : 'Failed to save template');
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+
+
+ setName(e.target.value)}
+ placeholder="Name der Vorlage"
+ maxLength={TEMPLATE_NAME_MAX_LENGTH}
+ disabled={isSubmitting}
+ />
+
+
+
+
+
+ Syntax: $tabelle.feld
+
+ {showTokens &&
}
+ {error &&
{error}
}
+
+
+
+
+ );
+}
diff --git a/src/components/support-templates/template-list.tsx b/src/components/support-templates/template-list.tsx
new file mode 100644
index 00000000..5855bacb
--- /dev/null
+++ b/src/components/support-templates/template-list.tsx
@@ -0,0 +1,219 @@
+import { useRef, useState } from 'react';
+import { MdDelete, MdEdit } from 'react-icons/md';
+import { ConfirmDialog } from 'src/components/confirm-dialog';
+import {
+ SupportIssueTemplateInfo,
+ TemplateContents,
+ TEMPLATE_GROUP_LABELS,
+ TEMPLATE_LANGUAGES,
+ TEMPLATE_LANGUAGE_LABELS,
+ TEMPLATE_NAME_MAX_LENGTH,
+ groupTemplatesByOwnership,
+ useTemplates,
+} from 'src/hooks/support-templates.hook';
+import { formatDateTime } from 'src/util/compliance-helpers';
+import { BilingualContentEditor, BilingualContentEditorHandle } from './bilingual-content-editor';
+import { CharRemainingHint } from './char-remaining-hint';
+import { TokenPickerPanel } from './token-picker-panel';
+
+interface Props {
+ templates: SupportIssueTemplateInfo[];
+ emptyMessage?: string;
+ onChange: () => void;
+}
+
+const EMPTY_CONTENTS: TemplateContents = { de: '', en: undefined };
+
+function SectionHeader({ label }: Readonly<{ label: string }>): JSX.Element {
+ return (
+
+ );
+}
+
+export function TemplateList({ templates, emptyMessage, onChange }: Readonly): JSX.Element {
+ const { updateTemplate, deleteTemplate } = useTemplates();
+ const editorRef = useRef(null);
+
+ const [editingId, setEditingId] = useState();
+ const [editName, setEditName] = useState('');
+ const [editContents, setEditContents] = useState(EMPTY_CONTENTS);
+ const [showTokens, setShowTokens] = useState(false);
+ const [error, setError] = useState();
+
+ const [deleteId, setDeleteId] = useState();
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ function startEdit(template: SupportIssueTemplateInfo): void {
+ setEditingId(template.id);
+ setEditName(template.name);
+ setEditContents({ de: template.contents.de, en: template.contents.en });
+ setShowTokens(false);
+ }
+
+ function insertToken(key: string): void {
+ editorRef.current?.insertAtActive(`$${key}`);
+ }
+
+ async function handleUpdate(id: number): Promise {
+ if (!editName.trim() || !editContents.de.trim()) return;
+ try {
+ await updateTemplate(id, {
+ name: editName.trim(),
+ contents: {
+ de: editContents.de.trim(),
+ en: editContents.en?.trim() ?? '',
+ },
+ });
+ setEditingId(undefined);
+ onChange();
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : 'Failed to update template');
+ }
+ }
+
+ async function confirmDelete(): Promise {
+ if (deleteId == null) return;
+ setIsDeleting(true);
+ try {
+ await deleteTemplate(deleteId);
+ setDeleteId(undefined);
+ onChange();
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : 'Failed to delete template');
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+
+ if (templates.length === 0) {
+ return (
+
+ {emptyMessage ?? 'Keine Vorlagen vorhanden.'}
+
+ );
+ }
+
+ const { own, foreign } = groupTemplatesByOwnership(templates);
+
+ function renderCard(template: SupportIssueTemplateInfo): JSX.Element {
+ const canModify = template.isOwn || template.isAdmin;
+ const isEditing = editingId === template.id;
+ return (
+
+
+
+ {template.name}
+ ·
+ {template.authorMail}
+ ·
+
+ {formatDateTime(template.updated ?? template.created)}
+ {template.updated && template.updated !== template.created && ' (bearbeitet)'}
+
+
+ {canModify && !isEditing && (
+
+
+
+
+ )}
+
+ {isEditing ? (
+ <>
+
+ setEditName(e.target.value)}
+ placeholder="Name der Vorlage"
+ maxLength={TEMPLATE_NAME_MAX_LENGTH}
+ />
+
+
+
+
+
+
+
+
+
+
+ {showTokens &&
}
+ >
+ ) : (
+
+ {TEMPLATE_LANGUAGES.map((lang) => {
+ const value = template.contents[lang];
+ if (!value) return null;
+ return (
+
+
{TEMPLATE_LANGUAGE_LABELS[lang]}
+
{value}
+
+ );
+ })}
+
+ )}
+
+ );
+ }
+
+ const showSectionHeaders = own.length > 0 && foreign.length > 0;
+ return (
+ <>
+ {error && {error}
}
+ {showSectionHeaders && own.length > 0 && }
+ {own.map(renderCard)}
+ {showSectionHeaders && foreign.length > 0 && }
+ {foreign.map(renderCard)}
+ t.id === deleteId)?.name ?? ''}' wirklich löschen?`}
+ confirmLabel="Löschen"
+ cancelLabel="Abbrechen"
+ destructive
+ isLoading={isDeleting}
+ onConfirm={confirmDelete}
+ onCancel={() => setDeleteId(undefined)}
+ />
+ >
+ );
+}
diff --git a/src/components/support-templates/template-picker-modal.tsx b/src/components/support-templates/template-picker-modal.tsx
new file mode 100644
index 00000000..34e78e8b
--- /dev/null
+++ b/src/components/support-templates/template-picker-modal.tsx
@@ -0,0 +1,493 @@
+import { useEffect, useMemo, useState } from 'react';
+import { MdWarning } from 'react-icons/md';
+import { ErrorHint } from 'src/components/error-hint';
+import { Modal } from 'src/components/modal';
+import {
+ SupportIssueTemplateInfo,
+ TEMPLATE_GROUP_LABELS,
+ TEMPLATE_LANGUAGES,
+ TEMPLATE_LANGUAGE_LABELS,
+ TemplateLanguage,
+ groupTemplatesByOwnership,
+ useTemplateOnlyOwn,
+ useTemplates,
+} from 'src/hooks/support-templates.hook';
+import {
+ DetectedToken,
+ detectPlaceholders,
+ getNonArrayMissingPlaceholders,
+ requiresArraySelection,
+ resolvePlaceholders,
+ TokenContext,
+} from 'src/util/template-placeholders';
+import { TemplateArrayPickerModal } from './template-array-picker-modal';
+
+interface Props {
+ isOpen: boolean;
+ context: TokenContext;
+ onClose: () => void;
+ onInsert: (text: string) => void;
+ /** Override für das Action-Button-Label. Wird im copyMode ignoriert. */
+ actionLabel?: string;
+ /** Aktiviert die State-Machine für den Copy-Workflow (Customer-Search). */
+ copyMode?: boolean;
+}
+
+const ARRAY_MARKER_VALUE = '[Auswahl beim Einfügen]';
+// Matches selector-less transaction tokens only (those with :selector are resolved directly)
+const ARRAY_TOKEN_REGEX = /\$transaction\.[a-zA-Z]+/g;
+
+function detectCustomerLanguage(context: TokenContext): TemplateLanguage {
+ // Prefer the language on the issue's account (always available with issueData),
+ // fall back to the lazy-loaded full userData for completeness.
+ const sym = (context.issue?.account?.language?.symbol ?? context.userData?.language?.symbol)?.toLowerCase();
+ if (sym === 'en') return 'en';
+ return 'de';
+}
+
+function pickContent(
+ template: SupportIssueTemplateInfo,
+ lang: TemplateLanguage,
+): { text: string; usedFallback: boolean } {
+ const direct = template.contents[lang];
+ if (direct) return { text: direct, usedFallback: false };
+ // Fallback to DE if requested variant is missing
+ return { text: template.contents.de, usedFallback: lang !== 'de' };
+}
+
+export function TemplatePickerModal({
+ isOpen,
+ context,
+ onClose,
+ onInsert,
+ actionLabel,
+ copyMode = false,
+}: Readonly): JSX.Element {
+ const { listTemplates } = useTemplates();
+
+ const [templates, setTemplates] = useState([]);
+ const [search, setSearch] = useState('');
+ const [selectedId, setSelectedId] = useState();
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState();
+
+ const customerLang = detectCustomerLanguage(context);
+ const [activeLang, setActiveLang] = useState(customerLang);
+
+ const [onlyOwn, setOnlyOwn] = useTemplateOnlyOwn();
+
+ // --- copyMode state machine ---
+ const [selections, setSelections] = useState<{ transactionId?: number }>({});
+ const [editedText, setEditedText] = useState();
+ const [arrayPickerOpen, setArrayPickerOpen] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) setActiveLang(customerLang);
+ }, [isOpen, customerLang]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setIsLoading(true);
+ setError(undefined);
+ listTemplates()
+ .then((items) => {
+ setTemplates(items);
+ const initial = onlyOwn ? items.filter((t) => t.isOwn) : items;
+ if (initial.length > 0 && selectedId == null) setSelectedId(initial[0].id);
+ })
+ .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load templates'))
+ .finally(() => setIsLoading(false));
+ }, [isOpen, listTemplates]);
+
+ // Drop the current selection if the onlyOwn toggle hides it; re-pick first of the visible set.
+ useEffect(() => {
+ if (!isOpen || templates.length === 0) return;
+ const visible = onlyOwn ? templates.filter((t) => t.isOwn) : templates;
+ if (selectedId != null && !visible.some((t) => t.id === selectedId)) {
+ setSelectedId(visible[0]?.id);
+ }
+ }, [isOpen, onlyOwn, templates, selectedId]);
+
+ // Reset transient copyMode state when template or language changes
+ useEffect(() => {
+ setSelections({});
+ setEditedText(undefined);
+ setArrayPickerOpen(false);
+ }, [selectedId, activeLang]);
+
+ // On close: reset transient state so a fresh open starts clean.
+ // `onlyOwn` is intentionally kept across openings — it's a user preference.
+ useEffect(() => {
+ if (isOpen) return;
+ setSelectedId(undefined);
+ setSearch('');
+ setSelections({});
+ setEditedText(undefined);
+ setArrayPickerOpen(false);
+ }, [isOpen]);
+
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ const scoped = onlyOwn ? templates.filter((t) => t.isOwn) : templates;
+ if (!q) return scoped;
+ return scoped.filter(
+ (t) =>
+ t.name.toLowerCase().includes(q) ||
+ t.contents.de.toLowerCase().includes(q) ||
+ (t.contents.en?.toLowerCase().includes(q) ?? false),
+ );
+ }, [templates, search, onlyOwn]);
+
+ const selected = templates.find((t) => t.id === selectedId);
+ const picked = selected ? pickContent(selected, activeLang) : undefined;
+ const pickedText = picked?.text ?? '';
+
+ // --- Insert-mode (default) computed values ---
+ const needsArraySelection = picked ? requiresArraySelection(pickedText, context) : false;
+ const insertPreview = picked ? resolvePlaceholders(pickedText, context) : '';
+ const insertPreviewWithMarker = needsArraySelection
+ ? insertPreview.replace(ARRAY_TOKEN_REGEX, ARRAY_MARKER_VALUE)
+ : insertPreview;
+ const nonArrayMissing = picked ? getNonArrayMissingPlaceholders(pickedText, context) : [];
+
+ // --- copyMode computed values ---
+ const baseResolved = useMemo(
+ () => (picked ? resolvePlaceholders(picked.text, context, selections) : ''),
+ [picked?.text, context, selections],
+ );
+ const currentText = editedText ?? baseResolved;
+ const remainingTokens = useMemo(() => detectPlaceholders(currentText), [currentText]);
+ const hasArrayTokens = remainingTokens.some((t) => t.source === 'transaction' && !t.selector);
+ // Array-Pick nur anbieten, solange keine TX gewählt wurde — sonst nützt eine erneute Auswahl nichts.
+ const canPickArray = hasArrayTokens && (context.transactions?.length ?? 0) > 1 && selections.transactionId == null;
+ const inEditMode = editedText !== undefined || (remainingTokens.length > 0 && !canPickArray);
+
+ function handleAction(): void {
+ if (!picked) return;
+ if (copyMode) {
+ if (canPickArray && !inEditMode) {
+ setArrayPickerOpen(true);
+ return;
+ }
+ if (remainingTokens.length > 0) return; // safety — button is disabled
+ onInsert(currentText);
+ onClose();
+ return;
+ }
+ onInsert(picked.text);
+ onClose();
+ }
+
+ const copyButtonLabel = canPickArray && !inEditMode ? 'Ausfüllen' : 'Kopieren';
+ const copyButtonDisabled = !(canPickArray && !inEditMode) && remainingTokens.length > 0;
+ const insertButtonLabel = actionLabel ?? (needsArraySelection ? 'Ausfüllen' : 'Einfügen');
+ const finalButtonLabel = copyMode ? copyButtonLabel : insertButtonLabel;
+ const finalButtonDisabled = !selected || (copyMode && copyButtonDisabled);
+
+ return (
+
+
+
+
+ Vorlage auswählen
+
+
+
+
+
+
+
setSearch(e.target.value)}
+ placeholder="Vorlage suchen..."
+ autoFocus
+ />
+
+ {TEMPLATE_LANGUAGES.map((lang) => (
+
+ ))}
+
+
+
+ {error && (
+
+
+
+ )}
+
+
+
+
+
+
+ {picked ? (
+
+ ) : (
+
Bitte links eine Vorlage auswählen.
+ )}
+
+
+
+
+
+
+
+
+
+ {copyMode && (
+ {
+ setSelections({ transactionId });
+ setArrayPickerOpen(false);
+ }}
+ onCancel={() => setArrayPickerOpen(false)}
+ />
+ )}
+
+ );
+}
+
+// ----- Sub-components -----
+
+interface TemplateListSectionProps {
+ isLoading: boolean;
+ templates: SupportIssueTemplateInfo[];
+ selectedId: number | undefined;
+ activeLang: TemplateLanguage;
+ onSelect: (id: number) => void;
+}
+
+function TemplateListSection({
+ isLoading,
+ templates,
+ selectedId,
+ activeLang,
+ onSelect,
+}: Readonly): JSX.Element {
+ if (isLoading) {
+ return Lade...
;
+ }
+ if (templates.length === 0) {
+ return Keine Vorlagen gefunden
;
+ }
+ const { own, foreign } = groupTemplatesByOwnership(templates);
+ const showSectionHeaders = own.length > 0 && foreign.length > 0;
+ function renderItem(t: SupportIssueTemplateInfo): JSX.Element {
+ const hasActive = !!t.contents[activeLang];
+ return (
+
+
+
+ );
+ }
+ function renderSectionHeader(label: string): JSX.Element {
+ return (
+
+ {label}
+
+ );
+ }
+ return (
+
+ {showSectionHeaders && renderSectionHeader(TEMPLATE_GROUP_LABELS.own)}
+ {own.map(renderItem)}
+ {showSectionHeaders && renderSectionHeader(TEMPLATE_GROUP_LABELS.foreign)}
+ {foreign.map(renderItem)}
+
+ );
+}
+
+interface PreviewSectionProps {
+ picked: { text: string; usedFallback: boolean };
+ activeLang: TemplateLanguage;
+ copyMode: boolean;
+ inEditMode: boolean;
+ editedText: string | undefined;
+ onEditChange: (text: string) => void;
+ currentText: string;
+ previewWithMarker: string;
+ remainingTokens: DetectedToken[];
+ needsArraySelection: boolean;
+ nonArrayMissing: DetectedToken[];
+ canPickArray: boolean;
+ hasTxSelection: boolean;
+}
+
+function PreviewSection(props: Readonly): JSX.Element {
+ const { picked, activeLang, copyMode } = props;
+ return (
+ <>
+
+ Vorschau ({TEMPLATE_LANGUAGE_LABELS[activeLang]}):
+ {picked.usedFallback && (
+
+
+ Variante in {TEMPLATE_LANGUAGE_LABELS[activeLang]} fehlt – Deutsch wird verwendet.
+
+ )}
+
+ {copyMode ? : }
+ >
+ );
+}
+
+function CopyModeContent({
+ inEditMode,
+ editedText,
+ onEditChange,
+ currentText,
+ remainingTokens,
+ canPickArray,
+ hasTxSelection,
+}: Readonly): JSX.Element {
+ return (
+ <>
+ {inEditMode && remainingTokens.length > 0 && (
+
+
+ {hasTxSelection && editedText === undefined
+ ? 'Die gewählte Transaktion enthält keinen Wert für folgende Platzhalter:'
+ : `Noch ${remainingTokens.length} Platzhalter offen:`}
+
+
+ {remainingTokens.map((t) => (
+
+ ${t.fullKey}
+
+ ))}
+
+
Bitte unten manuell mit echten Werten ersetzen.
+
+ )}
+ {inEditMode ? (
+