Files
BarangaySystem/resources/js/Pages/ManageAnnouncements.vue
2026-06-06 18:43:00 +08:00

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>