split admin page in smaller components
This commit is contained in:
parent
0886757f89
commit
9bf715ee88
@ -25,10 +25,6 @@
|
||||
{
|
||||
"id": "abstain",
|
||||
"label": "Ich enthalte mich"
|
||||
},
|
||||
{
|
||||
"id": "123",
|
||||
"label": "123"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -2,8 +2,17 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { VoteOption } from '@/lib/survey';
|
||||
import QuillEditor from '@/components/QuillEditor';
|
||||
import MembersManager from '@/components/MembersManager';
|
||||
import {
|
||||
LoginForm,
|
||||
TextEditor,
|
||||
TextEditorManager,
|
||||
StatsButton,
|
||||
StatsDisplay,
|
||||
VoteOptionsManager,
|
||||
MemberAuthManager,
|
||||
TokenGenerator,
|
||||
ResetVotes
|
||||
} from '@/components/admin';
|
||||
|
||||
// Define types based on the data structure
|
||||
interface Stats {
|
||||
@ -23,13 +32,6 @@ interface EditableText {
|
||||
}
|
||||
|
||||
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 [stats, setStats] = useState<Stats | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
@ -45,14 +47,9 @@ export default function AdminPage() {
|
||||
{ id: 'no', label: 'Nein, ich stimme nicht zu' },
|
||||
{ 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 [editorContent, setEditorContent] = useState('');
|
||||
const [showMembers, setShowMembers] = useState(false);
|
||||
const [memberAuthEnabled, setMemberAuthEnabled] = useState(false);
|
||||
const [isTogglingMemberAuth, setIsTogglingMemberAuth] = useState(false);
|
||||
|
||||
// Check if already authenticated and load settings on component mount
|
||||
useEffect(() => {
|
||||
@ -85,91 +82,6 @@ export default function AdminPage() {
|
||||
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 textToEdit = editableTexts.find(text => text.id === textId);
|
||||
if (textToEdit) {
|
||||
@ -206,16 +118,14 @@ export default function AdminPage() {
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const handleSaveText = async () => {
|
||||
const handleSaveText = async (content: string) => {
|
||||
if (selectedTextId) {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Update local state
|
||||
setEditableTexts(prev =>
|
||||
prev.map(text =>
|
||||
text.id === selectedTextId
|
||||
? { ...text, content: editorContent }
|
||||
? { ...text, content }
|
||||
: text
|
||||
)
|
||||
);
|
||||
@ -228,7 +138,7 @@ export default function AdminPage() {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: selectedTextId,
|
||||
content: editorContent
|
||||
content
|
||||
}),
|
||||
});
|
||||
|
||||
@ -239,116 +149,33 @@ export default function AdminPage() {
|
||||
setShowEditor(false);
|
||||
setSelectedTextId(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
console.error('Error saving text:', err);
|
||||
// Show error message to user if needed
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
const handleStatsLoaded = (loadedStats: Stats, loadedComments: Comment[]) => {
|
||||
setStats(loadedStats);
|
||||
setComments(loadedComments);
|
||||
setShowStats(true);
|
||||
};
|
||||
|
||||
// Check if ID already exists
|
||||
if (voteOptions.some(option => option.id === newOptionId)) {
|
||||
setError('Eine Option mit dieser ID existiert bereits');
|
||||
return;
|
||||
}
|
||||
const handleVoteOptionsChange = (newOptions: { id: string; label: string }[]) => {
|
||||
setVoteOptions(newOptions);
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const handleMemberAuthChange = (enabled: boolean) => {
|
||||
setMemberAuthEnabled(enabled);
|
||||
};
|
||||
|
||||
const handleResetVotes = () => {
|
||||
// If stats are currently shown, refresh them
|
||||
if (showStats) {
|
||||
// Refresh stats
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Add to local state
|
||||
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', {
|
||||
const response = await fetch('/api/stats', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -357,194 +184,38 @@ export default function AdminPage() {
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Fehler beim Ändern der Einstellung');
|
||||
if (response.ok) {
|
||||
setStats(data.stats);
|
||||
setComments(data.comments || []);
|
||||
}
|
||||
|
||||
setMemberAuthEnabled(data.memberAuthEnabled);
|
||||
|
||||
// Show success message
|
||||
alert(data.message);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||
} finally {
|
||||
setIsTogglingMemberAuth(false);
|
||||
console.error('Error refreshing stats:', err);
|
||||
}
|
||||
};
|
||||
|
||||
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.');
|
||||
|
||||
// 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);
|
||||
fetchStats();
|
||||
}
|
||||
};
|
||||
|
||||
// Login form if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
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>
|
||||
);
|
||||
return <LoginForm onLoginSuccess={() => setIsAuthenticated(true)} />;
|
||||
}
|
||||
|
||||
// WYSIWYG editor view
|
||||
if (showEditor && selectedTextId) {
|
||||
const textToEdit = editableTexts.find(text => text.id === selectedTextId);
|
||||
if (textToEdit) {
|
||||
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">
|
||||
{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..."
|
||||
<TextEditor
|
||||
textId={selectedTextId}
|
||||
initialContent={editorContent}
|
||||
onSave={handleSaveText}
|
||||
onCancel={() => setShowEditor(false)}
|
||||
/>
|
||||
</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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@ -553,355 +224,34 @@ export default function AdminPage() {
|
||||
|
||||
{!showStats ? (
|
||||
<div className="ssvc-main-content">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<StatsButton onStatsLoaded={handleStatsLoaded} />
|
||||
|
||||
<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"
|
||||
>
|
||||
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. "yes", "no")
|
||||
</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"
|
||||
<TextEditorManager
|
||||
editableTexts={editableTexts}
|
||||
onEditText={handleEditText}
|
||||
/>
|
||||
<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"
|
||||
<VoteOptionsManager
|
||||
voteOptions={voteOptions}
|
||||
onVoteOptionsChange={handleVoteOptionsChange}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Der Anzeigename wird den Abstimmenden angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddVoteOption}
|
||||
disabled={isLoading}
|
||||
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]"
|
||||
<MemberAuthManager
|
||||
memberAuthEnabled={memberAuthEnabled}
|
||||
onMemberAuthChange={handleMemberAuthChange}
|
||||
/>
|
||||
</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">
|
||||
<h2 className="text-xl font-bold text-[#0057a6] mb-4">Zurü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>
|
||||
|
||||
<ResetVotes onReset={handleResetVotes} />
|
||||
</div>
|
||||
) : (
|
||||
<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={() => setShowStats(false)}
|
||||
className="ssvc-button mt-4"
|
||||
>
|
||||
Zurück zum Link-Generator
|
||||
</button>
|
||||
</div>
|
||||
<StatsDisplay
|
||||
stats={stats!}
|
||||
comments={comments}
|
||||
voteOptions={voteOptions}
|
||||
onBack={() => setShowStats(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
80
src/components/admin/LoginForm.tsx
Normal file
80
src/components/admin/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
92
src/components/admin/MemberAuthManager.tsx
Normal file
92
src/components/admin/MemberAuthManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
73
src/components/admin/ResetVotes.tsx
Normal file
73
src/components/admin/ResetVotes.tsx
Normal 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ü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>
|
||||
);
|
||||
}
|
68
src/components/admin/StatsButton.tsx
Normal file
68
src/components/admin/StatsButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
123
src/components/admin/StatsDisplay.tsx
Normal file
123
src/components/admin/StatsDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
73
src/components/admin/TextEditor.tsx
Normal file
73
src/components/admin/TextEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
src/components/admin/TextEditorManager.tsx
Normal file
48
src/components/admin/TextEditorManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
157
src/components/admin/TokenGenerator.tsx
Normal file
157
src/components/admin/TokenGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
201
src/components/admin/VoteOptionsManager.tsx
Normal file
201
src/components/admin/VoteOptionsManager.tsx
Normal 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. "yes", "no")
|
||||
</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>
|
||||
);
|
||||
}
|
9
src/components/admin/index.ts
Normal file
9
src/components/admin/index.ts
Normal 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';
|
Loading…
x
Reference in New Issue
Block a user