diff --git a/data/editable_text.json b/data/editable_text.json index 5d8ebd5..49420be 100755 --- a/data/editable_text.json +++ b/data/editable_text.json @@ -25,10 +25,6 @@ { "id": "abstain", "label": "Ich enthalte mich" - }, - { - "id": "123", - "label": "123" } ] } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d77b940..ca56e49 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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(null); - const [generatedLink, setGeneratedLink] = useState(null); - const [bulkTokenCount, setBulkTokenCount] = useState(10); - const [isGeneratingBulk, setIsGeneratingBulk] = useState(false); - const [isResetting, setIsResetting] = useState(false); const [showStats, setShowStats] = useState(false); const [stats, setStats] = useState(null); const [comments, setComments] = useState([]); @@ -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(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,311 +149,72 @@ 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; - } - - // 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 }]; - 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); - } + const handleStatsLoaded = (loadedStats: Stats, loadedComments: Comment[]) => { + setStats(loadedStats); + setComments(loadedComments); + setShowStats(true); }; - // 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 handleVoteOptionsChange = (newOptions: { id: string; label: string }[]) => { + setVoteOptions(newOptions); }; - const copyToClipboard = () => { - if (generatedLink) { - navigator.clipboard.writeText(generatedLink); - alert('Link in die Zwischenablage kopiert!'); - } + const handleMemberAuthChange = (enabled: boolean) => { + setMemberAuthEnabled(enabled); }; - 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'); - } - - setMemberAuthEnabled(data.memberAuthEnabled); - - // Show success message - alert(data.message); - } catch (err) { - setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten'); - } finally { - setIsTogglingMemberAuth(false); - } - }; - - 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 + const handleResetVotes = () => { + // If stats are currently shown, refresh them + if (showStats) { + // Refresh stats + const fetchStats = async () => { 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}`); + const response = await fetch('/api/stats', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); + + const data = await response.json(); + if (response.ok) { + setStats(data.stats); + setComments(data.comments || []); + } + } catch (err) { + console.error('Error refreshing stats:', err); } - } + }; - // 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 ( -
-
-

ADMIN-BEREICH

- -
-
-
- - setPassword(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" - required - /> -
- - {error && ( -
{error}
- )} - - -
-
-
-
- ); + return setIsAuthenticated(true)} />; } // WYSIWYG editor view if (showEditor && selectedTextId) { - return ( -
-
-

TEXT BEARBEITEN

- -
-
-

- {selectedTextId === 'welcome-text' && 'Willkommenstext'} - {selectedTextId === 'current-vote-text' && 'Aktuelle Abstimmung Text'} - {selectedTextId === 'vote-question' && 'Abstimmungsfrage'} -

- -
- -
- -
- - - -
-
-
-
-
- ); + const textToEdit = editableTexts.find(text => text.id === selectedTextId); + if (textToEdit) { + return ( + setShowEditor(false)} + /> + ); + } } return ( @@ -553,355 +224,34 @@ export default function AdminPage() { {!showStats ? (
-
+ - -
+ + + -
-

Texte bearbeiten

- -
- - - - - -
-
- -
-
-

Abstimmungsoptionen

- -
- - {showVoteOptions && ( -
-

Aktuelle Optionen

- -
- {voteOptions.map((option) => ( -
-
- {option.id}: {option.label} -
- -
- ))} -
- -

Neue Option hinzufügen

-
-
- - setNewOptionId(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" - placeholder="option-id" - /> -

- Die ID wird intern verwendet und sollte kurz und eindeutig sein. -

-
- -
- - 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" - /> -

- Der Anzeigename wird den Abstimmenden angezeigt. -

-
- - -
- -
-

Hinweis:

-

Das Ändern der Abstimmungsoptionen wirkt sich nur auf neue Abstimmungen aus. Bestehende Abstimmungsdaten bleiben unverändert.

-
-
- )} -
- -
-
-

Mitgliederanmeldung

- -
- -
-

- Mit dieser Funktion können sich Mitglieder mit ihrer Mitgliedsnummer und einem Passwort anmelden, um abzustimmen. -

- -
- -
-
- - {showMembers && ( -
- -
- )} -
- -
-

Abstimmungslinks

- -
- - -
-
- - {generatedLink && ( -
-

Generierter Abstimmungslink:

-
- {generatedLink} -
- -
- )} -
- - {error && ( -
{error}
- )} - -
-

Mehrere Abstimmungslinks generieren:

-
- - 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]" - /> -
- -

- Die generierten Links werden als CSV-Datei heruntergeladen, die Sie mit Ihrer Mitgliederliste zusammenführen können. -

-
-
- - -
-

Zurücksetzen

- -
- -

- Achtung: Diese Aktion löscht alle Abstimmungsdaten und kann nicht rückgängig gemacht werden. -

-
- - {error && ( -
{error}
- )} -
+ +
) : ( -
-

Abstimmungsstatistiken

- - {stats && voteOptions.length > 0 && ( -
-
- {voteOptions.map((option) => ( -
-
{stats[option.id] || 0}
-
{option.label}
-
- ))} -
- -
-
{stats.total}
-
Gesamtstimmen
-
- - {stats.total > 0 && ( -
-

Ergebnisübersicht

-
-
- {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 ( -
- ); - })} -
-
-
- {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 ( - - {percentage}% {option.label} - - ); - })} -
-
- )} -
- )} - - {comments.length > 0 && ( -
-

Kommentare der Teilnehmer

-
- {comments.map((comment, index) => ( -
-
- - {voteOptions.find(option => option.id === comment.vote)?.label || comment.vote} - - - {new Date(comment.timestamp).toLocaleDateString('de-DE')} - -
-

{comment.comment}

-
- ))} -
-
- )} - - -
+ setShowStats(false)} + /> )} diff --git a/src/components/admin/LoginForm.tsx b/src/components/admin/LoginForm.tsx new file mode 100644 index 0000000..8c39390 --- /dev/null +++ b/src/components/admin/LoginForm.tsx @@ -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(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 ( +
+
+

ADMIN-BEREICH

+ +
+
+
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" + required + /> +
+ + {error && ( +
{error}
+ )} + + +
+
+
+
+ ); +} diff --git a/src/components/admin/MemberAuthManager.tsx b/src/components/admin/MemberAuthManager.tsx new file mode 100644 index 0000000..e3ee3c1 --- /dev/null +++ b/src/components/admin/MemberAuthManager.tsx @@ -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(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 ( +
+
+

Mitgliederanmeldung

+ +
+ +
+

+ Mit dieser Funktion können sich Mitglieder mit ihrer Mitgliedsnummer und einem Passwort anmelden, um abzustimmen. +

+ + {error && ( +
{error}
+ )} + +
+ +
+
+ + {showMembers && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/admin/ResetVotes.tsx b/src/components/admin/ResetVotes.tsx new file mode 100644 index 0000000..bd2df48 --- /dev/null +++ b/src/components/admin/ResetVotes.tsx @@ -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(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 ( +
+

Zurücksetzen

+ +
+ +

+ Achtung: Diese Aktion löscht alle Abstimmungsdaten und kann nicht rückgängig gemacht werden. +

+
+ + {error && ( +
{error}
+ )} +
+ ); +} diff --git a/src/components/admin/StatsButton.tsx b/src/components/admin/StatsButton.tsx new file mode 100644 index 0000000..30691d2 --- /dev/null +++ b/src/components/admin/StatsButton.tsx @@ -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(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 ( +
+ + + {error && ( +
{error}
+ )} +
+ ); +} diff --git a/src/components/admin/StatsDisplay.tsx b/src/components/admin/StatsDisplay.tsx new file mode 100644 index 0000000..dffbe0e --- /dev/null +++ b/src/components/admin/StatsDisplay.tsx @@ -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 ( +
+

Abstimmungsstatistiken

+ + {stats && voteOptions.length > 0 && ( +
+
+ {voteOptions.map((option) => ( +
+
{stats[option.id] || 0}
+
{option.label}
+
+ ))} +
+ +
+
{stats.total}
+
Gesamtstimmen
+
+ + {stats.total > 0 && ( +
+

Ergebnisübersicht

+
+
+ {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 ( +
+ ); + })} +
+
+
+ {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 ( + + {percentage}% {option.label} + + ); + })} +
+
+ )} +
+ )} + + {comments.length > 0 && ( +
+

Kommentare der Teilnehmer

+
+ {comments.map((comment, index) => ( +
+
+ + {voteOptions.find(option => option.id === comment.vote)?.label || comment.vote} + + + {new Date(comment.timestamp).toLocaleDateString('de-DE')} + +
+

{comment.comment}

+
+ ))} +
+
+ )} + + +
+ ); +} diff --git a/src/components/admin/TextEditor.tsx b/src/components/admin/TextEditor.tsx new file mode 100644 index 0000000..7db65ba --- /dev/null +++ b/src/components/admin/TextEditor.tsx @@ -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 ( +
+
+

TEXT BEARBEITEN

+ +
+
+

+ {getTextTitle()} +

+ +
+ +
+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/src/components/admin/TextEditorManager.tsx b/src/components/admin/TextEditorManager.tsx new file mode 100644 index 0000000..ccfd85d --- /dev/null +++ b/src/components/admin/TextEditorManager.tsx @@ -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 ( +
+

Texte bearbeiten

+ +
+ {textItems.map(text => ( + + ))} +
+
+ ); +} diff --git a/src/components/admin/TokenGenerator.tsx b/src/components/admin/TokenGenerator.tsx new file mode 100644 index 0000000..eee63f4 --- /dev/null +++ b/src/components/admin/TokenGenerator.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useState } from 'react'; + +export default function TokenGenerator() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [generatedLink, setGeneratedLink] = useState(null); + const [bulkTokenCount, setBulkTokenCount] = useState(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 ( +
+

Abstimmungslinks

+ +
+ +
+ +
+ {generatedLink && ( +
+

Generierter Abstimmungslink:

+
+ {generatedLink} +
+ +
+ )} +
+ + {error && ( +
{error}
+ )} + +
+

Mehrere Abstimmungslinks generieren:

+
+ + 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]" + /> +
+ +

+ Die generierten Links werden als CSV-Datei heruntergeladen, die Sie mit Ihrer Mitgliederliste zusammenführen können. +

+
+
+ ); +} diff --git a/src/components/admin/VoteOptionsManager.tsx b/src/components/admin/VoteOptionsManager.tsx new file mode 100644 index 0000000..9e30344 --- /dev/null +++ b/src/components/admin/VoteOptionsManager.tsx @@ -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(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 ( +
+
+

Abstimmungsoptionen

+ +
+ + {showVoteOptions && ( +
+

Aktuelle Optionen

+ +
+ {voteOptions.map((option) => ( +
+
+ {option.id}: {option.label} +
+ +
+ ))} +
+ +

Neue Option hinzufügen

+
+
+ + setNewOptionId(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]" + placeholder="option-id" + /> +

+ Die ID wird intern verwendet und sollte kurz und eindeutig sein. +

+
+ +
+ + 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" + /> +

+ Der Anzeigename wird den Abstimmenden angezeigt. +

+
+ + {error && ( +
{error}
+ )} + + +
+ +
+

Hinweis:

+

Das Ändern der Abstimmungsoptionen wirkt sich nur auf neue Abstimmungen aus. Bestehende Abstimmungsdaten bleiben unverändert.

+
+
+ )} +
+ ); +} diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts new file mode 100644 index 0000000..6713d6d --- /dev/null +++ b/src/components/admin/index.ts @@ -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';