543 lines
14 KiB
Vue
543 lines
14 KiB
Vue
<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>
|