Files
Voting-System/client/src/views/AdminDashboard.vue
2026-05-03 16:55:22 +05:30

543 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="admin-page">
<div class="admin-container">
<header class="admin-header">
<h1>Admin Control Panel</h1>
<div class="header-actions">
<button @click="fetchData" class="secondary-btn">🔄 Refresh Data</button>
</div>
</header>
<div class="dashboard-layout">
<!-- Left: Forms -->
<section class="config-section">
<!-- Position Management -->
<div class="card">
<h3>Manage Positions</h3>
<form @submit.prevent="addPosition" class="admin-form">
<div class="form-group">
<label>Position Title</label>
<input v-model="newPosition.title" type="text" placeholder="e.g. Head Boy" required />
</div>
<div class="form-group">
<label>Display Order</label>
<input v-model="newPosition.display_order" type="number" placeholder="0" />
</div>
<button type="submit" :disabled="loading" class="primary-btn">
Add Position
</button>
</form>
<div class="positions-list">
<div v-for="pos in positions" :key="pos.id" class="pos-tag">
{{ pos.title }}
</div>
</div>
</div>
<!-- Candidate Registration -->
<div class="card">
<h3>Register New Candidate</h3>
<form @submit.prevent="addCandidate" class="admin-form">
<div class="form-group">
<label>Candidate Name</label>
<input v-model="newCandidate.name" type="text" placeholder="Full Name" required />
</div>
<div class="form-group">
<label>Select Position</label>
<select v-model="newCandidate.position_id" required class="admin-select">
<option disabled value="">Choose position...</option>
<option v-for="pos in positions" :key="pos.id" :value="pos.id">
{{ pos.title }}
</option>
</select>
</div>
<div class="form-group">
<label>Profile Image</label>
<div class="file-input-wrapper">
<input type="file" @change="handleImageUpload" accept="image/*" id="candidate-img" class="hidden-input" required />
<label for="candidate-img" class="file-label">
<span v-if="!newCandidate.image">Choose Image...</span>
<span v-else>{{ newCandidate.image.name }}</span>
</label>
</div>
</div>
<button type="submit" :disabled="loading || positions.length === 0" class="primary-btn">
{{ loading ? 'Saving...' : 'Add Candidate' }}
</button>
<p v-if="positions.length === 0" class="hint">Create a position first!</p>
</form>
</div>
<div class="card danger-card">
<h3>Danger Zone</h3>
<p>Delete all votes and reset the election system. This cannot be undone.</p>
<button @click="showResetModal = true" class="danger-btn">Reset All Votes</button>
</div>
</section>
<!-- Right: Results -->
<section class="results-section">
<div class="card">
<h3>Live Election Results</h3>
<div class="results-container">
<div v-if="groupedResults.length === 0" class="empty-state">
No data available. Register positions and candidates first.
</div>
<div v-for="group in groupedResults" :key="group.position_id" class="position-group">
<h4 class="group-title">{{ group.position_title }}</h4>
<div v-for="candidate in group.candidates" :key="candidate.candidate_id" class="result-row">
<div class="row-header">
<div class="cand-details-admin">
<span class="cand-name">{{ candidate.candidate_name }}</span>
<button @click="deleteCandidate(candidate.candidate_id)" class="delete-btn" title="Remove Candidate">🗑</button>
</div>
<span class="vote-count">{{ candidate.vote_count }} Votes</span>
</div>
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: getPercentage(candidate.vote_count, group.total) + '%' }"
></div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Reset Modal -->
<Transition name="fade">
<div v-if="showResetModal" class="modal-backdrop">
<div class="modal reset-modal">
<div class="warning-icon"></div>
<h2>Critical Action Required</h2>
<p>Please complete the following to confirm the full system reset.</p>
<div class="confirm-inputs">
<div class="form-group">
<label>Admin Password</label>
<input v-model="resetConfirm.adminPassword" type="password" />
</div>
<div class="form-group">
<label>Secondary Key (resetvotes2026)</label>
<input v-model="resetConfirm.resetPassword" type="password" />
</div>
<div class="form-group">
<label>Type: "I confirm to reset all votes"</label>
<input v-model="resetConfirm.sentence" type="text" placeholder="Type sentence exactly" />
</div>
</div>
<div class="modal-footer">
<button
class="final-reset-btn"
:disabled="!isResetFormValid"
@click="handleReset"
>
PERFORM SYSTEM RESET
</button>
<button class="cancel-btn" @click="closeResetModal">Cancel</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import { candidateService, voteService, positionService } from '../services/api';
const results = ref([]);
const positions = ref([]);
const loading = ref(false);
const showResetModal = ref(false);
const newPosition = reactive({ title: '', display_order: 0 });
const newCandidate = reactive({ name: '', position_id: '', image: null });
const resetConfirm = reactive({ adminPassword: '', resetPassword: '', sentence: '' });
const groupedResults = computed(() => {
const groups = {};
results.value.forEach(r => {
if (!groups[r.position_id]) {
groups[r.position_id] = {
position_id: r.position_id,
position_title: r.position_title,
candidates: [],
total: 0
};
}
groups[r.position_id].candidates.push(r);
groups[r.position_id].total += r.vote_count;
});
return Object.values(groups);
});
const getPercentage = (count, total) => total === 0 ? 0 : (count / total) * 100;
const isResetFormValid = computed(() => {
return resetConfirm.adminPassword === 'adminkcps2026' &&
resetConfirm.resetPassword === 'resetvotes2026' &&
resetConfirm.sentence === 'I confirm to reset all votes';
});
onMounted(fetchData);
async function fetchData() {
await fetchPositions();
await fetchResults();
}
async function fetchPositions() {
try {
const response = await positionService.getAll();
positions.value = response.data;
} catch (err) { console.error('Positions fetch failed'); }
}
async function fetchResults() {
try {
const response = await voteService.getResults();
results.value = response.data;
} catch (err) { console.error('Results fetch failed'); }
}
async function addPosition() {
loading.value = true;
try {
await positionService.create(newPosition);
newPosition.title = ''; newPosition.display_order = 0;
await fetchPositions();
} catch (err) { alert('Failed to add position'); }
finally { loading.value = false; }
}
function handleImageUpload(e) { newCandidate.image = e.target.files[0]; }
async function addCandidate() {
loading.value = true;
const formData = new FormData();
formData.append('name', newCandidate.name);
formData.append('position_id', newCandidate.position_id);
formData.append('image', newCandidate.image);
try {
await candidateService.create(formData);
alert('Candidate Added!');
newCandidate.name = ''; newCandidate.position_id = ''; newCandidate.image = null;
fetchResults();
} catch (err) { alert('Save failed'); }
finally { loading.value = false; }
}
async function deleteCandidate(id) {
if (!confirm('Are you sure you want to delete this candidate? This will also remove all votes they received.')) return;
try {
await candidateService.delete(id);
await fetchResults();
} catch (err) {
alert(err.response?.data?.error || 'Failed to delete candidate');
}
}
async function handleReset() {
if (!confirm('This is the FINAL check. Continue?')) return;
try {
await voteService.resetVotes(resetConfirm);
alert('System Reset Successfully.');
closeResetModal();
fetchResults();
} catch (err) { alert('Reset Failed'); }
}
function closeResetModal() {
showResetModal.value = false;
resetConfirm.adminPassword = ''; resetConfirm.resetPassword = ''; resetConfirm.sentence = '';
}
</script>
<style scoped>
.admin-page {
min-height: 100vh;
background-color: var(--bg-color);
padding: 2rem;
}
.admin-container {
max-width: 1200px;
margin: 0 auto;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
}
.admin-header h1 {
font-size: 2rem;
color: var(--accent-color);
}
.dashboard-layout {
display: grid;
grid-template-columns: 400px 1fr;
gap: 2rem;
}
.card {
background: var(--card-bg);
padding: 2rem;
border-radius: 12px;
border: 1px solid var(--border-color);
margin-bottom: 2rem;
}
h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.25rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.75rem;
color: var(--text-primary);
}
.form-group {
margin-bottom: 1.25rem;
}
label {
display: block;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
input, .admin-select {
width: 100%;
background: #252932;
border: 1px solid var(--border-color);
padding: 0.8rem;
border-radius: 6px;
color: var(--text-primary);
box-sizing: border-box;
}
.admin-select {
cursor: pointer;
}
.positions-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.pos-tag {
background: #252932;
padding: 0.3rem 0.8rem;
border-radius: 15px;
font-size: 0.8rem;
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.hidden-input {
display: none;
}
.file-label {
display: block;
background: #252932;
border: 1px dashed var(--border-color);
padding: 1rem;
text-align: center;
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
}
.file-label:hover {
border-color: var(--accent-color);
}
.primary-btn {
width: 100%;
background: var(--accent-color);
color: #fff;
padding: 1rem;
border: none;
border-radius: 6px;
font-weight: 700;
cursor: pointer;
}
.secondary-btn {
background: #252932;
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 0.6rem 1.2rem;
border-radius: 6px;
cursor: pointer;
}
.danger-card {
border-color: rgba(231, 76, 60, 0.3);
}
.danger-card h3 {
color: var(--danger-color);
}
.danger-btn {
background: var(--danger-color);
color: #fff;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 6px;
font-weight: 700;
cursor: pointer;
}
/* Results */
.position-group {
margin-bottom: 3rem;
}
.group-title {
color: var(--accent-color);
font-size: 1.1rem;
margin-bottom: 1.5rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.result-row {
margin-bottom: 1.5rem;
}
.row-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
align-items: center;
}
.cand-details-admin {
display: flex;
align-items: center;
gap: 0.8rem;
}
.delete-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0;
opacity: 0.5;
transition: opacity 0.2s;
}
.delete-btn:hover {
opacity: 1;
}
.cand-name {
font-weight: 700;
color: var(--text-primary);
}
.vote-count {
font-weight: 800;
color: var(--accent-color);
}
.progress-track {
height: 10px;
background: #252932;
border-radius: 5px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent-color);
border-radius: 5px;
transition: width 1s ease-out;
}
.hint {
font-size: 0.8rem;
color: var(--danger-color);
margin-top: 0.5rem;
}
/* Modal */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background: var(--card-bg);
padding: 3rem;
border-radius: 20px;
border: 1px solid var(--border-color);
max-width: 500px;
width: 90%;
text-align: center;
}
.warning-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.confirm-inputs {
margin: 2rem 0;
text-align: left;
}
.final-reset-btn {
width: 100%;
background: var(--danger-color);
color: #fff;
padding: 1rem;
border: none;
border-radius: 6px;
font-weight: 800;
margin-bottom: 1rem;
cursor: pointer;
}
.final-reset-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.cancel-btn {
background: transparent;
color: var(--text-secondary);
border: none;
cursor: pointer;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>