567 lines
17 KiB
Vue
567 lines
17 KiB
Vue
<script setup>
|
|
import { usePageTitle } from '../composables/Core/usePageTitle';
|
|
usePageTitle('Manage Announcements');
|
|
|
|
import { ref, onMounted, computed } from 'vue';
|
|
import { useAnnouncements } from '../composables/useAnnouncements.js';
|
|
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
|
import SearchBar from '../Components/Core/Search/SearchBar.vue';
|
|
|
|
const { fetchAllAdmin, storeAnnouncement, updateAnnouncement, deleteAnnouncement, toggleStatus, uploadPhoto, loading } = useAnnouncements();
|
|
|
|
const announcements = ref([]);
|
|
const showModal = ref(false);
|
|
const editingItem = ref(null);
|
|
const searchQuery = ref('');
|
|
const form = ref({
|
|
title: '',
|
|
content: '',
|
|
photo: '',
|
|
type: 'info',
|
|
is_active: true,
|
|
starts_at: '',
|
|
ends_at: ''
|
|
});
|
|
|
|
const loadAnnouncements = async () => {
|
|
announcements.value = await fetchAllAdmin();
|
|
};
|
|
|
|
const filteredAnnouncements = computed(() => {
|
|
if (!searchQuery.value) return announcements.value;
|
|
const q = searchQuery.value.toLowerCase();
|
|
return announcements.value.filter(a =>
|
|
a.title.toLowerCase().includes(q) ||
|
|
a.content.toLowerCase().includes(q)
|
|
);
|
|
});
|
|
|
|
onMounted(loadAnnouncements);
|
|
|
|
const openCreateModal = () => {
|
|
editingItem.value = null;
|
|
form.value = {
|
|
title: '',
|
|
content: '',
|
|
photo: '',
|
|
type: 'info',
|
|
is_active: true,
|
|
starts_at: '',
|
|
ends_at: ''
|
|
};
|
|
showModal.value = true;
|
|
};
|
|
|
|
const openEditModal = (item) => {
|
|
editingItem.value = item;
|
|
form.value = { ...item };
|
|
// Format dates for input type="datetime-local" if needed
|
|
if (form.value.starts_at) form.value.starts_at = new Date(form.value.starts_at).toISOString().slice(0, 16);
|
|
if (form.value.ends_at) form.value.ends_at = new Date(form.value.ends_at).toISOString().slice(0, 16);
|
|
showModal.value = true;
|
|
};
|
|
|
|
const saveAnnouncement = async () => {
|
|
try {
|
|
if (editingItem.value) {
|
|
// Use hashkey as target for update
|
|
await updateAnnouncement({ target: editingItem.value.hashkey, ...form.value });
|
|
} else {
|
|
await storeAnnouncement(form.value);
|
|
}
|
|
showModal.value = false;
|
|
await loadAnnouncements();
|
|
if (typeof toastr !== 'undefined') toastr.success('Announcement saved successfully');
|
|
} catch (err) {
|
|
if (typeof toastr !== 'undefined') toastr.error('Failed to save announcement');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (item) => {
|
|
if (confirm(`Are you sure you want to delete "${item.title}"?`)) {
|
|
// Controller expects 'target' (hashkey)
|
|
await deleteAnnouncement(item.hashkey);
|
|
await loadAnnouncements();
|
|
if (typeof toastr !== 'undefined') toastr.success('Announcement deleted');
|
|
}
|
|
};
|
|
|
|
const handleToggle = async (item) => {
|
|
// Controller expects 'target' (hashkey)
|
|
await toggleStatus(item.hashkey);
|
|
await loadAnnouncements();
|
|
};
|
|
|
|
const uploadingPhoto = ref(false);
|
|
const fileInput = ref(null);
|
|
|
|
const triggerFileUpload = () => {
|
|
fileInput.value.click();
|
|
};
|
|
|
|
const handleFileUpload = async (event) => {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
uploadingPhoto.value = true;
|
|
try {
|
|
const data = await uploadPhoto(file);
|
|
|
|
if (data.success) {
|
|
form.value.photo = data.hashkey;
|
|
if (typeof toastr !== 'undefined') toastr.success('Photo uploaded successfully');
|
|
}
|
|
} catch (err) {
|
|
if (typeof toastr !== 'undefined') toastr.error('Photo upload failed');
|
|
} finally {
|
|
uploadingPhoto.value = false;
|
|
}
|
|
};
|
|
|
|
const getBadgeClass = (type) => {
|
|
const map = {
|
|
info: 'bg-soft-info text-info',
|
|
success: 'bg-soft-success text-success',
|
|
warning: 'bg-soft-warning text-warning',
|
|
danger: 'bg-soft-danger text-danger'
|
|
};
|
|
return `badge rounded-pill px-3 py-2 ${map[type] || map.info}`;
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="manage-announcements-page pb-5">
|
|
<div class="tf-container mt-4">
|
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<h3 class="fw_6 mb-0">Manage Announcements</h3>
|
|
<button @click="openCreateModal" class="btn btn-sm btn-primary rounded-pill px-3 py-1 d-flex align-items-center gap-2">
|
|
<i class="fas fa-plus"></i> New Announcement
|
|
</button>
|
|
</div>
|
|
<div class="badge bg-soft-primary px-3 py-2 rounded-pill text-primary">
|
|
{{ filteredAnnouncements.length }} Total
|
|
</div>
|
|
</div>
|
|
|
|
<SearchBar v-model="searchQuery" placeholder="Search announcements..." class="mb-4" />
|
|
|
|
<div v-if="loading && announcements.length === 0" class="text-center py-5">
|
|
<LoadingSpinner />
|
|
<p class="mt-3 text-muted">Loading announcements...</p>
|
|
</div>
|
|
|
|
<div v-else class="table-responsive item-table-container">
|
|
<table class="table table-hover align-middle">
|
|
<thead>
|
|
<tr>
|
|
<th>Announcement</th>
|
|
<th class="text-center">Type</th>
|
|
<th class="text-center">Status</th>
|
|
<th class="text-center">Schedule</th>
|
|
<th class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="item in filteredAnnouncements" :key="item.id">
|
|
<td>
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div v-if="item.photo" class="table-thumbnail">
|
|
<img :src="'/RequestData/File/' + item.photo" alt="Thumbnail">
|
|
</div>
|
|
<div class="flex-grow-1 overflow-hidden">
|
|
<div class="fw_6 text-dark text-truncate">{{ item.title }}</div>
|
|
<div class="text-muted small text-truncate" style="max-width: 300px;">{{ item.content }}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="text-center">
|
|
<span :class="getBadgeClass(item.type)">{{ item.type.toUpperCase() }}</span>
|
|
</td>
|
|
<td class="text-center">
|
|
<div class="form-check form-switch p-0 d-flex justify-content-center">
|
|
<input class="form-check-input ms-0" type="checkbox" role="switch"
|
|
:checked="item.is_active" @change="handleToggle(item)">
|
|
</div>
|
|
<div class="smallest mt-1" :class="item.is_active ? 'text-success' : 'text-danger'">
|
|
{{ item.is_active ? 'Active' : 'Inactive' }}
|
|
</div>
|
|
</td>
|
|
<td class="text-center">
|
|
<div class="small fw_6">{{ new Date(item.created_at).toLocaleDateString() }}</div>
|
|
<div v-if="item.starts_at || item.ends_at" class="smallest text-muted">
|
|
<span v-if="item.starts_at">{{ new Date(item.starts_at).toLocaleDateString() }}</span>
|
|
<span v-if="item.starts_at && item.ends_at"> - </span>
|
|
<span v-if="item.ends_at">{{ new Date(item.ends_at).toLocaleDateString() }}</span>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div class="d-flex justify-content-end gap-2">
|
|
<button @click="openEditModal(item)" class="btn btn-sm btn-icon btn-outline-info" title="Edit">
|
|
<i class="fas fa-edit"></i>
|
|
</button>
|
|
<button @click="handleDelete(item)" class="btn btn-sm btn-icon btn-outline-danger" title="Delete">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="filteredAnnouncements.length === 0">
|
|
<td colspan="5" class="text-center py-5 text-muted">
|
|
<div class="mb-3">
|
|
<i class="fas fa-bullhorn fa-3x opacity-2"></i>
|
|
</div>
|
|
<h5>No announcements found</h5>
|
|
<p>Create your first announcement to share updates with users</p>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal backdrop with transition -->
|
|
<div v-if="showModal" class="modal-backdrop-custom" @click="showModal = false"></div>
|
|
|
|
<!-- Premium Modal -->
|
|
<div v-if="showModal" class="modal-premium-container">
|
|
<div class="modal-premium shadow-lg">
|
|
<div class="modal-premium-header">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<div class="icon-box" :class="editingItem ? 'bg-soft-info text-info' : 'bg-soft-primary text-primary'">
|
|
<i class="fas" :class="editingItem ? 'fa-edit' : 'fa-plus'"></i>
|
|
</div>
|
|
<h5 class="mb-0 fw_7">{{ editingItem ? 'Edit' : 'Create' }} Announcement</h5>
|
|
</div>
|
|
<button class="btn-close-custom" @click="showModal = false">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="modal-premium-body">
|
|
<div class="form-group mb-3">
|
|
<label class="form-label fw_6">Title</label>
|
|
<input v-model="form.title" type="text" class="form-control form-control-lg" placeholder="Enter headline...">
|
|
</div>
|
|
|
|
<div class="form-group mb-3">
|
|
<label class="form-label fw_6">Content</label>
|
|
<textarea v-model="form.content" class="form-control" rows="4" placeholder="What is the announcement about?"></textarea>
|
|
</div>
|
|
|
|
<div class="form-group mb-3">
|
|
<label class="form-label fw_6">Announcement Photo</label>
|
|
<div class="photo-upload-container">
|
|
<div v-if="form.photo" class="photo-preview mb-2">
|
|
<img :src="'/RequestData/File/' + form.photo" alt="Preview">
|
|
<button @click="form.photo = ''" class="btn-remove-photo">
|
|
<i class="fas fa-times-circle"></i>
|
|
</button>
|
|
</div>
|
|
<div v-else class="photo-placeholder" @click="triggerFileUpload">
|
|
<i v-if="uploadingPhoto" class="fas fa-spinner fa-spin"></i>
|
|
<i v-else class="fas fa-camera fa-2x"></i>
|
|
<span>{{ uploadingPhoto ? 'Uploading...' : 'Click to upload photo' }}</span>
|
|
</div>
|
|
<input type="file" ref="fileInput" class="d-none" accept="image/*" @change="handleFileUpload">
|
|
</div>
|
|
<small class="text-muted smallest">Recommended: 800x400 landscape image</small>
|
|
</div>
|
|
|
|
<div class="row g-3 mb-3">
|
|
<div class="col-md-6">
|
|
<div class="form-group">
|
|
<label class="form-label fw_6">Announcement Type</label>
|
|
<select v-model="form.type" class="form-select form-control">
|
|
<option value="info">Info (Blue)</option>
|
|
<option value="success">Success (Green)</option>
|
|
<option value="warning">Warning (Yellow)</option>
|
|
<option value="danger">Danger (Red)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="form-group">
|
|
<label class="form-label fw_6">Display Status</label>
|
|
<div class="d-flex align-items-center gap-2 mt-2">
|
|
<div class="form-check form-switch m-0">
|
|
<input class="form-check-input" type="checkbox" v-model="form.is_active" id="activeStatus">
|
|
<label class="form-check-label ms-2" for="activeStatus">
|
|
{{ form.is_active ? 'Currently Active' : 'Hidden / Draft' }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<div class="form-group">
|
|
<label class="form-label fw_6">Starts Displaying</label>
|
|
<input v-model="form.starts_at" type="datetime-local" class="form-control">
|
|
<small class="text-muted smallest">Optional: Schedule start date</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="form-group">
|
|
<label class="form-label fw_6">Ends Displaying</label>
|
|
<input v-model="form.ends_at" type="datetime-local" class="form-control">
|
|
<small class="text-muted smallest">Optional: Expiration date</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-premium-footer">
|
|
<button @click="showModal = false" class="btn btn-sm btn-light rounded-pill px-3">Cancel</button>
|
|
<button @click="saveAnnouncement" class="btn btn-sm btn-primary rounded-pill px-4 shadow-sm fw_6">
|
|
{{ editingItem ? 'Update Announcement' : 'Publish Now' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.manage-announcements-page {
|
|
background: #f8fbff;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.item-table-container {
|
|
background: white;
|
|
border-radius: 20px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
|
padding: 1rem;
|
|
}
|
|
|
|
.table-thumbnail {
|
|
width: 50px;
|
|
height: 50px;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.table-thumbnail img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.table thead th {
|
|
background: #f8fbff;
|
|
color: #0085ff;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
font-size: 0.75rem;
|
|
letter-spacing: 0.8px;
|
|
padding: 1.25rem 1rem;
|
|
border: none;
|
|
}
|
|
|
|
.table tbody td {
|
|
padding: 1.25rem 1rem;
|
|
border-bottom: 1px solid #f0f3f8;
|
|
}
|
|
|
|
.bg-soft-primary { background-color: rgba(66, 185, 131, 0.1); }
|
|
.bg-soft-info { background-color: rgba(23, 162, 184, 0.1); }
|
|
.bg-soft-success { background-color: rgba(40, 167, 69, 0.1); }
|
|
.bg-soft-warning { background-color: rgba(255, 193, 7, 0.1); }
|
|
.bg-soft-danger { background-color: rgba(220, 53, 69, 0.1); }
|
|
|
|
.smallest { font-size: 0.7rem; }
|
|
.fw_7 { font-weight: 700; }
|
|
|
|
.btn-icon {
|
|
width: 38px;
|
|
height: 38px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 12px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.form-switch .form-check-input {
|
|
width: 2.8em;
|
|
height: 1.4em;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.form-switch .form-check-input:checked {
|
|
background-color: #42b983;
|
|
border-color: #42b983;
|
|
}
|
|
|
|
/* Modal Styling */
|
|
.modal-backdrop-custom {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(15, 23, 42, 0.6);
|
|
backdrop-filter: blur(4px);
|
|
z-index: 1040;
|
|
}
|
|
|
|
.modal-premium-container {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1050;
|
|
pointer-events: none;
|
|
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.modal-premium {
|
|
background: white;
|
|
width: min(100vw - 32px, 540px);
|
|
max-height: min(90vh, calc(100dvh - 80px));
|
|
border-radius: 24px;
|
|
pointer-events: auto;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
animation: modalIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
|
|
@keyframes modalIn {
|
|
from { transform: translateY(20px) scale(0.95); opacity: 0; }
|
|
to { transform: translateY(0) scale(1); opacity: 1; }
|
|
}
|
|
|
|
.modal-premium-header {
|
|
padding: 1.5rem 2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
}
|
|
|
|
.icon-box {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.modal-premium-body {
|
|
padding: 1.5rem;
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
}
|
|
|
|
.modal-premium-footer {
|
|
padding: 1.5rem 2rem;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 1rem;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.form-control, .form-select {
|
|
border-radius: 12px;
|
|
border: 1px solid #e2e8f0;
|
|
padding: 0.75rem 1rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.form-control:focus {
|
|
border-color: #42b983;
|
|
box-shadow: 0 0 0 4px rgba(66, 185, 131, 0.1);
|
|
}
|
|
|
|
.btn-close-custom {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.25rem;
|
|
color: #94a3b8;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btn-primary {
|
|
background-color: #42b983;
|
|
border-color: #42b983;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background-color: #38a169;
|
|
border-color: #38a169;
|
|
}
|
|
|
|
.photo-upload-container {
|
|
border: 2px dashed #cbd5e1;
|
|
border-radius: 16px;
|
|
padding: 10px;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.photo-preview {
|
|
position: relative;
|
|
width: 100%;
|
|
max-height: 200px;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
background: #000;
|
|
}
|
|
|
|
.photo-preview img {
|
|
max-width: 100%;
|
|
max-height: 200px;
|
|
}
|
|
|
|
.btn-remove-photo {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
background: rgba(255, 255, 255, 0.8);
|
|
border: none;
|
|
border-radius: 50%;
|
|
color: #ef4444;
|
|
font-size: 1.5rem;
|
|
padding: 0;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-remove-photo:hover {
|
|
transform: scale(1.1);
|
|
color: #b91c1c;
|
|
}
|
|
|
|
.photo-placeholder {
|
|
height: 100px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
color: #64748b;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.photo-placeholder:hover {
|
|
background: #f1f5f9;
|
|
}
|
|
|
|
.opacity-2 { opacity: 0.2; }
|
|
</style>
|
|
|