split admin page in smaller components
This commit is contained in:
parent
0886757f89
commit
9bf715ee88
@ -25,10 +25,6 @@
|
|||||||
{
|
{
|
||||||
"id": "abstain",
|
"id": "abstain",
|
||||||
"label": "Ich enthalte mich"
|
"label": "Ich enthalte mich"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "123",
|
|
||||||
"label": "123"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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. "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>
|
<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ü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>
|
||||||
|
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