Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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();
Expand Down Expand Up @@ -464,6 +466,10 @@ export const Routes = [
path: 'notes',
element: withSuspense(<NotesScreen />),
},
{
path: 'templates',
element: withSuspense(<TemplatesScreen />),
},
{
path: 'realunit',
element: (
Expand Down Expand Up @@ -543,6 +549,10 @@ export const Routes = [
},
],
},
{
path: 'realunit-tracing',
element: withSuspense(<DashboardRealunitTracingScreen />),
},
],
},
],
Expand Down
9 changes: 5 additions & 4 deletions src/components/compliance/note-list.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -101,19 +102,19 @@ export function NoteList({ notes, showUserDataIdLink, emptyMessage, onChange }:
<div className="flex gap-1">
<button
type="button"
className="px-2 py-0.5 text-xs text-dfxBlue-800 hover:bg-dfxGray-300 rounded transition-colors"
className="p-1 text-dfxBlue-800 hover:bg-dfxGray-300 rounded transition-colors"
onClick={() => startEdit(note)}
title="Edit"
>
✏️
<MdEdit size={16} />
</button>
<button
type="button"
className="px-2 py-0.5 text-xs text-dfxBlue-800 hover:bg-dfxRed-100/20 rounded transition-colors"
className="p-1 text-dfxRed-100 hover:bg-dfxRed-100/20 rounded transition-colors"
onClick={() => setDeleteId(note.id)}
title="Delete"
>
🗑️
<MdDelete size={16} />
</button>
</div>
)}
Expand Down
14 changes: 11 additions & 3 deletions src/components/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -61,7 +69,7 @@ export function Modal({ isOpen, onClose, children, variant = 'fullscreen', class
onClick={(e) => e.target === e.currentTarget && onClose?.()}
>
<div className="flex min-h-full items-center justify-center p-4">
<div className="w-full max-w-screen-md flex flex-col">{children}</div>
<div className={`w-full ${maxWidthClass} flex flex-col`}>{children}</div>
</div>
</div>,
rootRef.current,
Expand All @@ -74,7 +82,7 @@ export function Modal({ isOpen, onClose, children, variant = 'fullscreen', class
style={{ top: topOffset }}
>
<div className="flex flex-grow justify-center h-full">
<div className="w-full max-w-screen-md flex flex-grow flex-col p-4">{children}</div>
<div className={`w-full ${maxWidthClass} flex flex-grow flex-col p-4`}>{children}</div>
</div>
</div>,
rootRef.current,
Expand Down
112 changes: 112 additions & 0 deletions src/components/support-templates/bilingual-content-editor.tsx
Original file line number Diff line number Diff line change
@@ -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<TemplateLanguage, string> = {
de: 'Deutsch',
en: 'English',
};

export const BilingualContentEditor = forwardRef<BilingualContentEditorHandle, Props>(function BilingualContentEditor(
{ contents, onChange, disabled, placeholderDe, placeholderEn },
ref,
) {
const refs = useRef<Record<TemplateLanguage, HTMLTextAreaElement | null>>({ de: null, en: null });
const [activeLang, setActiveLang] = useState<TemplateLanguage>('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 (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{TEMPLATE_LANGUAGES.map((lang) => {
const value = contents[lang] ?? '';
const isActive = activeLang === lang;
return (
<div key={lang} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span
className={`font-semibold ${isActive ? 'text-dfxBlue-800' : 'text-dfxGray-700'}`}
title={isActive ? 'Aktive Sprache (Platzhalter werden hier eingefügt)' : undefined}
>
{LANG_LABELS[lang]}
{lang === 'en' && <span className="ml-1 text-dfxGray-700 font-normal">(optional)</span>}
</span>
{isActive && <span className="text-[10px] text-dfxBlue-800">● aktiv</span>}
</div>
<textarea
ref={(el) => {
refs.current[lang] = el;
autoResize(el);
}}
className={`w-full px-3 py-2 text-sm border rounded bg-white text-dfxBlue-800 min-h-[200px] resize-none overflow-hidden ${
isActive ? 'border-dfxBlue-400' : 'border-dfxGray-400'
}`}
value={value}
onChange={(e) => updateLang(lang, e.target.value)}
onFocus={() => setActiveLang(lang)}
placeholder={lang === 'de' ? placeholderDe : placeholderEn}
maxLength={TEMPLATE_CONTENT_MAX_LENGTH}
disabled={disabled}
/>
<CharRemainingHint value={value} max={TEMPLATE_CONTENT_MAX_LENGTH} />
</div>
);
})}
</div>
);
});
14 changes: 14 additions & 0 deletions src/components/support-templates/char-remaining-hint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
interface Props {
value: string;
max: number;
}

export function CharRemainingHint({ value, max }: Readonly<Props>): JSX.Element {
const remaining = max - value.length;
const isAtLimit = remaining === 0;
return (
<div className={`text-[10px] text-right ${isAtLimit ? 'text-dfxRed-100' : 'text-dfxGray-700'}`}>
noch {remaining} Zeichen
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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<Props>): JSX.Element {
return (
<Modal isOpen={isOpen} onClose={onCancel} variant="dialog">
<div className="bg-white rounded-lg shadow-lg p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-dfxBlue-800 font-semibold">Transaktion auswählen</h2>
<button
type="button"
className="text-dfxGray-700 hover:text-dfxBlue-800 text-xl leading-none"
onClick={onCancel}
>
×
</button>
</div>
<p className="text-xs text-dfxGray-700">
Die Vorlage enthält Platzhalter für eine Transaktion. Bitte wähle aus, welche verwendet werden soll.
</p>
<div className="max-h-[60vh] overflow-auto border border-dfxGray-300 rounded">
<table className="w-full text-sm">
<thead className="bg-dfxGray-300 sticky top-0">
<tr>
<th className="px-2 py-1 text-left text-xs font-semibold text-dfxBlue-800">ID</th>
<th className="px-2 py-1 text-left text-xs font-semibold text-dfxBlue-800">UID</th>
<th className="px-2 py-1 text-left text-xs font-semibold text-dfxBlue-800">Type</th>
<th className="px-2 py-1 text-right text-xs font-semibold text-dfxBlue-800">CHF</th>
<th className="px-2 py-1 text-left text-xs font-semibold text-dfxBlue-800">Input</th>
<th className="px-2 py-1 text-left text-xs font-semibold text-dfxBlue-800">Created</th>
</tr>
</thead>
<tbody>
{transactions.map((tx) => (
<tr
key={tx.id}
className="border-b border-dfxGray-300 hover:bg-dfxBlue-400 cursor-pointer group"
onClick={() => onSelect(tx.id)}
>
<td className="px-2 py-1 text-xs text-dfxBlue-800 group-hover:text-white">{tx.id}</td>
<td className="px-2 py-1 text-xs font-mono text-dfxBlue-800 group-hover:text-white">{tx.uid}</td>
<td className="px-2 py-1 text-xs text-dfxBlue-800 group-hover:text-white">{tx.type ?? '-'}</td>
<td className="px-2 py-1 text-xs text-right text-dfxBlue-800 group-hover:text-white">
{tx.amountInChf != null ? tx.amountInChf.toFixed(2) : '-'}
</td>
<td className="px-2 py-1 text-xs text-dfxBlue-800 group-hover:text-white">
{tx.inputAmount != null ? `${tx.inputAmount} ${tx.inputAsset ?? ''}` : '-'}
</td>
<td className="px-2 py-1 text-xs text-dfxBlue-800 group-hover:text-white whitespace-nowrap">
{formatDateTime(tx.created)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end">
<button
type="button"
className="px-3 py-1 text-xs text-dfxBlue-800 bg-dfxGray-300 rounded hover:bg-dfxGray-400 transition-colors"
onClick={onCancel}
>
Abbrechen
</button>
</div>
</div>
</Modal>
);
}
Loading
Loading