441 lines
18 KiB
TypeScript
441 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useEffect } from 'react';
|
|
|
|
// Function to generate a random ID
|
|
function generateRandomId(existingIds: string[]): string {
|
|
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
const length = 8;
|
|
let result: string;
|
|
|
|
do {
|
|
result = '';
|
|
for (let i = 0; i < length; i++) {
|
|
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
|
}
|
|
} while (existingIds.includes(result));
|
|
|
|
return result;
|
|
}
|
|
|
|
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);
|
|
const [editingOptionId, setEditingOptionId] = useState<string | null>(null);
|
|
const [editedLabel, setEditedLabel] = useState('');
|
|
const editInputRef = useRef<HTMLInputElement>(null);
|
|
const labelInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Generate a random ID when the component mounts or when the form is reset
|
|
useEffect(() => {
|
|
if (!newOptionId && voteOptions) {
|
|
const existingIds = voteOptions.map(option => option.id);
|
|
setNewOptionId(generateRandomId(existingIds));
|
|
}
|
|
}, [newOptionId, voteOptions]);
|
|
|
|
// 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 and generate a new random ID
|
|
setNewOptionLabel('');
|
|
const existingIds = updatedOptions.map(option => option.id);
|
|
setNewOptionId(generateRandomId(existingIds));
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Start editing a vote option
|
|
const handleEditVoteOption = (option: { id: string; label: string }) => {
|
|
setEditingOptionId(option.id);
|
|
setEditedLabel(option.label);
|
|
// Focus the input field after it's rendered
|
|
setTimeout(() => {
|
|
if (editInputRef.current) {
|
|
editInputRef.current.focus();
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
// Save the edited vote option
|
|
const handleSaveVoteOption = async () => {
|
|
if (!editingOptionId || !editedLabel.trim()) {
|
|
setError('Das Label darf nicht leer sein');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Update in local state
|
|
const updatedOptions = voteOptions.map(option =>
|
|
option.id === editingOptionId
|
|
? { ...option, label: editedLabel.trim() }
|
|
: option
|
|
);
|
|
|
|
// 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);
|
|
|
|
// Exit edit mode
|
|
setEditingOptionId(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Cancel editing
|
|
const handleCancelEdit = () => {
|
|
setEditingOptionId(null);
|
|
};
|
|
|
|
// Move option up in the list
|
|
const handleMoveOptionUp = async (index: number) => {
|
|
if (index <= 0) return; // Already at the top
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Create a new array with the option moved up
|
|
const updatedOptions = [...voteOptions];
|
|
[updatedOptions[index - 1], updatedOptions[index]] = [updatedOptions[index], updatedOptions[index - 1]];
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
// Move option down in the list
|
|
const handleMoveOptionDown = async (index: number) => {
|
|
if (index >= voteOptions.length - 1) return; // Already at the bottom
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Create a new array with the option moved down
|
|
const updatedOptions = [...voteOptions];
|
|
[updatedOptions[index], updatedOptions[index + 1]] = [updatedOptions[index + 1], updatedOptions[index]];
|
|
|
|
// 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);
|
|
}
|
|
};
|
|
|
|
// 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
|
|
const optionToRemove = voteOptions.find(option => option.id === optionId);
|
|
if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionToRemove?.label}" 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, index) => (
|
|
<div key={option.id} className="flex items-center justify-between p-2 bg-white">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="flex flex-col">
|
|
<button
|
|
onClick={() => handleMoveOptionUp(index)}
|
|
className="text-gray-600 hover:text-gray-800 disabled:opacity-30"
|
|
disabled={index === 0}
|
|
title="Nach oben verschieben"
|
|
>
|
|
▲
|
|
</button>
|
|
<button
|
|
onClick={() => handleMoveOptionDown(index)}
|
|
className="text-gray-600 hover:text-gray-800 disabled:opacity-30"
|
|
disabled={index === voteOptions.length - 1}
|
|
title="Nach unten verschieben"
|
|
>
|
|
▼
|
|
</button>
|
|
</div>
|
|
<div>
|
|
{editingOptionId === option.id ? (
|
|
<div className="flex items-center">
|
|
<span className="font-medium mr-2">{option.id}:</span>
|
|
<input
|
|
ref={editInputRef}
|
|
type="text"
|
|
value={editedLabel}
|
|
onChange={(e) => setEditedLabel(e.target.value)}
|
|
className="px-2 py-1 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleSaveVoteOption();
|
|
if (e.key === 'Escape') handleCancelEdit();
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={handleSaveVoteOption}
|
|
className="ml-2 text-green-600 hover:text-green-800"
|
|
title="Speichern"
|
|
>
|
|
✓
|
|
</button>
|
|
<button
|
|
onClick={handleCancelEdit}
|
|
className="ml-1 text-gray-600 hover:text-gray-800"
|
|
title="Abbrechen"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<span className="font-medium">{option.id}:</span> {option.label}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
{editingOptionId !== option.id && (
|
|
<button
|
|
onClick={() => handleEditVoteOption(option)}
|
|
className="text-blue-600 hover:text-blue-800"
|
|
title="Option bearbeiten"
|
|
>
|
|
✎
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleRemoveVoteOption(option.id)}
|
|
className="text-red-600 hover:text-red-800"
|
|
title="Option entfernen"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</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"
|
|
ref={labelInputRef}
|
|
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"
|
|
onFocus={() => {
|
|
// Generate a random ID if the field is empty
|
|
if (!newOptionId) {
|
|
const existingIds = voteOptions.map(option => option.id);
|
|
setNewOptionId(generateRandomId(existingIds));
|
|
}
|
|
}}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|