split admin page in smaller components

This commit is contained in:
Jean Jacques Avril 2025-03-02 18:04:12 +00:00
parent 0886757f89
commit 9bf715ee88
12 changed files with 1003 additions and 733 deletions

View File

@ -25,10 +25,6 @@
{ {
"id": "abstain", "id": "abstain",
"label": "Ich enthalte mich" "label": "Ich enthalte mich"
},
{
"id": "123",
"label": "123"
} }
] ]
} }

View File

@ -2,8 +2,17 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { VoteOption } from '@/lib/survey'; import { VoteOption } from '@/lib/survey';
import QuillEditor from '@/components/QuillEditor'; import {
import MembersManager from '@/components/MembersManager'; LoginForm,
TextEditor,
TextEditorManager,
StatsButton,
StatsDisplay,
VoteOptionsManager,
MemberAuthManager,
TokenGenerator,
ResetVotes
} from '@/components/admin';
// Define types based on the data structure // Define types based on the data structure
interface Stats { interface Stats {
@ -23,13 +32,6 @@ interface EditableText {
} }
export default function AdminPage() { export default function AdminPage() {
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
const [bulkTokenCount, setBulkTokenCount] = useState<number>(10);
const [isGeneratingBulk, setIsGeneratingBulk] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [showStats, setShowStats] = useState(false); const [showStats, setShowStats] = useState(false);
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
@ -45,14 +47,9 @@ export default function AdminPage() {
{ id: 'no', label: 'Nein, ich stimme nicht zu' }, { id: 'no', label: 'Nein, ich stimme nicht zu' },
{ id: 'abstain', label: 'Ich enthalte mich' } { id: 'abstain', label: 'Ich enthalte mich' }
]); ]);
const [showVoteOptions, setShowVoteOptions] = useState(false);
const [newOptionId, setNewOptionId] = useState('');
const [newOptionLabel, setNewOptionLabel] = useState('');
const [selectedTextId, setSelectedTextId] = useState<string | null>(null); const [selectedTextId, setSelectedTextId] = useState<string | null>(null);
const [editorContent, setEditorContent] = useState(''); const [editorContent, setEditorContent] = useState('');
const [showMembers, setShowMembers] = useState(false);
const [memberAuthEnabled, setMemberAuthEnabled] = useState(false); const [memberAuthEnabled, setMemberAuthEnabled] = useState(false);
const [isTogglingMemberAuth, setIsTogglingMemberAuth] = useState(false);
// Check if already authenticated and load settings on component mount // Check if already authenticated and load settings on component mount
useEffect(() => { useEffect(() => {
@ -85,91 +82,6 @@ export default function AdminPage() {
checkAuthAndSettings(); checkAuthAndSettings();
}, []); }, []);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/generate-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Anmeldung fehlgeschlagen');
}
setIsAuthenticated(true);
setPassword(''); // Clear password field after successful login
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
const handleGenerateToken = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/generate-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}), // No need to send password, using JWT cookie
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Token konnte nicht generiert werden');
}
setGeneratedLink(data.voteUrl);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
const handleGetStats = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}), // No need to send password, using JWT cookie
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Statistiken konnten nicht abgerufen werden');
}
setStats(data.stats);
setComments(data.comments || []);
setShowStats(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
const handleEditText = (textId: string) => { const handleEditText = (textId: string) => {
const textToEdit = editableTexts.find(text => text.id === textId); const textToEdit = editableTexts.find(text => text.id === textId);
if (textToEdit) { if (textToEdit) {
@ -206,16 +118,14 @@ export default function AdminPage() {
} }
}, [isAuthenticated]); }, [isAuthenticated]);
const handleSaveText = async () => { const handleSaveText = async (content: string) => {
if (selectedTextId) { if (selectedTextId) {
setIsLoading(true);
try { try {
// Update local state // Update local state
setEditableTexts(prev => setEditableTexts(prev =>
prev.map(text => prev.map(text =>
text.id === selectedTextId text.id === selectedTextId
? { ...text, content: editorContent } ? { ...text, content }
: text : text
) )
); );
@ -228,7 +138,7 @@ export default function AdminPage() {
}, },
body: JSON.stringify({ body: JSON.stringify({
id: selectedTextId, id: selectedTextId,
content: editorContent content
}), }),
}); });
@ -239,116 +149,33 @@ export default function AdminPage() {
setShowEditor(false); setShowEditor(false);
setSelectedTextId(null); setSelectedTextId(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); console.error('Error saving text:', err);
} finally { // Show error message to user if needed
setIsLoading(false);
} }
} }
}; };
// Add a new vote option const handleStatsLoaded = (loadedStats: Stats, loadedComments: Comment[]) => {
const handleAddVoteOption = async () => { setStats(loadedStats);
if (!newOptionId || !newOptionLabel) { setComments(loadedComments);
setError('Bitte geben Sie sowohl eine ID als auch ein Label für die neue Option ein'); setShowStats(true);
return; };
}
// Check if ID already exists const handleVoteOptionsChange = (newOptions: { id: string; label: string }[]) => {
if (voteOptions.some(option => option.id === newOptionId)) { setVoteOptions(newOptions);
setError('Eine Option mit dieser ID existiert bereits'); };
return;
}
setIsLoading(true); const handleMemberAuthChange = (enabled: boolean) => {
setError(null); setMemberAuthEnabled(enabled);
};
const handleResetVotes = () => {
// If stats are currently shown, refresh them
if (showStats) {
// Refresh stats
const fetchStats = async () => {
try { try {
// Add to local state const response = await fetch('/api/stats', {
const updatedOptions = [...voteOptions, { id: newOptionId, label: newOptionLabel }];
setVoteOptions(updatedOptions);
// Save to server
const response = await fetch('/api/editable-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: 'vote-options',
content: updatedOptions
}),
});
if (!response.ok) {
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
}
// Reset form
setNewOptionId('');
setNewOptionLabel('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
// Remove a vote option
const handleRemoveVoteOption = async (optionId: string) => {
// Don't allow removing if there are less than 2 options
if (voteOptions.length <= 1) {
setError('Es muss mindestens eine Abstimmungsoption vorhanden sein');
return;
}
// Show confirmation dialog
if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionId}" entfernen möchten? Bestehende Abstimmungen mit dieser Option werden nicht gelöscht, aber in den Statistiken möglicherweise nicht mehr korrekt angezeigt.`)) {
return;
}
setIsLoading(true);
setError(null);
try {
// Remove from local state
const updatedOptions = voteOptions.filter(option => option.id !== optionId);
setVoteOptions(updatedOptions);
// Save to server
const response = await fetch('/api/editable-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: 'vote-options',
content: updatedOptions
}),
});
if (!response.ok) {
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
const copyToClipboard = () => {
if (generatedLink) {
navigator.clipboard.writeText(generatedLink);
alert('Link in die Zwischenablage kopiert!');
}
};
const handleToggleMemberAuth = async () => {
setIsTogglingMemberAuth(true);
setError(null);
try {
const response = await fetch('/api/toggle-member-auth', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -357,194 +184,38 @@ export default function AdminPage() {
}); });
const data = await response.json(); const data = await response.json();
if (response.ok) {
if (!response.ok) { setStats(data.stats);
throw new Error(data.error || 'Fehler beim Ändern der Einstellung'); setComments(data.comments || []);
} }
setMemberAuthEnabled(data.memberAuthEnabled);
// Show success message
alert(data.message);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); console.error('Error refreshing stats:', err);
} finally {
setIsTogglingMemberAuth(false);
} }
}; };
const handleResetVotes = async () => { fetchStats();
// Show confirmation dialog
if (!confirm('Sind Sie sicher, dass Sie alle Abstimmungsdaten zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.')) {
return;
}
setIsResetting(true);
setError(null);
try {
const response = await fetch('/api/reset-votes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}), // No need to send password, using JWT cookie
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Zurücksetzen der Abstimmungsdaten');
}
// Show success message
alert('Abstimmungsdaten wurden erfolgreich zurückgesetzt.');
// If stats are currently shown, refresh them
if (showStats) {
handleGetStats();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsResetting(false);
}
};
const handleGenerateBulkTokens = async () => {
setIsGeneratingBulk(true);
setError(null);
try {
// Generate the specified number of tokens
const response = await fetch('/api/generate-bulk-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ count: bulkTokenCount }),
});
if (!response.ok) {
// Try to parse error as JSON
try {
const errorData = await response.json();
throw new Error(errorData.error || 'Fehler beim Generieren der Tokens');
} catch {
// If not JSON, use status text
throw new Error(`Fehler beim Generieren der Tokens: ${response.statusText}`);
}
}
// For successful responses, get the CSV data and create a download
const csvData = await response.text();
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
// Create a temporary link and trigger download
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `abstimmungslinks_${new Date().toISOString().slice(0, 10)}.csv`);
document.body.appendChild(link);
link.click();
// Clean up
setTimeout(() => {
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, 100);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsGeneratingBulk(false);
} }
}; };
// Login form if not authenticated // Login form if not authenticated
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return <LoginForm onLoginSuccess={() => setIsAuthenticated(true)} />;
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">ADMIN-BEREICH</h1>
<div className="ssvc-main-content">
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Admin-Passwort
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
</div>
</div>
</div>
);
} }
// WYSIWYG editor view // WYSIWYG editor view
if (showEditor && selectedTextId) { if (showEditor && selectedTextId) {
const textToEdit = editableTexts.find(text => text.id === selectedTextId);
if (textToEdit) {
return ( return (
<div className="container mx-auto px-4 py-8"> <TextEditor
<div className="mb-8"> textId={selectedTextId}
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">TEXT BEARBEITEN</h1> initialContent={editorContent}
onSave={handleSaveText}
<div className="ssvc-main-content"> onCancel={() => setShowEditor(false)}
<div className="mb-4">
<h2 className="text-lg font-medium text-[#0057a6] mb-2">
{selectedTextId === 'welcome-text' && 'Willkommenstext'}
{selectedTextId === 'current-vote-text' && 'Aktuelle Abstimmung Text'}
{selectedTextId === 'vote-question' && 'Abstimmungsfrage'}
</h2>
<div className="mb-4">
<QuillEditor
value={editorContent}
onChange={setEditorContent}
placeholder="Enter content here..."
/> />
</div>
<div className="flex gap-2">
<button
onClick={handleSaveText}
className="ssvc-button flex-1"
>
Speichern
</button>
<button
onClick={() => setShowEditor(false)}
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 transition-colors duration-300"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
</div>
); );
} }
}
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@ -553,355 +224,34 @@ export default function AdminPage() {
{!showStats ? ( {!showStats ? (
<div className="ssvc-main-content"> <div className="ssvc-main-content">
<div className="flex gap-2 mb-4"> <StatsButton onStatsLoaded={handleStatsLoaded} />
<button <TextEditorManager
onClick={handleGetStats} editableTexts={editableTexts}
disabled={isLoading} onEditText={handleEditText}
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 transition-colors duration-300 disabled:opacity-50"
>
Statistiken anzeigen
</button>
</div>
<div className="mb-6">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Texte bearbeiten</h2>
<div className="space-y-3">
<button
onClick={() => handleEditText('welcome-text')}
className="w-full text-left p-3 border border-gray-200 hover:bg-[#e6f0fa] transition-colors"
>
Willkommenstext bearbeiten
</button>
<button
onClick={() => handleEditText('current-vote-text')}
className="w-full text-left p-3 border border-gray-200 hover:bg-[#e6f0fa] transition-colors"
>
Aktuelle Abstimmung Text bearbeiten
</button>
<button
onClick={() => handleEditText('vote-question')}
className="w-full text-left p-3 border border-gray-200 hover:bg-[#e6f0fa] transition-colors"
>
Abstimmungsfrage bearbeiten
</button>
</div>
</div>
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#0057a6]">Abstimmungsoptionen</h2>
<button
onClick={() => setShowVoteOptions(!showVoteOptions)}
className="text-sm text-[#0057a6] hover:underline"
>
{showVoteOptions ? 'Ausblenden' : 'Bearbeiten'}
</button>
</div>
{showVoteOptions && (
<div className="bg-[#e6f0fa] p-4 mb-4">
<h3 className="font-medium text-[#0057a6] mb-3">Aktuelle Optionen</h3>
<div className="space-y-2 mb-4">
{voteOptions.map((option) => (
<div key={option.id} className="flex items-center justify-between p-2 bg-white">
<div>
<span className="font-medium">{option.id}:</span> {option.label}
</div>
<button
onClick={() => handleRemoveVoteOption(option.id)}
className="text-red-600 hover:text-red-800"
title="Option entfernen"
>
</button>
</div>
))}
</div>
<h3 className="font-medium text-[#0057a6] mb-2">Neue Option hinzufügen</h3>
<div className="space-y-3">
<div>
<label htmlFor="newOptionId" className="block text-sm font-medium text-gray-700 mb-1">
Option ID (z.B. &quot;yes&quot;, &quot;no&quot;)
</label>
<input
type="text"
id="newOptionId"
value={newOptionId}
onChange={(e) => setNewOptionId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
placeholder="option-id"
/> />
<p className="text-xs text-gray-500 mt-1">
Die ID wird intern verwendet und sollte kurz und eindeutig sein.
</p>
</div>
<div> <VoteOptionsManager
<label htmlFor="newOptionLabel" className="block text-sm font-medium text-gray-700 mb-1"> voteOptions={voteOptions}
Anzeigename onVoteOptionsChange={handleVoteOptionsChange}
</label>
<input
type="text"
id="newOptionLabel"
value={newOptionLabel}
onChange={(e) => setNewOptionLabel(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
placeholder="Anzeigename der Option"
/> />
<p className="text-xs text-gray-500 mt-1">
Der Anzeigename wird den Abstimmenden angezeigt.
</p>
</div>
<button <MemberAuthManager
onClick={handleAddVoteOption} memberAuthEnabled={memberAuthEnabled}
disabled={isLoading} onMemberAuthChange={handleMemberAuthChange}
className="ssvc-button w-full"
>
{isLoading ? 'Wird hinzugefügt...' : 'Option hinzufügen'}
</button>
</div>
<div className="mt-4 text-sm text-gray-600">
<p className="font-medium">Hinweis:</p>
<p>Das Ändern der Abstimmungsoptionen wirkt sich nur auf neue Abstimmungen aus. Bestehende Abstimmungsdaten bleiben unverändert.</p>
</div>
</div>
)}
</div>
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#0057a6]">Mitgliederanmeldung</h2>
<button
onClick={handleToggleMemberAuth}
disabled={isTogglingMemberAuth}
className={`px-3 py-1 rounded text-white ${memberAuthEnabled
? 'bg-green-600 hover:bg-green-700'
: 'bg-gray-600 hover:bg-gray-700'
}`}
>
{isTogglingMemberAuth
? 'Wird geändert...'
: memberAuthEnabled
? 'Aktiviert'
: 'Deaktiviert'}
</button>
</div>
<div className="p-4 bg-[#e6f0fa] mb-4">
<p className="mb-3">
Mit dieser Funktion können sich Mitglieder mit ihrer Mitgliedsnummer und einem Passwort anmelden, um abzustimmen.
</p>
<div className="flex gap-2">
<button
onClick={() => setShowMembers(!showMembers)}
className="ssvc-button flex-1"
>
{showMembers ? 'Mitgliederverwaltung ausblenden' : 'Mitgliederverwaltung anzeigen'}
</button>
</div>
</div>
{showMembers && (
<div className="mb-6">
<MembersManager />
</div>
)}
</div>
<div className="mb-6">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Abstimmungslinks</h2>
<div className="flex gap-2 mb-4">
<button
onClick={handleGenerateToken}
disabled={isLoading}
className="ssvc-button flex-1 disabled:opacity-50"
>
{isLoading ? 'Generiere...' : 'Abstimmungslink generieren'}
</button>
</div>
<div className="flex gap-2 mb-4">
{generatedLink && (
<div className="mt-6 p-4 bg-[#e6f0fa]">
<h3 className="font-medium text-[#0057a6] mb-2">Generierter Abstimmungslink:</h3>
<div className="break-all text-sm text-[#0057a6] mb-2">
{generatedLink}
</div>
<button
onClick={copyToClipboard}
className="w-full ssvc-button mt-2"
>
In die Zwischenablage kopieren
</button>
</div>
)}
</div>
{error && (
<div className="text-red-500 text-sm mb-4">{error}</div>
)}
<div className="mt-6 p-4">
<h3 className="font-medium text-[#0057a6] mb-2">Mehrere Abstimmungslinks generieren:</h3>
<div className="flex items-center gap-2 mb-3">
<label htmlFor="bulkTokenCount" className="text-sm">Anzahl der Links:</label>
<input
type="number"
id="bulkTokenCount"
min="1"
max="1000"
value={bulkTokenCount}
onChange={(e) => setBulkTokenCount(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-2 py-1 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
/> />
</div>
<button
onClick={handleGenerateBulkTokens}
disabled={isGeneratingBulk}
className="w-full ssvc-button"
>
{isGeneratingBulk ? 'Generiere CSV...' : 'Links als CSV generieren'}
</button>
<p className="text-xs text-gray-600 mt-2">
Die generierten Links werden als CSV-Datei heruntergeladen, die Sie mit Ihrer Mitgliederliste zusammenführen können.
</p>
</div>
</div>
<TokenGenerator />
<div className="mb-6"> <ResetVotes onReset={handleResetVotes} />
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Zur&uuml;cksetzen</h2>
<div className="mb-4">
<button
onClick={handleResetVotes}
disabled={isResetting || isLoading}
className="w-full bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 transition-colors duration-300 disabled:opacity-50"
>
{isResetting ? 'Zurücksetzen...' : 'Abstimmungsdaten zurücksetzen'}
</button>
<p className="text-xs text-gray-600 mt-1">
Achtung: Diese Aktion löscht alle Abstimmungsdaten und kann nicht rückgängig gemacht werden.
</p>
</div>
{error && (
<div className="text-red-500 text-sm mb-4">{error}</div>
)}
</div>
</div> </div>
) : ( ) : (
<div className="ssvc-main-content"> <StatsDisplay
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Abstimmungsstatistiken</h2> stats={stats!}
comments={comments}
{stats && voteOptions.length > 0 && ( voteOptions={voteOptions}
<div className="space-y-4"> onBack={() => setShowStats(false)}
<div className={`grid ${voteOptions.length === 1 ? 'grid-cols-1' : voteOptions.length === 2 ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}> />
{voteOptions.map((option) => (
<div key={option.id} className="bg-[#e6f0fa] p-4 text-center">
<div className="text-2xl font-bold text-[#0057a6]">{stats[option.id] || 0}</div>
<div className="text-sm text-[#0057a6]">{option.label}</div>
</div>
))}
</div>
<div className="bg-[#0057a6] p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.total}</div>
<div className="text-sm text-white">Gesamtstimmen</div>
</div>
{stats.total > 0 && (
<div className="mt-4">
<h3 className="text-lg font-medium text-[#0057a6] mb-2">Ergebnisübersicht</h3>
<div className="h-6 bg-gray-200 overflow-hidden">
<div className="flex h-full">
{voteOptions.map((option, index) => {
const percentage = ((stats[option.id] || 0) / stats.total) * 100;
// Define colors for different options
const colors = [
'bg-[#0057a6]', // Blue for first option (usually yes)
'bg-red-500', // Red for second option (usually no)
'bg-gray-500' // Gray for third option (usually abstain)
];
// Use a default color for additional options
const color = index < colors.length ? colors[index] : 'bg-green-500';
return (
<div
key={option.id}
className={`${color} h-full`}
style={{ width: `${percentage}%` }}
></div>
);
})}
</div>
</div>
<div className="flex justify-between text-xs mt-1">
{voteOptions.map((option, index) => {
const percentage = Math.round(((stats[option.id] || 0) / stats.total) * 100);
// Define text colors for different options
const textColors = [
'text-[#0057a6]', // Blue for first option (usually yes)
'text-red-600', // Red for second option (usually no)
'text-gray-600' // Gray for third option (usually abstain)
];
// Use a default color for additional options
const textColor = index < textColors.length ? textColors[index] : 'text-green-600';
return (
<span key={option.id} className={textColor}>
{percentage}% {option.label}
</span>
);
})}
</div>
</div>
)}
</div>
)}
{comments.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-medium text-[#0057a6] mb-4">Kommentare der Teilnehmer</h3>
<div className="space-y-4">
{comments.map((comment, index) => (
<div key={index} className="p-4 bg-[#e6f0fa] rounded">
<div className="flex justify-between items-center mb-2">
<span className="font-medium">
{voteOptions.find(option => option.id === comment.vote)?.label || comment.vote}
</span>
<span className="text-xs text-gray-500">
{new Date(comment.timestamp).toLocaleDateString('de-DE')}
</span>
</div>
<p className="text-gray-700">{comment.comment}</p>
</div>
))}
</div>
</div>
)}
<button
onClick={() => setShowStats(false)}
className="ssvc-button mt-4"
>
Zurück zum Link-Generator
</button>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,80 @@
'use client';
import { useState } from 'react';
interface LoginFormProps {
onLoginSuccess: () => void;
}
export default function LoginForm({ onLoginSuccess }: LoginFormProps) {
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/generate-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Anmeldung fehlgeschlagen');
}
onLoginSuccess();
setPassword(''); // Clear password field after successful login
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">ADMIN-BEREICH</h1>
<div className="ssvc-main-content">
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Admin-Passwort
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
required
/>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
type="submit"
disabled={isLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,92 @@
'use client';
import { useState } from 'react';
import MembersManager from '@/components/MembersManager';
interface MemberAuthManagerProps {
memberAuthEnabled: boolean;
onMemberAuthChange: (enabled: boolean) => void;
}
export default function MemberAuthManager({ memberAuthEnabled, onMemberAuthChange }: MemberAuthManagerProps) {
const [showMembers, setShowMembers] = useState(false);
const [isTogglingMemberAuth, setIsTogglingMemberAuth] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleToggleMemberAuth = async () => {
setIsTogglingMemberAuth(true);
setError(null);
try {
const response = await fetch('/api/toggle-member-auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Ändern der Einstellung');
}
onMemberAuthChange(data.memberAuthEnabled);
// Show success message
alert(data.message);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsTogglingMemberAuth(false);
}
};
return (
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#0057a6]">Mitgliederanmeldung</h2>
<button
onClick={handleToggleMemberAuth}
disabled={isTogglingMemberAuth}
className={`px-3 py-1 rounded text-white ${memberAuthEnabled
? 'bg-green-600 hover:bg-green-700'
: 'bg-gray-600 hover:bg-gray-700'
}`}
>
{isTogglingMemberAuth
? 'Wird geändert...'
: memberAuthEnabled
? 'Aktiviert'
: 'Deaktiviert'}
</button>
</div>
<div className="p-4 bg-[#e6f0fa] mb-4">
<p className="mb-3">
Mit dieser Funktion können sich Mitglieder mit ihrer Mitgliedsnummer und einem Passwort anmelden, um abzustimmen.
</p>
{error && (
<div className="text-red-500 text-sm mb-3">{error}</div>
)}
<div className="flex gap-2">
<button
onClick={() => setShowMembers(!showMembers)}
className="ssvc-button flex-1"
>
{showMembers ? 'Mitgliederverwaltung ausblenden' : 'Mitgliederverwaltung anzeigen'}
</button>
</div>
</div>
{showMembers && (
<div className="mb-6">
<MembersManager />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import { useState } from 'react';
interface ResetVotesProps {
onReset?: () => void;
}
export default function ResetVotes({ onReset }: ResetVotesProps) {
const [isResetting, setIsResetting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleResetVotes = async () => {
// Show confirmation dialog
if (!confirm('Sind Sie sicher, dass Sie alle Abstimmungsdaten zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.')) {
return;
}
setIsResetting(true);
setError(null);
try {
const response = await fetch('/api/reset-votes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}), // No need to send password, using JWT cookie
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Fehler beim Zurücksetzen der Abstimmungsdaten');
}
// Show success message
alert('Abstimmungsdaten wurden erfolgreich zurückgesetzt.');
// Call the onReset callback if provided
if (onReset) {
onReset();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsResetting(false);
}
};
return (
<div className="mb-6">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Zur&uuml;cksetzen</h2>
<div className="mb-4">
<button
onClick={handleResetVotes}
disabled={isResetting}
className="w-full bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 transition-colors duration-300 disabled:opacity-50"
>
{isResetting ? 'Zurücksetzen...' : 'Abstimmungsdaten zurücksetzen'}
</button>
<p className="text-xs text-gray-600 mt-1">
Achtung: Diese Aktion löscht alle Abstimmungsdaten und kann nicht rückgängig gemacht werden.
</p>
</div>
{error && (
<div className="text-red-500 text-sm mb-4">{error}</div>
)}
</div>
);
}

View File

@ -0,0 +1,68 @@
'use client';
import { useState } from 'react';
import { VoteOption } from '@/lib/survey';
// Define types based on the data structure
interface Stats {
total: number;
[key: string]: number; // Allow dynamic keys for vote options
}
interface Comment {
vote: VoteOption;
comment: string;
timestamp: string;
}
interface StatsButtonProps {
onStatsLoaded: (stats: Stats, comments: Comment[]) => void;
}
export default function StatsButton({ onStatsLoaded }: StatsButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleGetStats = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/stats', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}), // No need to send password, using JWT cookie
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Statistiken konnten nicht abgerufen werden');
}
onStatsLoaded(data.stats, data.comments || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex gap-2 mb-4">
<button
onClick={handleGetStats}
disabled={isLoading}
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 transition-colors duration-300 disabled:opacity-50"
>
{isLoading ? 'Lade Statistiken...' : 'Statistiken anzeigen'}
</button>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
</div>
);
}

View File

@ -0,0 +1,123 @@
'use client';
import { VoteOption } from '@/lib/survey';
interface Stats {
total: number;
[key: string]: number; // Allow dynamic keys for vote options
}
interface Comment {
vote: VoteOption;
comment: string;
timestamp: string;
}
interface StatsDisplayProps {
stats: Stats;
comments: Comment[];
voteOptions: { id: string; label: string }[];
onBack: () => void;
}
export default function StatsDisplay({ stats, comments, voteOptions, onBack }: StatsDisplayProps) {
return (
<div className="ssvc-main-content">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Abstimmungsstatistiken</h2>
{stats && voteOptions.length > 0 && (
<div className="space-y-4">
<div className={`grid ${voteOptions.length === 1 ? 'grid-cols-1' : voteOptions.length === 2 ? 'grid-cols-2' : 'grid-cols-3'} gap-4`}>
{voteOptions.map((option) => (
<div key={option.id} className="bg-[#e6f0fa] p-4 text-center">
<div className="text-2xl font-bold text-[#0057a6]">{stats[option.id] || 0}</div>
<div className="text-sm text-[#0057a6]">{option.label}</div>
</div>
))}
</div>
<div className="bg-[#0057a6] p-4 text-center">
<div className="text-2xl font-bold text-white">{stats.total}</div>
<div className="text-sm text-white">Gesamtstimmen</div>
</div>
{stats.total > 0 && (
<div className="mt-4">
<h3 className="text-lg font-medium text-[#0057a6] mb-2">Ergebnisübersicht</h3>
<div className="h-6 bg-gray-200 overflow-hidden">
<div className="flex h-full">
{voteOptions.map((option, index) => {
const percentage = ((stats[option.id] || 0) / stats.total) * 100;
// Define colors for different options
const colors = [
'bg-[#0057a6]', // Blue for first option (usually yes)
'bg-red-500', // Red for second option (usually no)
'bg-gray-500' // Gray for third option (usually abstain)
];
// Use a default color for additional options
const color = index < colors.length ? colors[index] : 'bg-green-500';
return (
<div
key={option.id}
className={`${color} h-full`}
style={{ width: `${percentage}%` }}
></div>
);
})}
</div>
</div>
<div className="flex justify-between text-xs mt-1">
{voteOptions.map((option, index) => {
const percentage = Math.round(((stats[option.id] || 0) / stats.total) * 100);
// Define text colors for different options
const textColors = [
'text-[#0057a6]', // Blue for first option (usually yes)
'text-red-600', // Red for second option (usually no)
'text-gray-600' // Gray for third option (usually abstain)
];
// Use a default color for additional options
const textColor = index < textColors.length ? textColors[index] : 'text-green-600';
return (
<span key={option.id} className={textColor}>
{percentage}% {option.label}
</span>
);
})}
</div>
</div>
)}
</div>
)}
{comments.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-medium text-[#0057a6] mb-4">Kommentare der Teilnehmer</h3>
<div className="space-y-4">
{comments.map((comment, index) => (
<div key={index} className="p-4 bg-[#e6f0fa] rounded">
<div className="flex justify-between items-center mb-2">
<span className="font-medium">
{voteOptions.find(option => option.id === comment.vote)?.label || comment.vote}
</span>
<span className="text-xs text-gray-500">
{new Date(comment.timestamp).toLocaleDateString('de-DE')}
</span>
</div>
<p className="text-gray-700">{comment.comment}</p>
</div>
))}
</div>
</div>
)}
<button
onClick={onBack}
className="ssvc-button mt-4"
>
Zurück zum Link-Generator
</button>
</div>
);
}

View File

@ -0,0 +1,73 @@
'use client';
import { useState } from 'react';
import QuillEditor from '@/components/QuillEditor';
interface TextEditorProps {
textId: string;
initialContent: string;
onSave: (content: string) => void;
onCancel: () => void;
}
export default function TextEditor({ textId, initialContent, onSave, onCancel }: TextEditorProps) {
const [editorContent, setEditorContent] = useState(initialContent);
// No need for loading or error states as parent handles saving
const getTextTitle = () => {
switch (textId) {
case 'welcome-text':
return 'Willkommenstext';
case 'current-vote-text':
return 'Aktuelle Abstimmung Text';
case 'vote-question':
return 'Abstimmungsfrage';
default:
return 'Text bearbeiten';
}
};
const handleSaveText = () => {
onSave(editorContent);
};
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-[#0057a6] mb-4">TEXT BEARBEITEN</h1>
<div className="ssvc-main-content">
<div className="mb-4">
<h2 className="text-lg font-medium text-[#0057a6] mb-2">
{getTextTitle()}
</h2>
<div className="mb-4">
<QuillEditor
value={editorContent}
onChange={setEditorContent}
placeholder="Enter content here..."
/>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveText}
className="ssvc-button flex-1"
>
Speichern
</button>
<button
onClick={onCancel}
className="flex-1 bg-gray-600 hover:bg-gray-700 text-white font-medium py-2 px-4 transition-colors duration-300"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
'use client';
interface EditableText {
id: string;
content: string;
}
interface TextEditorManagerProps {
editableTexts: EditableText[];
onEditText: (textId: string) => void;
}
export default function TextEditorManager({ editableTexts, onEditText }: TextEditorManagerProps) {
// Helper function to get a human-readable title for each text ID
const getTextTitle = (textId: string) => {
switch (textId) {
case 'welcome-text':
return 'Willkommenstext bearbeiten';
case 'current-vote-text':
return 'Aktuelle Abstimmung Text bearbeiten';
case 'vote-question':
return 'Abstimmungsfrage bearbeiten';
default:
return `${textId} bearbeiten`;
}
};
// Filter out vote-options as it's handled separately
const textItems = editableTexts.filter(text => text.id !== 'vote-options');
return (
<div className="mb-6">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Texte bearbeiten</h2>
<div className="space-y-3">
{textItems.map(text => (
<button
key={text.id}
onClick={() => onEditText(text.id)}
className="w-full text-left p-3 border border-gray-200 hover:bg-[#e6f0fa] transition-colors"
>
{getTextTitle(text.id)}
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
export default function TokenGenerator() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
const [bulkTokenCount, setBulkTokenCount] = useState<number>(10);
const [isGeneratingBulk, setIsGeneratingBulk] = useState(false);
const handleGenerateToken = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/generate-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}), // No need to send password, using JWT cookie
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Token konnte nicht generiert werden');
}
setGeneratedLink(data.voteUrl);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
const copyToClipboard = () => {
if (generatedLink) {
navigator.clipboard.writeText(generatedLink);
alert('Link in die Zwischenablage kopiert!');
}
};
const handleGenerateBulkTokens = async () => {
setIsGeneratingBulk(true);
setError(null);
try {
// Generate the specified number of tokens
const response = await fetch('/api/generate-bulk-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ count: bulkTokenCount }),
});
if (!response.ok) {
// Try to parse error as JSON
try {
const errorData = await response.json();
throw new Error(errorData.error || 'Fehler beim Generieren der Tokens');
} catch {
// If not JSON, use status text
throw new Error(`Fehler beim Generieren der Tokens: ${response.statusText}`);
}
}
// For successful responses, get the CSV data and create a download
const csvData = await response.text();
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
// Create a temporary link and trigger download
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `abstimmungslinks_${new Date().toISOString().slice(0, 10)}.csv`);
document.body.appendChild(link);
link.click();
// Clean up
setTimeout(() => {
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, 100);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsGeneratingBulk(false);
}
};
return (
<div className="mb-6">
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Abstimmungslinks</h2>
<div className="flex gap-2 mb-4">
<button
onClick={handleGenerateToken}
disabled={isLoading}
className="ssvc-button flex-1 disabled:opacity-50"
>
{isLoading ? 'Generiere...' : 'Abstimmungslink generieren'}
</button>
</div>
<div className="flex gap-2 mb-4">
{generatedLink && (
<div className="mt-6 p-4 bg-[#e6f0fa] w-full">
<h3 className="font-medium text-[#0057a6] mb-2">Generierter Abstimmungslink:</h3>
<div className="break-all text-sm text-[#0057a6] mb-2">
{generatedLink}
</div>
<button
onClick={copyToClipboard}
className="w-full ssvc-button mt-2"
>
In die Zwischenablage kopieren
</button>
</div>
)}
</div>
{error && (
<div className="text-red-500 text-sm mb-4">{error}</div>
)}
<div className="mt-6 p-4">
<h3 className="font-medium text-[#0057a6] mb-2">Mehrere Abstimmungslinks generieren:</h3>
<div className="flex items-center gap-2 mb-3">
<label htmlFor="bulkTokenCount" className="text-sm">Anzahl der Links:</label>
<input
type="number"
id="bulkTokenCount"
min="1"
max="1000"
value={bulkTokenCount}
onChange={(e) => setBulkTokenCount(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-2 py-1 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
/>
</div>
<button
onClick={handleGenerateBulkTokens}
disabled={isGeneratingBulk}
className="w-full ssvc-button disabled:opacity-50"
>
{isGeneratingBulk ? 'Generiere CSV...' : 'Links als CSV generieren'}
</button>
<p className="text-xs text-gray-600 mt-2">
Die generierten Links werden als CSV-Datei heruntergeladen, die Sie mit Ihrer Mitgliederliste zusammenführen können.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,201 @@
'use client';
import { useState } from 'react';
interface VoteOptionsManagerProps {
voteOptions: { id: string; label: string }[];
onVoteOptionsChange: (newOptions: { id: string; label: string }[]) => void;
}
export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }: VoteOptionsManagerProps) {
const [showVoteOptions, setShowVoteOptions] = useState(false);
const [newOptionId, setNewOptionId] = useState('');
const [newOptionLabel, setNewOptionLabel] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Add a new vote option
const handleAddVoteOption = async () => {
if (!newOptionId || !newOptionLabel) {
setError('Bitte geben Sie sowohl eine ID als auch ein Label für die neue Option ein');
return;
}
// Check if ID already exists
if (voteOptions.some(option => option.id === newOptionId)) {
setError('Eine Option mit dieser ID existiert bereits');
return;
}
setIsLoading(true);
setError(null);
try {
// Add to local state
const updatedOptions = [...voteOptions, { id: newOptionId, label: newOptionLabel }];
// Save to server
const response = await fetch('/api/editable-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: 'vote-options',
content: updatedOptions
}),
});
if (!response.ok) {
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
}
// Update parent component state
onVoteOptionsChange(updatedOptions);
// Reset form
setNewOptionId('');
setNewOptionLabel('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
// Remove a vote option
const handleRemoveVoteOption = async (optionId: string) => {
// Don't allow removing if there are less than 2 options
if (voteOptions.length <= 1) {
setError('Es muss mindestens eine Abstimmungsoption vorhanden sein');
return;
}
// Show confirmation dialog
if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionId}" entfernen möchten? Bestehende Abstimmungen mit dieser Option werden nicht gelöscht, aber in den Statistiken möglicherweise nicht mehr korrekt angezeigt.`)) {
return;
}
setIsLoading(true);
setError(null);
try {
// Remove from local state
const updatedOptions = voteOptions.filter(option => option.id !== optionId);
// Save to server
const response = await fetch('/api/editable-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: 'vote-options',
content: updatedOptions
}),
});
if (!response.ok) {
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
}
// Update parent component state
onVoteOptionsChange(updatedOptions);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setIsLoading(false);
}
};
return (
<div className="mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#0057a6]">Abstimmungsoptionen</h2>
<button
onClick={() => setShowVoteOptions(!showVoteOptions)}
className="text-sm text-[#0057a6] hover:underline"
>
{showVoteOptions ? 'Ausblenden' : 'Bearbeiten'}
</button>
</div>
{showVoteOptions && (
<div className="bg-[#e6f0fa] p-4 mb-4">
<h3 className="font-medium text-[#0057a6] mb-3">Aktuelle Optionen</h3>
<div className="space-y-2 mb-4">
{voteOptions.map((option) => (
<div key={option.id} className="flex items-center justify-between p-2 bg-white">
<div>
<span className="font-medium">{option.id}:</span> {option.label}
</div>
<button
onClick={() => handleRemoveVoteOption(option.id)}
className="text-red-600 hover:text-red-800"
title="Option entfernen"
>
</button>
</div>
))}
</div>
<h3 className="font-medium text-[#0057a6] mb-2">Neue Option hinzufügen</h3>
<div className="space-y-3">
<div>
<label htmlFor="newOptionId" className="block text-sm font-medium text-gray-700 mb-1">
Option ID (z.B. &quot;yes&quot;, &quot;no&quot;)
</label>
<input
type="text"
id="newOptionId"
value={newOptionId}
onChange={(e) => setNewOptionId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
placeholder="option-id"
/>
<p className="text-xs text-gray-500 mt-1">
Die ID wird intern verwendet und sollte kurz und eindeutig sein.
</p>
</div>
<div>
<label htmlFor="newOptionLabel" className="block text-sm font-medium text-gray-700 mb-1">
Anzeigename
</label>
<input
type="text"
id="newOptionLabel"
value={newOptionLabel}
onChange={(e) => setNewOptionLabel(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
placeholder="Anzeigename der Option"
/>
<p className="text-xs text-gray-500 mt-1">
Der Anzeigename wird den Abstimmenden angezeigt.
</p>
</div>
{error && (
<div className="text-red-500 text-sm">{error}</div>
)}
<button
onClick={handleAddVoteOption}
disabled={isLoading}
className="ssvc-button w-full disabled:opacity-50"
>
{isLoading ? 'Wird hinzugefügt...' : 'Option hinzufügen'}
</button>
</div>
<div className="mt-4 text-sm text-gray-600">
<p className="font-medium">Hinweis:</p>
<p>Das Ändern der Abstimmungsoptionen wirkt sich nur auf neue Abstimmungen aus. Bestehende Abstimmungsdaten bleiben unverändert.</p>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,9 @@
export { default as LoginForm } from './LoginForm';
export { default as TextEditor } from './TextEditor';
export { default as TextEditorManager } from './TextEditorManager';
export { default as StatsButton } from './StatsButton';
export { default as StatsDisplay } from './StatsDisplay';
export { default as VoteOptionsManager } from './VoteOptionsManager';
export { default as MemberAuthManager } from './MemberAuthManager';
export { default as TokenGenerator } from './TokenGenerator';
export { default as ResetVotes } from './ResetVotes';