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

546 lines
20 KiB
Vue

<template>
<div class="system-settings-page min-vh-100 bg-light pb-5">
<!-- Header -->
<header style="margin-top:5vh" class=" bg-dark text-white py-3 shadow-lg border-bottom border-primary border-4 sticky-top-header">
<div class="container py-2">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div class="d-flex align-items-center gap-4 animate-fade-in">
<div class="display-container position-relative d-none d-md-block">
<i class="fas fa-cogs fa-2x text-primary-gradient"></i>
<div class="pulse-ring position-absolute top-50 start-50 translate-middle"></div>
</div>
<div>
<h1 class="h5 fw-black text-white mb-0 ls-tight">Global <span class="text-primary-gradient">Settings</span></h1>
<p class="text-muted fw-medium small text-uppercase ls-wide mt-1 mb-0 d-none d-sm-block">
Platform-wide configuration & Branding
</p>
</div>
</div>
<div class="d-flex gap-2">
<button
@click="saveAll"
class="btn btn-primary-gradient rounded-pill px-3 px-sm-4 fw-bold shadow-lg d-flex align-items-center gap-2"
:disabled="isLoading"
>
<i v-if="isLoading" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas fa-save"></i>
<span class="d-none d-sm-inline">Save Changes</span>
<span class="d-sm-none">Save</span>
</button>
<button
@click="goBack"
class="btn btn-outline-light rounded-pill px-3 px-sm-4 fw-bold border-2"
>
<i class="fas fa-arrow-left me-1 me-sm-2"></i> <span class="d-none d-sm-inline">Back</span>
</button>
</div>
</div>
</div>
</header>
<div class="container pt-2" style="margin-top:10vh">
<div v-if="error" class="alert alert-danger rounded-4 shadow-sm border-0 d-flex align-items-center gap-3">
<i class="fas fa-exclamation-triangle fa-lg"></i>
<div class="fw-bold">{{ error }}</div>
<button @click="error = null" class="btn-close ms-auto"></button>
</div>
<div class="row g-4">
<!-- Sidebar Groups -->
<div class="col-lg-3">
<div class="card border-0 shadow-sm rounded-4 p-3 bg-white sticky-top" style="top: 7rem; z-index: 10;">
<div class="d-flex flex-column gap-2">
<button
v-for="(groupSettings, groupName) in visibleGroups"
:key="groupName"
@click="activeGroup = groupName"
class="btn rounded-4 border-0 text-start px-3 py-2 fw-bold d-flex align-items-center gap-3 transition-all"
:class="activeGroup === groupName ? 'btn-soft-primary text-primary' : 'btn-light text-muted hover-bg-light'"
>
<i :class="getGroupIcon(groupName)"></i>
<span class="text-capitalize">{{ groupName }}</span>
</button>
</div>
</div>
</div>
<!-- Settings Editor -->
<div class="col-lg-9">
<div v-show="activeGroup" class="animate-slide-up">
<div class="card border-0 shadow-lg rounded-5 overflow-hidden bg-white mb-4">
<div class="card-header bg-white border-0 p-4 pb-0">
<h4 class="fw-black mb-0 text-capitalize d-flex align-items-center gap-3">
<i :class="getGroupIcon(activeGroup)" class="text-primary"></i>
{{ activeGroup }} Settings
</h4>
<hr class="mt-4 opacity-10">
</div>
<div class="card-body p-4 pt-2">
<div class="row g-4">
<div v-for="setting in currentGroupSettings" :key="setting.key" class="col-12">
<div class="setting-item p-3 rounded-4 border border-light-subtle hover-bg-light transition-all">
<div class="row align-items-center">
<div class="col-md-5">
<label class="fw-black mb-1 d-block">{{ setting.label }}</label>
<small class="text-muted d-block">{{ setting.description }}</small>
</div>
<div class="col-md-7 mt-3 mt-md-0">
<!-- Boolean Input -->
<div v-if="setting.type === 'boolean'" class="form-check form-switch custom-switch">
<input
class="form-check-input"
type="checkbox"
v-model="formData[setting.key]"
role="switch"
>
<span class="ms-2 fw-bold" :class="formData[setting.key] ? 'text-primary' : 'text-muted'">
{{ formData[setting.key] ? 'Enabled' : 'Disabled' }}
</span>
</div>
<!-- JSON Array Input -->
<div v-else-if="setting.type === 'json'" class="d-flex flex-column gap-3">
<div class="d-flex flex-wrap gap-2 mb-2">
<div
v-for="(item, index) in formData[setting.key]"
:key="index"
class="badge rounded-pill bg-soft-primary text-primary border border-primary-subtle d-flex align-items-center gap-2 py-2 px-3 animate-fade-in"
>
<span class="fw-bold text-uppercase small">{{ item }}</span>
<i
@click="removeItemFromJson(setting.key, index)"
class="fas fa-times-circle cursor-pointer hover-text-danger transition-all"
></i>
</div>
<div v-if="!formData[setting.key] || formData[setting.key].length === 0" class="text-muted small italic opacity-50">
No items added yet.
</div>
</div>
<div class="input-group">
<input
type="text"
v-model="newItemInputs[setting.key]"
class="form-control rounded-start-pill border-2 h-48px px-3 shadow-none"
placeholder="Add new item..."
@keyup.enter="addItemToJson(setting.key)"
>
<button
@click="addItemToJson(setting.key)"
class="btn btn-primary-gradient rounded-end-pill px-4"
type="button"
>
<i class="fas fa-plus me-2"></i> Add
</button>
</div>
</div>
<!-- Image Input -->
<div v-else-if="setting.type === 'image'" class="d-flex align-items-center gap-3">
<div class="logo-preview skeleton-loading rounded-4 border p-2 bg-light d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
<img v-if="logoPreviews[setting.key] || setting.value_url" :src="logoPreviews[setting.key] || setting.value_url" class="img-fluid rounded-3" alt="Logo">
<i v-else class="fas fa-image fa-2x text-muted opacity-25"></i>
</div>
<div class="flex-grow-1">
<input
type="file"
@change="(e) => handleLogoUpload(e, setting.key)"
class="form-control form-control-sm rounded-pill border-2"
accept="image/*"
>
<small class="text-muted mt-1 d-block">Recommended size: 512x512px. PNG or SVG preferred.</small>
</div>
</div>
<!-- Select Input -->
<select
v-else-if="setting.type === 'select'"
v-model="formData[setting.key]"
class="form-select rounded-pill border-2 px-3 fw-medium h-48px"
>
<option
v-for="opt in (setting.options_parsed || [])"
:key="opt"
:value="opt"
>{{ formatOption(opt) }}</option>
</select>
<!-- Organization Select -->
<div v-else-if="setting.type === 'organization'" class="d-flex flex-column gap-2">
<select
v-model="formData[setting.key]"
class="form-select rounded-pill border-2 px-3 fw-medium h-48px"
:disabled="loadingOrganizations"
>
<option :value="null"> None </option>
<option
v-for="org in organizations"
:key="org.hashkey"
:value="org.hashkey"
>{{ org.name }} ({{ org.type }})</option>
</select>
<small v-if="loadingOrganizations" class="text-muted">
<i class="fas fa-spinner fa-spin me-1"></i> Loading organizations
</small>
<small v-else-if="organizations.length === 0" class="text-muted">
No active organizations available.
</small>
</div>
<!-- Textarea Input -->
<textarea
v-else-if="setting.type === 'textarea'"
v-model="formData[setting.key]"
class="form-control rounded-4 border-2 px-3 fw-medium"
rows="4"
:placeholder="'Enter ' + setting.label"
></textarea>
<!-- Text Input -->
<input
v-else
type="text"
v-model="formData[setting.key]"
class="form-control rounded-pill border-2 px-3 fw-medium h-48px"
:placeholder="'Enter ' + setting.label"
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!activeGroup" class="text-center py-5">
<div class="opacity-25 mb-4">
<i class="fas fa-cog fa-5x animate-spin-slow"></i>
</div>
<h4 class="fw-black text-muted">Select a setting group to begin</h4>
<p class="text-muted small">Configurations changes take effect immediately across all user sessions.</p>
</div>
</div>
</div>
</div>
<!-- Feedback Modal -->
<div v-if="successMsg" class="toast-container position-fixed top-0 end-0 p-4 mt-5" style="z-index: 10050;">
<div class="toast show border-0 shadow-lg bg-primary text-white rounded-4 animate-slide-down">
<div class="d-flex p-3 px-4 align-items-center gap-3">
<i class="fas fa-check-circle fa-lg"></i>
<div class="fw-bold">{{ successMsg }}</div>
<button @click="successMsg = ''" class="btn-close btn-close-white ms-auto"></button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useSystemSettings } from '@/composables/useSystemSettings';
import { useUIStore } from '@/stores/ui';
const {
groupedSettings, fetchAdminSettings, updateSettings, uploadLogo, isLoading, error
} = useSystemSettings();
const uiStore = useUIStore();
const activeGroup = ref('general');
const formData = reactive({});
const newItemInputs = reactive({});
const logoPreviews = reactive({});
const successMsg = ref('');
const organizations = ref([]);
const loadingOrganizations = ref(false);
const formatOption = (opt) => {
if (!opt) return '';
return String(opt)
.replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
};
const fetchOrganizations = async () => {
loadingOrganizations.value = true;
try {
const response = await fetch('/admin/ultimate/system-settings/organizations', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
const result = await response.json();
if (result.success && Array.isArray(result.data)) {
organizations.value = result.data;
}
} catch (e) {
console.error('Failed to load organizations', e);
} finally {
loadingOrganizations.value = false;
}
};
const addItemToJson = (key) => {
const value = newItemInputs[key]?.trim();
if (!value) return;
if (!formData[key]) formData[key] = [];
if (!formData[key].includes(value)) {
formData[key].push(value);
}
newItemInputs[key] = '';
};
const removeItemFromJson = (key, index) => {
if (formData[key]) {
formData[key].splice(index, 1);
}
};
const HIDDEN_GROUPS = ['modules'];
const visibleGroups = computed(() => {
const all = groupedSettings.value || {};
return Object.fromEntries(
Object.entries(all).filter(([name]) => !HIDDEN_GROUPS.includes(name))
);
});
const currentGroupSettings = computed(() => {
if (!groupedSettings.value || !activeGroup.value) return [];
return groupedSettings.value[activeGroup.value] || [];
});
const getGroupIcon = (group) => {
const icons = {
general: 'fas fa-globe',
branding: 'fas fa-paint-brush',
advanced: 'fas fa-sliders-h',
security: 'fas fa-shield-alt',
content: 'fas fa-bible',
};
return icons[group] || 'fas fa-cog';
};
const handleLogoUpload = async (event, key) => {
const file = event.target.files[0];
if (!file) return;
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
logoPreviews[key] = e.target.result;
};
reader.readAsDataURL(file);
try {
const result = await uploadLogo(file);
if (result.success) {
formData[key] = result.hashkey;
showToast('Logo updated successfully!');
}
} catch (err) {
// Error handled by composable
}
};
const saveAll = async () => {
try {
const result = await updateSettings(formData);
if (result.success) {
showToast('All settings saved successfully!');
await fetchAdminSettings(); // Refresh to get current state
uiStore.refreshSettings(); // Sync with global UI store
}
} catch (err) {
// Error handled by composable
}
};
const showToast = (msg) => {
successMsg.value = msg;
setTimeout(() => successMsg.value = '', 3000);
};
const goBack = () => {
window.history.back();
};
onMounted(async () => {
await Promise.all([fetchAdminSettings(), fetchOrganizations()]);
// Initialize form data
if (groupedSettings.value) {
Object.keys(groupedSettings.value).forEach(group => {
groupedSettings.value[group].forEach(setting => {
// Handle boolean and JSON parsing
if (setting.type === 'boolean') {
formData[setting.key] = setting.value === 'true' || setting.value === true || setting.value === '1' || setting.value === 1;
} else if (setting.type === 'json') {
try {
formData[setting.key] = typeof setting.value === 'string' ? JSON.parse(setting.value) : (setting.value || []);
} catch (e) {
formData[setting.key] = [];
}
} else if (setting.type === 'organization') {
formData[setting.key] = setting.value ? setting.value : null;
} else {
formData[setting.key] = setting.value;
}
// Add value_url helper for images
if (setting.type === 'image' && setting.value) {
setting.value_url = `/RequestData/File/${setting.value}`;
}
// Parse options for select type
if (setting.type === 'select' && setting.options) {
try {
setting.options_parsed = typeof setting.options === 'string' ? JSON.parse(setting.options) : setting.options;
} catch (e) {
setting.options_parsed = [];
}
}
});
});
}
// Set default active group
if (Object.keys(groupedSettings.value).length > 0 && !activeGroup.value) {
activeGroup.value = Object.keys(groupedSettings.value)[0];
}
});
</script>
<style scoped>
.text-primary-gradient {
background: linear-gradient(135deg, #0d6efd 0%, #00d2ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.btn-primary-gradient {
background: linear-gradient(135deg, #0d6efd 0%, #0db9fd 100%);
border: none;
color: white;
transition: all 0.3s ease;
}
.btn-primary-gradient:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(13, 110, 253, 0.3);
}
.ls-tight { letter-spacing: -0.025em; }
.ls-wide { letter-spacing: 0.1em; }
.fw-black { font-weight: 900; }
.h-48px { height: 48px; }
.animate-fade-in {
animation: fadeIn 0.8s ease-out;
}
.animate-slide-up {
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-down {
animation: slideDown 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.pulse-ring {
width: 80px;
height: 80px;
background: rgba(13, 110, 253, 0.1);
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.5; }
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
}
.hover-bg-light:hover {
background-color: #f8f9fa;
}
.transition-all {
transition: all 0.2s ease-in-out;
}
.custom-switch .form-check-input {
width: 3rem;
height: 1.5rem;
cursor: pointer;
}
.custom-switch .form-check-input:checked {
background-color: #0d6efd;
border-color: #0d6efd;
}
.shadow-primary-lg {
box-shadow: 0 15px 30px rgba(13, 110, 253, 0.2);
}
.animate-spin-slow {
animation: spin 8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.logo-preview {
transition: all 0.3s ease;
}
.logo-preview:hover {
transform: scale(1.05);
}
.bg-soft-primary {
background-color: rgba(13, 110, 253, 0.1);
}
.hover-text-danger:hover {
color: #dc3545 !important;
}
.cursor-pointer {
cursor: pointer;
}
/* Fixed header that stays at the top */
.sticky-top-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1030;
}
</style>