1628 lines
77 KiB
Vue
1628 lines
77 KiB
Vue
<template>
|
||
<div class="ultimate-console min-vh-100 bg-white text-dark pb-5">
|
||
<!-- Premium Header -->
|
||
<header class="header-ultimate text-white py-1 shadow-2xl border-bottom border-primary border-4 position-relative overflow-hidden sticky-top" style="z-index: 1020;">
|
||
<!-- Decorative Background Elements -->
|
||
<div class="header-mesh-radial position-absolute top-0 start-0 w-100 h-100 opacity-50"></div>
|
||
|
||
<div class="container py-2 position-relative" style="z-index: 2;">
|
||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-4">
|
||
<div class="d-flex align-items-center gap-4 animate-fade-in">
|
||
<div class="display-container position-relative">
|
||
<div class="icon-glow position-absolute top-50 start-50 translate-middle"></div>
|
||
<i class="fas fa-user-shield fa-4x text-primary-gradient position-relative" style="z-index: 3;"></i>
|
||
<div class="pulse-ring-premium position-absolute top-50 start-50 translate-middle"></div>
|
||
</div>
|
||
<div>
|
||
<h1 class="display-5 fw-black text-white mb-0 ls-tight">Ultimate <span class="text-primary-gradient">Console</span></h1>
|
||
<p class="text-white-50 fw-medium small text-uppercase ls-wide mt-1 letter-spacing-2">
|
||
System Administration & Database Power Tools
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-pills d-flex flex-wrap ">
|
||
<div v-for="(val, label) in quickStats" :key="label" class="badge-pill-premium d-flex m-1 align-items-center gap-3">
|
||
<div class="pill-icon-box">
|
||
<i v-if="label === 'Users'" class="fas fa-users-cog"></i>
|
||
<i v-else-if="label === 'Stores'" class="fas fa-store"></i>
|
||
<i v-else-if="label === 'Transactions'" class="fas fa-exchange-alt"></i>
|
||
<i v-else-if="label === 'POS Sessions'" class="fas fa-cash-register"></i>
|
||
<i v-else-if="label === 'Sys Logs'" class="fas fa-file-invoice"></i>
|
||
<i v-else-if="label === 'Balance'" class="fas fa-wallet"></i>
|
||
<i v-else class="fas fa-chart-bar"></i>
|
||
</div>
|
||
<div>
|
||
<div class="small fw-bold text-white-50 text-uppercase ls-wide" style="font-size: 0.65rem;">{{ label }}</div>
|
||
<div v-if="statsLoading" class="spinner-border spinner-border-sm text-primary"></div>
|
||
<div v-else class="fw-black h5 mb-0 text-white counter-animation">{{ val }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="container px-3">
|
||
<div class="row g-4 justify-content-center">
|
||
<!-- Sidebar Navigation -->
|
||
<div class="col-lg-3">
|
||
<div class="card border-0 shadow-lg rounded-4 p-3 bg-white hover-up overflow-hidden sticky-top" style="top: 18rem; z-index: 10;">
|
||
<div class="d-flex flex-column gap-2">
|
||
<button
|
||
v-for="tab in tabs"
|
||
:key="tab.id"
|
||
@click="activeTab = tab.id"
|
||
class="btn rounded-4 border-0 text-start px-3 py-2 fw-bold d-flex align-items-center gap-3 transition-all"
|
||
:class="activeTab === tab.id ? 'btn-primary-gradient shadow-primary-lg text-white' : 'btn-light text-muted hover-bg-light'"
|
||
>
|
||
<i :class="tab.icon"></i>
|
||
{{ tab.label }}
|
||
<i v-if="activeTab === tab.id" class="fas fa-chevron-right ms-auto"></i>
|
||
</button>
|
||
|
||
<hr class="my-3 opacity-10">
|
||
|
||
<div class="text-center p-3 rounded-4 bg-light small mb-2">
|
||
<div class="fw-bold mb-1">System Health</div>
|
||
<div class="d-flex align-items-center justify-content-center gap-2">
|
||
<span class="status-dot pulse" :class="redisHealthy ? 'bg-success' : 'bg-danger'"></span>
|
||
<span class="fw-bold" :class="redisHealthy ? 'text-success' : 'text-danger'">
|
||
{{ redisHealthy ? 'OPERATIONAL' : 'REDIS DOWN' }}
|
||
</span>
|
||
</div>
|
||
<div v-if="stats?.redis" class="mt-2 d-flex flex-column gap-1">
|
||
<div class="d-flex justify-content-between">
|
||
<span class="text-muted">Redis</span>
|
||
<span class="fw-bold" :class="redisHealthy ? 'text-success' : 'text-danger'">
|
||
{{ redisHealthy ? 'Connected' : 'Disconnected' }}
|
||
</span>
|
||
</div>
|
||
<div v-if="redisHealthy" class="d-flex justify-content-between">
|
||
<span class="text-muted">Ping</span>
|
||
<span class="fw-bold">{{ stats.redis.ping_ms }} ms</span>
|
||
</div>
|
||
<div v-if="redisHealthy && stats.redis.used_memory_human" class="d-flex justify-content-between">
|
||
<span class="text-muted">Memory</span>
|
||
<span class="fw-bold">{{ stats.redis.used_memory_human }}</span>
|
||
</div>
|
||
<div v-if="redisHealthy && stats.redis.version" class="d-flex justify-content-between">
|
||
<span class="text-muted">Version</span>
|
||
<span class="fw-bold">{{ stats.redis.version }}</span>
|
||
</div>
|
||
<div v-if="!redisHealthy && stats.redis.error" class="text-danger small text-truncate" :title="stats.redis.error">
|
||
{{ stats.redis.error }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content Area -->
|
||
<div class="col-lg-9 col-xl-8">
|
||
<div class="content-container animate-slide-up">
|
||
|
||
<!-- Dashboard Tab -->
|
||
<div v-if="activeTab === 'dashboard'" class="tab-content">
|
||
<div class="row g-4 mb-4">
|
||
<div v-for="scard in statCards" :key="scard.label" class="col-md-4">
|
||
<div class="card border-0 shadow-sm rounded-4 p-4 text-center h-100 hover-up bg-white">
|
||
<div class="icon-shape rounded-circle mx-auto mb-3" :class="scard.colorClass">
|
||
<i :class="scard.icon"></i>
|
||
</div>
|
||
<div class="h3 fw-black mb-1 counter-animation">{{ scard.value }}</div>
|
||
<div class="small fw-bold text-muted text-uppercase ls-wide">{{ scard.label }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Maintenance Mode Card -->
|
||
<div class="card border-0 shadow-lg rounded-5 overflow-hidden bg-white mb-4">
|
||
<div class="card-body p-4">
|
||
<div class="row align-items-center">
|
||
<div class="col-lg-7">
|
||
<h4 class="fw-black mb-3 d-flex align-items-center gap-3">
|
||
<i class="fas fa-tools text-primary"></i>
|
||
System Maintenance
|
||
</h4>
|
||
<p class="text-muted pe-lg-5 mb-0">
|
||
When maintenance mode is enabled, non-ultimate accounts will see an "Application Down for Maintenance" screen. All API requests from unauthorized accounts will be blocked.
|
||
</p>
|
||
</div>
|
||
<div class="col-lg-5 text-lg-end mt-4 mt-lg-0">
|
||
<button
|
||
@click="maintenanceToggle"
|
||
class="btn btn-lg rounded-pill px-5 fw-black shadow-lg shadow-hover"
|
||
:class="stats?.maintenance_mode ? 'btn-danger' : 'btn-outline-success border-2'"
|
||
>
|
||
<i :class="stats?.maintenance_mode ? 'fas fa-power-off' : 'fas fa-play-circle'" class="me-2 fa-lg"></i>
|
||
{{ stats?.maintenance_mode ? 'Disable Maintenance' : 'Enable Maintenance' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Global Message Card -->
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white overflow-hidden mb-4">
|
||
<div class="card-body p-4">
|
||
<h4 class="fw-black mb-4 d-flex align-items-center gap-3">
|
||
<i class="fas fa-bullhorn text-warning"></i>
|
||
Global System Broadcast
|
||
</h4>
|
||
<div class="d-flex align-items-center gap-2 bg-light rounded-pill p-2 mb-3 shadow-sm border border-2 border-white focus-within-shadow">
|
||
<div class="ps-4">
|
||
<i class="fas fa-pen-nib text-muted"></i>
|
||
</div>
|
||
<input
|
||
v-model="msgData.text"
|
||
type="text"
|
||
class="form-control bg-transparent border-0 border-0-focus shadow-none flex-grow-1"
|
||
placeholder="Type a global message for everyone..."
|
||
>
|
||
<button @click="broadcastMessage" class="btn btn-warning rounded-pill px-4 fw-black d-flex align-items-center gap-2">
|
||
<i class="fas fa-paper-plane fa-lg"></i>
|
||
Broadcast
|
||
</button>
|
||
</div>
|
||
<div class="d-flex flex-wrap gap-2 justify-content-center">
|
||
<button
|
||
v-for="t in ['info', 'success', 'warning', 'danger']"
|
||
:key="t"
|
||
@click="msgData.type = t"
|
||
class="btn btn-sm rounded-pill px-4 fw-bold text-uppercase border-2"
|
||
:class="msgData.type === t ? 'btn-' + t + ' shadow-sm' : 'btn-outline-' + t"
|
||
>
|
||
{{ t }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Command Lab Tab -->
|
||
<div v-else-if="activeTab === 'lab'" class="tab-content">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white mb-4 overflow-hidden">
|
||
<div class="card-header bg-dark text-white border-0 py-3 px-4">
|
||
<h4 class="fw-black mb-0 d-flex align-items-center gap-3">
|
||
<i class="fas fa-terminal text-primary-gradient"></i>
|
||
Dev Commander
|
||
</h4>
|
||
</div>
|
||
<div class="card-body p-4">
|
||
<div class="alert alert-info border-0 rounded-4 mb-4 d-flex align-items-center gap-3">
|
||
<i class="fas fa-info-circle text-info"></i>
|
||
<span class="small fw-medium text-dark">Only specific administrative Artisan commands are allowed for safety.</span>
|
||
</div>
|
||
|
||
<div class="input-group rounded-4 overflow-hidden mb-4 border border-2 shadow-sm">
|
||
<span class="input-group-text bg-light border-0 ps-4">
|
||
<span class="text-primary-gradient fw-black">ARTISAN:</span>
|
||
</span>
|
||
<input
|
||
v-model="artisanCommand"
|
||
type="text"
|
||
class="form-control border-0 small font-monospace"
|
||
placeholder="e.g., config:clear"
|
||
>
|
||
<button @click="executeArtisan" class="btn btn-primary-gradient px-4 fw-black d-flex align-items-center gap-2">
|
||
<i class="fas fa-terminal fa-lg text-white"></i>
|
||
<span class="text-white">EXEC</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Quick Commands -->
|
||
<div class="d-flex flex-wrap gap-2 mb-4 animate-fade-in" style="animation-delay: 0.1s;">
|
||
<button
|
||
v-for="cmd in [
|
||
{ label: 'Migrate', cmd: 'migrate', icon: 'fas fa-database', color: 'btn-outline-primary' },
|
||
{ label: 'DB Seed', cmd: 'db:seed', icon: 'fas fa-seedling', color: 'btn-outline-success' },
|
||
{ label: 'Reset App (All Users)', cmd: 'reset-app all users', icon: 'fas fa-users-slash', color: 'btn-outline-danger' },
|
||
{ label: 'Clear Cache', cmd: 'cache:clear', icon: 'fas fa-broom', color: 'btn-outline-warning' },
|
||
{ label: 'Optimize', cmd: 'optimize', icon: 'fas fa-bolt', color: 'btn-outline-info' },
|
||
]"
|
||
:key="cmd.cmd"
|
||
@click="artisanCommand = cmd.cmd; executeArtisan();"
|
||
class="btn btn-sm rounded-pill px-3 fw-bold d-flex align-items-center gap-2 transition-all hover-up shadow-sm"
|
||
:class="cmd.color"
|
||
>
|
||
<i :class="cmd.icon"></i>
|
||
{{ cmd.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="commandOutput" class="terminal-view p-4 bg-dark text-white rounded-4 border-0 shadow-lg font-monospace small position-relative group mt-4">
|
||
<button @click="commandOutput = ''" class="btn btn-sm btn-outline-light rounded-pill position-absolute top-0 end-0 m-3 opacity-0 group-hover-opacity-100 transition-all">
|
||
Clear
|
||
</button>
|
||
<div class="text-light-50 mb-2 border-bottom border-white-10 pb-2">Command Output:</div>
|
||
<pre class="mb-0 text-success glow-text font-monospace">{{ commandOutput }}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-4">
|
||
<div class="col-md-4">
|
||
<div class="card border-0 shadow-lg rounded-4 bg-white p-3 text-center h-100 shadow-hover border-hover-primary border-4-hover transition-all">
|
||
<div class="icon-shape bg-soft-primary rounded-circle mx-auto mb-4">
|
||
<i class="fas fa-database fa-2x"></i>
|
||
</div>
|
||
<h5 class="fw-black mb-3">Raw Query Lab</h5>
|
||
<p class="text-muted small px-3">Directly execute SQL queries against the database with real-time results table.</p>
|
||
<ButtonPrimary @click="showQueryModal = true" btnClass="btn btn-primary rounded-3 px-4 fw-semibold mt-auto shadow-sm d-flex align-items-center justify-content-center gap-2">
|
||
<i class="fas fa-database"></i>
|
||
Open Query Lab
|
||
</ButtonPrimary>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card border-0 shadow-lg rounded-4 bg-white p-3 text-center h-100 shadow-hover border-hover-info border-4-hover transition-all">
|
||
<div class="icon-shape bg-soft-info rounded-circle mx-auto mb-4">
|
||
<i class="fas fa-cloud-download-alt fa-2x"></i>
|
||
</div>
|
||
<h5 class="fw-black mb-3">Database Backup</h5>
|
||
<p class="text-muted small px-3">Generate and download a full SQL dump. System will enter maintenance mode briefly.</p>
|
||
<BaseButton @click="initiateBackup" btnClass="btn btn-info rounded-3 text-white shadow-sm d-flex align-items-center justify-content-center gap-2 px-4 fw-semibold mt-auto">
|
||
<i class="fas fa-cloud-download-alt"></i>
|
||
Backup & Download
|
||
</BaseButton>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="card border-0 shadow-lg rounded-4 bg-white p-3 text-center h-100 shadow-hover border-hover-warning border-4-hover transition-all">
|
||
<div class="icon-shape bg-soft-warning rounded-circle mx-auto mb-4">
|
||
<i class="fas fa-tasks fa-2x text-warning"></i>
|
||
</div>
|
||
<h5 class="fw-black mb-3">Unit Testing</h5>
|
||
<p class="text-muted small px-3">Run system health checks, benchmark Redis connections, and verify SSE stability.</p>
|
||
<ButtonWarning btnClass="btn btn-warning rounded-3 px-4 fw-semibold mt-auto text-white shadow-sm d-flex align-items-center justify-content-center gap-2">
|
||
<i class="fas fa-tasks"></i>
|
||
Run Benchmarks
|
||
</ButtonWarning>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Batch Lab Tab -->
|
||
<div v-else-if="activeTab === 'batch'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white p-4">
|
||
<h4 class="fw-black mb-4 d-flex align-items-center gap-3">
|
||
<i class="fas fa-layer-group text-primary"></i>
|
||
Bulk Operations Center
|
||
</h4>
|
||
|
||
<div class="row g-4">
|
||
<div v-for="b in batchOperations" :key="b.label" class="col-md-6">
|
||
<div class="p-4 rounded-5 border-2 border-dashed border-light bg-light-hover transition-all h-100">
|
||
<div class="d-flex align-items-center gap-3 mb-3">
|
||
<div class="icon-shape sm" :class="b.bg">
|
||
<i :class="b.icon"></i>
|
||
</div>
|
||
<div class="fw-black text-dark">{{ b.label }}</div>
|
||
</div>
|
||
<p class="small text-muted mb-4">{{ b.desc }}</p>
|
||
<button class="btn btn-white border rounded-pill px-4 btn-sm fw-bold hover-bg-primary hover-text-white w-100 shadow-sm">
|
||
Configure Operation
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Management Lab Tab -->
|
||
<div v-else-if="activeTab === 'manage'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white p-4">
|
||
<h4 class="fw-black mb-4 d-flex align-items-center gap-3 text-danger">
|
||
<i class="fas fa-radiation text-danger"></i>
|
||
Destructive Zone
|
||
</h4>
|
||
<div class="p-4 rounded-4 bg-danger-5 border border-danger-10 mb-4">
|
||
<div class="d-flex align-items-start gap-3">
|
||
<i class="fas fa-exclamation-triangle mt-1 text-danger"></i>
|
||
<div class="small fw-bold text-danger">CRITICAL WARNING: The following operations cannot be undone. Always verify your backup status before performing data flushes.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="list-group list-group-flush rounded-4 overflow-hidden border">
|
||
<div v-for="flush in flushOptions" :key="flush.id" class="list-group-item p-4 d-flex align-items-center justify-content-between">
|
||
<div>
|
||
<div class="fw-black d-flex align-items-center gap-2">
|
||
{{ flush.label }}
|
||
<span class="badge bg-light text-dark fw-bold border small py-1 px-3 rounded-pill">{{ flush.rows }} records</span>
|
||
</div>
|
||
<div class="small text-muted">{{ flush.desc }}</div>
|
||
</div>
|
||
<button @click="doFlush(flush.id)" class="btn btn-outline-danger btn-sm rounded-pill px-4 fw-bold border-2">
|
||
Flush Now
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Backups Lab Tab -->
|
||
<div v-if="activeTab === 'backups'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white p-0 overflow-hidden">
|
||
<div class="card-header bg-primary text-white p-3 px-4 d-flex align-items-center justify-content-between">
|
||
<h4 class="fw-black mb-0 d-flex align-items-center gap-3">
|
||
<i class="fas fa-history text-white"></i>
|
||
Backup Repository
|
||
</h4>
|
||
<button @click="fetchBackups" class="btn btn-sm btn-glass-premium rounded-pill px-3 py-1 fw-bold">
|
||
<i class="fas fa-sync-alt me-2" :class="{'fa-spin': loadingBackups}"></i>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div class="table-responsive" style="min-height: 400px;">
|
||
<table class="table table-hover align-middle mb-0">
|
||
<thead class="bg-light">
|
||
<tr>
|
||
<th class="ps-5">FILENAME</th>
|
||
<th>SIZE</th>
|
||
<th>CREATED BY</th>
|
||
<th>DATE</th>
|
||
<th class="text-end pe-5">ACTION</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-if="loadingBackups" class="text-center py-5">
|
||
<td colspan="5" class="py-5">
|
||
<div class="spinner-border text-primary mb-3"></div>
|
||
<div class="fw-bold text-muted opacity-50">RETRIEVING BACKUPS...</div>
|
||
</td>
|
||
</tr>
|
||
<tr v-else-if="backupData.length === 0" class="text-center py-5">
|
||
<td colspan="5" class="py-5">
|
||
<i class="fas fa-folder-open fa-3x text-muted opacity-25 mb-3"></i>
|
||
<div class="fw-bold text-muted opacity-50">NO BACKUPS FOUND IN DATABASE</div>
|
||
</td>
|
||
</tr>
|
||
<template v-else>
|
||
<tr v-for="backup in backupData" :key="backup.hashkey">
|
||
<td class="ps-5">
|
||
<div class="d-flex align-items-center gap-3">
|
||
<div class="icon-shape sm bg-soft-info rounded-3">
|
||
<i class="fas fa-file-archive text-info"></i>
|
||
</div>
|
||
<div>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<div v-if="editingBackup === backup.hashkey" class="input-group input-group-sm">
|
||
<input v-model="backup.name" type="text" class="form-control form-control-sm rounded-start-pill" @keyup.enter="saveRename(backup)">
|
||
<button class="btn btn-primary btn-sm rounded-end-pill" @click="saveRename(backup)"><i class="fas fa-check"></i></button>
|
||
</div>
|
||
<div v-else class="fw-bold text-dark cursor-pointer hover-text-primary" @click="editingBackup = backup.hashkey">
|
||
{{ backup.name || backup.filename }}
|
||
<i class="fas fa-edit small ms-1 opacity-25"></i>
|
||
</div>
|
||
</div>
|
||
<div class="small text-muted font-monospace">{{ backup.hashkey.substring(0, 16) }}...</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span class="badge bg-light text-dark border rounded-pill">{{ formatBytes(backup.size_in_bytes) }}</span>
|
||
</td>
|
||
<td>
|
||
<div v-if="backup.creator" class="d-flex align-items-center gap-2">
|
||
<img :src="`https://ui-avatars.com/api/?name=${backup.creator.username}&background=random`" class="rounded-circle" width="24" height="24">
|
||
<span class="small fw-bold">{{ backup.creator.username }}</span>
|
||
</div>
|
||
<span v-else class="text-muted small">System</span>
|
||
</td>
|
||
<td class="small text-muted">
|
||
{{ formatTime(backup.created_at) }}
|
||
</td>
|
||
<td class="text-end pe-5">
|
||
<div class="btn-group shadow-sm rounded-pill overflow-hidden">
|
||
<button @click="downloadBackupByHash(backup.hashkey)" class="btn btn-soft-primary btn-sm px-3 fw-bold" title="Download">
|
||
<i class="fas fa-download fa-lg"></i>
|
||
</button>
|
||
<button @click="doDeleteBackup(backup.hashkey)" class="btn btn-soft-danger btn-sm px-3 fw-bold" title="Delete">
|
||
<i class="fas fa-trash-alt fa-lg"></i>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- System Settings Tab -->
|
||
<div v-else-if="activeTab === 'settings'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white p-4">
|
||
<div class="d-flex align-items-center gap-4 mb-4">
|
||
<div class="icon-shape bg-soft-primary text-primary rounded-circle p-3 shadow-primary-sm">
|
||
<i class="fas fa-cog fa-2x"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="fw-black mb-1">Global System Configuration</h4>
|
||
<p class="text-muted mb-0">Manage application branding, maintenance mode, and advanced system flags.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-light rounded-5 p-5 text-center mb-4 border border-2 border-dashed border-primary-subtle shadow-sm">
|
||
<h5 class="fw-black mb-3">Settings Hub Integrated</h5>
|
||
<p class="text-muted small px-lg-5 mb-4 fw-medium">
|
||
The platform configuration management has been centralized into a dedicated interface for enhanced security and branding control.
|
||
Changes take effect across the entire ecosystem in real-time.
|
||
</p>
|
||
<a href="/system-settings" class="btn btn-primary-gradient rounded-pill px-5 py-3 fw-black shadow-primary-lg animate-bounce-soft">
|
||
<i class="fas fa-external-link-alt me-2"></i> Enter Settings Hub
|
||
</a>
|
||
</div>
|
||
|
||
<div class="row g-4">
|
||
<div v-for="feat in [
|
||
{ title: 'Branding & Identity', desc: 'Customize application name, tagline and business logo.', icon: 'fas fa-paint-brush' },
|
||
{ title: 'Global Controls', desc: 'Toggle maintenance mode and restrict guest access system-wide.', icon: 'fas fa-shield-alt' },
|
||
{ title: 'Operational Flags', desc: 'Fine-tune feature availability and regional settings.', icon: 'fas fa-sliders-h' }
|
||
]" :key="feat.title" class="col-md-4">
|
||
<div class="p-3 rounded-4 border border-light-subtle h-100 bg-white shadow-sm hover-up">
|
||
<div class="d-flex align-items-center gap-3 mb-2">
|
||
<i :class="feat.icon" class="text-primary opacity-75"></i>
|
||
<div class="fw-bold small text-uppercase ls-wide">{{ feat.title }}</div>
|
||
</div>
|
||
<div class="text-muted smaller fw-medium">{{ feat.desc }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modules Tab -->
|
||
<div v-else-if="activeTab === 'modules'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white p-4">
|
||
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-3">
|
||
<div class="d-flex align-items-center gap-4">
|
||
<div class="icon-shape bg-soft-primary text-primary rounded-circle p-3 shadow-primary-sm">
|
||
<i class="fas fa-puzzle-piece fa-2x"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="fw-black mb-1">Module Manager</h4>
|
||
<p class="text-muted mb-0">Toggle features system-wide. These overrides take precedence over <code>.env</code> module flags.</p>
|
||
</div>
|
||
</div>
|
||
<div v-if="savingModules" class="badge bg-soft-primary text-primary rounded-pill px-4 py-2">
|
||
<i class="fas fa-sync-alt fa-spin me-2"></i> SAVING...
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="!modulesSystemEnabled" class="alert alert-warning border-0 rounded-4 d-flex align-items-center gap-3 mb-4">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
<div class="small fw-medium">
|
||
<strong>Module system is disabled</strong> via <code>MODULES_SYSTEM_ENABLED=false</code>. All modules are treated as enabled regardless of these toggles until that env flag is removed.
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loadingModules" class="text-center py-5">
|
||
<div class="spinner-border text-primary mb-3"></div>
|
||
<div class="fw-bold text-muted opacity-50">LOADING MODULES...</div>
|
||
</div>
|
||
|
||
<div v-else class="table-responsive">
|
||
<table class="table table-hover align-middle mb-0">
|
||
<thead class="bg-light">
|
||
<tr>
|
||
<th class="border-0 rounded-start ps-4 fw-bold text-muted text-uppercase small">Module</th>
|
||
<th class="border-0 fw-bold text-muted text-uppercase small">Key</th>
|
||
<th class="border-0 fw-bold text-muted text-uppercase small text-center">Env Default</th>
|
||
<th class="border-0 fw-bold text-muted text-uppercase small text-center">Effective</th>
|
||
<th class="border-0 rounded-end pe-4 fw-bold text-muted text-uppercase small text-end">Override</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(mod, key) in modules" :key="key">
|
||
<td class="ps-4">
|
||
<div class="fw-bold">{{ mod.label }}</div>
|
||
<div class="small text-muted">{{ mod.description }}</div>
|
||
</td>
|
||
<td>
|
||
<code class="bg-light rounded px-2 py-1 small text-muted">{{ key }}</code>
|
||
</td>
|
||
<td class="text-center">
|
||
<span
|
||
class="badge rounded-pill px-3 py-2 fw-bold"
|
||
:class="mod.config_default ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger'"
|
||
>
|
||
{{ mod.config_default ? 'ON' : 'OFF' }}
|
||
</span>
|
||
</td>
|
||
<td class="text-center">
|
||
<span
|
||
class="badge rounded-pill px-3 py-2 fw-bold"
|
||
:class="mod.enabled ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger'"
|
||
>
|
||
<i :class="mod.enabled ? 'fas fa-check-circle me-1' : 'fas fa-times-circle me-1'"></i>
|
||
{{ mod.enabled ? 'Enabled' : 'Disabled' }}
|
||
</span>
|
||
</td>
|
||
<td class="pe-4 text-end">
|
||
<div class="btn-group btn-group-sm shadow-sm rounded-pill overflow-hidden" role="group">
|
||
<button
|
||
type="button"
|
||
@click="setModuleOverride(key, true)"
|
||
class="btn fw-bold px-3"
|
||
:class="mod.override === true ? 'btn-success text-white' : 'btn-outline-success'"
|
||
title="Force enabled"
|
||
>
|
||
<i class="fas fa-check"></i>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="setModuleOverride(key, null)"
|
||
class="btn fw-bold px-3"
|
||
:class="mod.override === null ? 'btn-secondary text-white' : 'btn-outline-secondary'"
|
||
title="Use env default"
|
||
>
|
||
ENV
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="setModuleOverride(key, false)"
|
||
class="btn fw-bold px-3"
|
||
:class="mod.override === false ? 'btn-danger text-white' : 'btn-outline-danger'"
|
||
title="Force disabled"
|
||
>
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Page Controls Tab -->
|
||
<div v-else-if="activeTab === 'pages'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white p-4">
|
||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||
<div class="d-flex align-items-center gap-4">
|
||
<div class="icon-shape bg-soft-primary text-primary rounded-circle p-3 shadow-primary-sm">
|
||
<i class="fas fa-map-signs fa-2x"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="fw-black mb-1">Page Access Controls</h4>
|
||
<p class="text-muted mb-0">System-wide page disabling. Users will be redirected if they try to access a disabled page.</p>
|
||
</div>
|
||
</div>
|
||
<div v-if="savingPages" class="badge bg-soft-primary text-primary rounded-pill px-4 py-2">
|
||
<i class="fas fa-sync-alt fa-spin me-2"></i> SAVING...
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Search Bar -->
|
||
<div class="mb-4">
|
||
<div class="position-relative">
|
||
<i class="fas fa-search position-absolute top-50 start-0 translate-middle-y ms-3 text-muted"></i>
|
||
<input
|
||
v-model="pageSearchQuery"
|
||
type="text"
|
||
class="form-control form-control-lg rounded-pill ps-5 border-2"
|
||
placeholder="Search pages..."
|
||
style="border-color: #e2e8f0;"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Table View -->
|
||
<div class="table-responsive">
|
||
<table class="table table-hover align-middle mb-0">
|
||
<thead class="bg-light">
|
||
<tr>
|
||
<th class="border-0 rounded-start ps-4 fw-bold text-muted text-uppercase small" style="width: 50px;">#</th>
|
||
<th class="border-0 fw-bold text-muted text-uppercase small">Page</th>
|
||
<th class="border-0 fw-bold text-muted text-uppercase small">Route ID</th>
|
||
<th class="border-0 fw-bold text-muted text-uppercase small text-center">Status</th>
|
||
<th class="border-0 fw-bold text-muted text-uppercase small text-center">Go to Page</th>
|
||
<th class="border-0 rounded-end pe-4 fw-bold text-muted text-uppercase small text-end">Toggle</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="(page, index) in filteredPages"
|
||
:key="page.id"
|
||
:class="disabledPages.includes(page.id) ? 'opacity-50' : ''"
|
||
class="page-row"
|
||
>
|
||
<td class="ps-4 text-muted">{{ index + 1 }}</td>
|
||
<td>
|
||
<div class="d-flex align-items-center gap-3">
|
||
<div
|
||
class="d-flex align-items-center justify-content-center rounded-3"
|
||
:class="disabledPages.includes(page.id) ? 'bg-danger-subtle text-danger' : 'bg-primary-subtle text-primary'"
|
||
style="width: 40px; height: 40px;"
|
||
>
|
||
<i :class="page.icon"></i>
|
||
</div>
|
||
<div>
|
||
<div class="fw-bold" :class="disabledPages.includes(page.id) ? 'text-decoration-line-through text-muted' : ''">{{ page.label }}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<code class="bg-light rounded px-2 py-1 small text-muted">{{ page.id }}</code>
|
||
</td>
|
||
<td class="text-center">
|
||
<span
|
||
v-if="disabledPages.includes(page.id)"
|
||
class="badge bg-danger-subtle text-danger rounded-pill px-3 py-2 fw-bold"
|
||
>
|
||
<i class="fas fa-times-circle me-1"></i> Disabled
|
||
</span>
|
||
<span
|
||
v-else
|
||
class="badge bg-success-subtle text-success rounded-pill px-3 py-2 fw-bold"
|
||
>
|
||
<i class="fas fa-check-circle me-1"></i> Enabled
|
||
</span>
|
||
</td>
|
||
<td class="text-center">
|
||
<button
|
||
@click="navigate({ page: page.id })"
|
||
class="btn btn-sm btn-outline-primary rounded-pill px-3 fw-bold"
|
||
:disabled="disabledPages.includes(page.id)"
|
||
:title="disabledPages.includes(page.id) ? 'Page is disabled' : 'Go to ' + page.label"
|
||
>
|
||
<i class="fas fa-external-link-alt me-1"></i> Go
|
||
</button>
|
||
</td>
|
||
<td class="pe-4 text-end">
|
||
<button
|
||
@click="togglePage(page.id)"
|
||
class="btn btn-sm rounded-pill px-3 fw-bold"
|
||
:class="disabledPages.includes(page.id) ? 'btn-success' : 'btn-outline-danger'"
|
||
>
|
||
<i :class="disabledPages.includes(page.id) ? 'fas fa-power-off me-1' : 'fas fa-ban me-1'"></i>
|
||
{{ disabledPages.includes(page.id) ? 'Enable' : 'Disable' }}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="filteredPages.length === 0">
|
||
<td colspan="6" class="text-center py-5 text-muted">
|
||
<i class="fas fa-search fa-2x mb-3 d-block opacity-25"></i>
|
||
No pages found matching "{{ pageSearchQuery }}"
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Accounting Themes Tab -->
|
||
<div v-else-if="activeTab === 'accounting_themes'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white p-4">
|
||
<div class="d-flex align-items-center gap-4 mb-4">
|
||
<div class="icon-shape bg-soft-primary text-primary rounded-circle p-3 shadow-primary-sm">
|
||
<i class="fas fa-palette fa-2x"></i>
|
||
</div>
|
||
<div>
|
||
<h4 class="fw-black mb-1">Accounting Themes</h4>
|
||
<p class="text-muted mb-0">Pick a starter Chart of Accounts preset for this deployment, then re-apply to seed any missing accounts.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loadingThemes" class="text-center py-5">
|
||
<div class="spinner-border text-primary mb-3"></div>
|
||
<div class="fw-bold text-muted opacity-50">LOADING THEMES...</div>
|
||
</div>
|
||
|
||
<div v-else-if="themeInfo">
|
||
<div class="row g-3 mb-4">
|
||
<div
|
||
v-for="opt in themeInfo.options"
|
||
:key="opt.key"
|
||
class="col-md-6 col-lg-4"
|
||
>
|
||
<div
|
||
class="p-3 rounded-4 border h-100 hover-up cursor-pointer position-relative"
|
||
:class="opt.key === themeInfo.current ? 'border-primary border-2 bg-soft-primary' : 'border-light-subtle bg-white'"
|
||
@click="selectThemeKey = opt.key"
|
||
style="cursor: pointer;"
|
||
>
|
||
<div class="d-flex align-items-start gap-2 mb-2">
|
||
<i class="fas fa-palette text-primary mt-1"></i>
|
||
<div class="flex-grow-1">
|
||
<div class="fw-bold">{{ opt.label }}</div>
|
||
<div class="small text-muted">v{{ opt.version }}</div>
|
||
</div>
|
||
<span
|
||
v-if="opt.key === themeInfo.current"
|
||
class="badge bg-primary"
|
||
>Active</span>
|
||
<i
|
||
v-else-if="opt.key === selectThemeKey"
|
||
class="fas fa-check-circle text-primary"
|
||
></i>
|
||
</div>
|
||
<div class="small text-muted">{{ opt.description || 'No description.' }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="d-flex flex-wrap gap-3 align-items-center mb-4 p-3 bg-light rounded-4">
|
||
<div class="small">
|
||
<span class="text-muted">Active:</span>
|
||
<strong class="ms-1">{{ themeInfo.definition.label }}</strong>
|
||
</div>
|
||
<div class="small">
|
||
<span class="text-muted">Theme accounts:</span>
|
||
<strong class="ms-1">{{ themeInfo.counts.theme_tagged }}</strong>
|
||
</div>
|
||
<div class="small">
|
||
<span class="text-muted">User-added:</span>
|
||
<strong class="ms-1">{{ themeInfo.counts.user_added }}</strong>
|
||
</div>
|
||
<div class="small">
|
||
<span class="text-muted">Archived:</span>
|
||
<strong class="ms-1">{{ themeInfo.counts.archived }}</strong>
|
||
</div>
|
||
<div v-if="themeDrift" class="small">
|
||
<span class="text-muted">Missing:</span>
|
||
<strong class="ms-1" :class="themeDrift.totals.missing > 0 ? 'text-warning' : ''">{{ themeDrift.totals.missing }}</strong>
|
||
</div>
|
||
<div v-if="themeDrift && themeDrift.totals.renamed > 0" class="small">
|
||
<span class="text-muted">Renamed:</span>
|
||
<strong class="ms-1">{{ themeDrift.totals.renamed }}</strong>
|
||
</div>
|
||
<div class="ms-auto d-flex gap-2">
|
||
<button
|
||
class="btn btn-outline-primary btn-sm"
|
||
:disabled="!selectThemeKey || selectThemeKey === themeInfo.current || switchingTheme"
|
||
@click="switchTheme"
|
||
>
|
||
<i class="fas fa-shuffle me-1"></i>
|
||
{{ switchingTheme ? 'Switching…' : 'Switch to selected' }}
|
||
</button>
|
||
<button
|
||
class="btn btn-primary btn-sm"
|
||
:disabled="applyingTheme"
|
||
@click="reapplyTheme"
|
||
>
|
||
<i class="fas fa-sync me-1" :class="{ 'fa-spin': applyingTheme }"></i>
|
||
{{ applyingTheme ? 'Applying…' : 'Re-apply active theme' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="small text-muted">
|
||
<i class="fas fa-info-circle me-1"></i>
|
||
Switching only changes which accounts are tracked as "theme accounts" — it does not seed anything.
|
||
Re-applying the active theme is additive only: it never deletes or renames existing accounts.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- API Tokens Tab -->
|
||
<div v-else-if="activeTab === 'api_tokens'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white overflow-hidden">
|
||
<div class="card-body p-4">
|
||
<ApiTokensPanel />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Logs Lab Tab -->
|
||
<div v-else-if="activeTab === 'logs'" class="tab-content animate-slide-up">
|
||
<div class="card border-0 shadow-lg rounded-5 bg-white overflow-hidden">
|
||
<div class="card-header bg-dark text-white p-3 px-4 d-flex align-items-center justify-content-between">
|
||
<h4 class="fw-black mb-0 d-flex align-items-center gap-3">
|
||
<i class="fas fa-clipboard-list text-primary"></i>
|
||
System Intelligence Logs
|
||
</h4>
|
||
<div class="d-flex gap-2">
|
||
<button
|
||
v-for="ltype in [{id: 'database', label: 'Sys Logs', icon: 'fas fa-database'}, {id: 'audit', label: 'Audit', icon: 'fas fa-fingerprint'}, {id: 'file', label: 'Raw Files', icon: 'fas fa-file-code'}]"
|
||
:key="ltype.id"
|
||
@click="fetchLogs(ltype.id)"
|
||
class="btn btn-sm rounded-pill px-3 fw-bold border-0 d-flex align-items-center gap-2"
|
||
:class="activeLogType === ltype.id ? 'btn-primary shadow-primary-sm' : 'btn-outline-light opacity-50'"
|
||
>
|
||
<i :class="ltype.icon" class="small opacity-75"></i>
|
||
{{ ltype.label }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body p-4">
|
||
<div v-if="loadingLogs" class="text-center py-5">
|
||
<div class="spinner-border text-primary mb-3"></div>
|
||
<div class="fw-bold text-muted opacity-50">FETCHING SYSTEM ANALYTICS...</div>
|
||
</div>
|
||
<div v-else-if="!logData || logData.length === 0" class="text-center py-5">
|
||
<i class="fas fa-inbox fa-3x text-muted opacity-25 mb-3"></i>
|
||
<div class="fw-bold text-muted opacity-50 mb-2">NO LOG ENTRIES FOUND</div>
|
||
<div class="small text-muted">No system activity has been recorded yet</div>
|
||
</div>
|
||
<div v-else-if="typeof logData === 'string'" class="terminal-view bg-dark text-light p-4 rounded-4 font-monospace overflow-auto" style="max-height: 500px; font-size: 0.8rem;">
|
||
<pre class="mb-0 text-success">{{ logData }}</pre>
|
||
</div>
|
||
<div v-else>
|
||
<div class="log-list">
|
||
<template v-if="activeLogType === 'database'">
|
||
<div v-for="log in logData" :key="log.uid" class="log-entry d-flex align-items-start gap-3 p-3 rounded-3 mb-2 bg-light-hover transition-all">
|
||
<div class="log-time text-muted small" style="min-width: 120px;">
|
||
{{ formatTime(log.log_time) }}
|
||
</div>
|
||
<div class="log-content flex-grow-1">
|
||
<div class="d-flex align-items-center gap-2 mb-1">
|
||
<span class="badge bg-soft-primary text-primary rounded-pill px-2" style="font-size: 10px;">{{ log.log_type }}</span>
|
||
<span class="badge bg-soft-info text-info rounded-pill px-2" style="font-size: 10px;">{{ log.log_category }}</span>
|
||
</div>
|
||
<div class="text-dark small">{{ log.description }}</div>
|
||
<div class="text-muted small mt-1" v-if="log.useruid">User: {{ log.useruid }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="activeLogType === 'audit'">
|
||
<div v-for="log in logData" :key="log.id" class="log-entry d-flex align-items-start gap-3 p-3 rounded-3 mb-2 bg-light-hover transition-all">
|
||
<div class="log-time text-muted small" style="min-width: 120px;">
|
||
{{ formatTime(log.created_at) }}
|
||
</div>
|
||
<div class="log-content flex-grow-1">
|
||
<div class="d-flex align-items-center gap-2 mb-1">
|
||
<span class="badge bg-info text-white rounded-pill px-2" style="font-size: 10px;">{{ log.table_name }}</span>
|
||
<span class="badge bg-warning text-dark rounded-pill px-2" style="font-size: 10px;">ID #{{ log.target_id }}</span>
|
||
</div>
|
||
<div class="text-dark small">Audit Record Modified</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div v-for="log in logData" :key="log.path" class="log-entry d-flex align-items-start gap-3 p-3 rounded-3 mb-2 bg-light-hover transition-all">
|
||
<div class="log-icon text-muted">
|
||
<i class="fas fa-file-code"></i>
|
||
</div>
|
||
<div class="log-content flex-grow-1">
|
||
<div class="text-dark small font-monospace">{{ log.name }}</div>
|
||
<div class="d-flex gap-3 mt-1">
|
||
<span class="text-muted small">{{ log.size_human }}</span>
|
||
<span class="text-muted small">{{ formatTime(log.last_modified) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Reusable Components -->
|
||
<UltimateQueryModal
|
||
:show="showQueryModal"
|
||
@close="showQueryModal = false"
|
||
/>
|
||
|
||
<!-- Toasts / Notifications -->
|
||
<div class="position-fixed bottom-0 end-0 p-4 mb-5" style="z-index: 9999">
|
||
<div v-for="t in toasts" :key="t.id" class="toast show animate-slide-in mb-3 border-0 shadow-lg rounded-4 overflow-hidden" :class="'bg-' + t.type">
|
||
<div class="d-flex p-3 align-items-center gap-3 text-white">
|
||
<i :class="t.icon"></i>
|
||
<span class="fw-bold">{{ t.msg }}</span>
|
||
<button @click="removeToast(t.id)" class="ms-auto btn-close btn-close-white small"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Confirmation Modals -->
|
||
<ConfirmModal
|
||
v-model="showMaintenanceConfirm"
|
||
title="System Maintenance"
|
||
:message="pendingMaintenanceVal ? 'Enable Maintenance Mode? All non-ultimate users will be locked out.' : 'Disable Maintenance Mode? All users will regain access.'"
|
||
:confirmText="pendingMaintenanceVal ? 'Enable Mode' : 'Disable Mode'"
|
||
:variant="pendingMaintenanceVal ? 'danger' : 'info'"
|
||
@confirm="confirmMaintenanceToggle"
|
||
/>
|
||
|
||
<ConfirmModal
|
||
v-model="showBackupConfirm"
|
||
title="System Backup"
|
||
message="Initiate system-wide backup? The application will enter maintenance mode momentarily while the SQL dump is generated."
|
||
confirmText="Start Backup"
|
||
variant="warning"
|
||
@confirm="confirmInitiateBackup"
|
||
/>
|
||
|
||
<ConfirmModal
|
||
v-model="showFlushConfirm"
|
||
title="Confirm Flush Operation"
|
||
:message="'ARE YOU SURE? You are about to FLUSH ALL ' + (pendingFlushId?.toUpperCase().replace('_', ' ') || '') + '. This operation is critical and cannot be undone.'"
|
||
confirmText="Flush Now"
|
||
variant="danger"
|
||
@confirm="confirmFlush"
|
||
/>
|
||
|
||
<ConfirmModal
|
||
v-model="showDeleteBackupConfirm"
|
||
title="Delete Backup"
|
||
message="Permanently delete this backup from the database? This cannot be undone."
|
||
confirmText="Delete Permanently"
|
||
variant="danger"
|
||
@confirm="confirmDeleteBackup"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
|
||
|
||
<script setup>
|
||
import { ref, onMounted, computed, watch } from 'vue';
|
||
import axios from 'axios';
|
||
import { useUltimate } from '@/composables/useUltimate';
|
||
import { useNavigate } from '@/composables/Core/useNavigate';
|
||
import UltimateQueryModal from '@/Components/Ultimate/UltimateQueryModal.vue';
|
||
import ApiTokensPanel from '@/Components/Ultimate/ApiTokensPanel.vue';
|
||
import ButtonPrimary from '@/Components/Core/Layouts/Buttons/ButtonPrimary.vue';
|
||
import ButtonWarning from '@/Components/Core/Layouts/Buttons/ButtonWarning.vue';
|
||
import BaseButton from '@/Components/Core/Layouts/Buttons/BaseButton.vue';
|
||
import ConfirmModal from '@/Components/Core/ConfirmModal.vue';
|
||
import { useUIStore } from '@/stores/ui';
|
||
|
||
const { navigate } = useNavigate();
|
||
const {
|
||
getStats, stats, loading: statsLoading,
|
||
toggleMaintenance, sendGlobalMessage,
|
||
flushData, runCommand, commandOutput,
|
||
getLogs, downloadBackup, getBackups, downloadBackupByHash, renameBackup, deleteBackup
|
||
} = useUltimate();
|
||
|
||
const uiStore = useUIStore();
|
||
|
||
const activeTab = ref('dashboard');
|
||
const showQueryModal = ref(false);
|
||
const artisanCommand = ref('');
|
||
const toasts = ref([]);
|
||
|
||
// Modal States
|
||
const showMaintenanceConfirm = ref(false);
|
||
const showBackupConfirm = ref(false);
|
||
const showFlushConfirm = ref(false);
|
||
const showDeleteBackupConfirm = ref(false);
|
||
|
||
const pendingFlushId = ref(null);
|
||
const pendingDeleteHash = ref(null);
|
||
const pendingMaintenanceVal = ref(null);
|
||
|
||
|
||
// Logs specific states
|
||
const activeLogType = ref('database');
|
||
const logData = ref([]);
|
||
const loadingLogs = ref(false);
|
||
const loadingBackups = ref(false);
|
||
const backupData = ref([]);
|
||
const editingBackup = ref(null);
|
||
|
||
const msgData = ref({
|
||
text: '',
|
||
type: 'info'
|
||
});
|
||
|
||
const tabs = [
|
||
{ id: 'dashboard', label: 'Monitor', icon: 'fas fa-chart-line fa-lg' },
|
||
{ id: 'settings', label: 'Global Settings', icon: 'fas fa-cog fa-lg' },
|
||
{ id: 'modules', label: 'Modules', icon: 'fas fa-puzzle-piece fa-lg' },
|
||
{ id: 'lab', label: 'Command Lab', icon: 'fas fa-flask fa-lg' },
|
||
{ id: 'batch', label: 'Batch Tools', icon: 'fas fa-boxes fa-lg' },
|
||
{ id: 'manage', label: 'Dangerous Zone', icon: 'fas fa-skull-crossbones fa-lg' },
|
||
{ id: 'backups', label: 'Backups', icon: 'fas fa-history fa-lg' },
|
||
{ id: 'pages', label: 'Page Controls', icon: 'fas fa-map-signs fa-lg' },
|
||
{ id: 'accounting_themes', label: 'Accounting Themes', icon: 'fas fa-palette fa-lg' },
|
||
{ id: 'api_tokens', label: 'API Tokens', icon: 'fas fa-key fa-lg' },
|
||
{ id: 'logs', label: 'System Logs', icon: 'fas fa-briefcase-medical fa-lg' },
|
||
];
|
||
|
||
const allPages = [
|
||
{ id: 'UserList', label: 'User List', icon: 'fas fa-users' },
|
||
{ id: 'ManageStoresAdmin', label: 'Manage Stores', icon: 'fas fa-store' },
|
||
{ id: 'ManageProductAdmin', label: 'Manage Products', icon: 'fas fa-box' },
|
||
{ id: 'ManageGlobalTransactions', label: 'Global Transactions', icon: 'fas fa-hand-holding-usd' },
|
||
{ id: 'UltimateConsole', label: 'Ultimate Console', icon: 'fas fa-user-shield text-danger' },
|
||
{ id: 'VerificationDashboard', label: 'Verification Dashboard', icon: 'fas fa-check-circle' },
|
||
{ id: 'ShipmentList', label: 'Shipment List', icon: 'fas fa-truck' },
|
||
{ id: 'CooperativeList', label: 'Cooperative List', icon: 'fas fa-people-carry' },
|
||
{ id: 'AddTransaction', label: 'Add Transaction', icon: 'fas fa-plus-circle' },
|
||
{ id: 'TransferMyCredit', label: 'Transfer Credit', icon: 'fas fa-exchange-alt' },
|
||
{ id: 'StoreProfileEdit', label: 'Create/Edit Store', icon: 'fas fa-store-alt' },
|
||
{ id: 'PosMain', label: 'POS System', icon: 'fas fa-cash-register' },
|
||
{ id: 'PosHistory', label: 'POS History', icon: 'fas fa-history' },
|
||
{ id: 'ListProductsMarket', label: 'Market Products', icon: 'fas fa-shopping-basket' },
|
||
{ id: 'ListStores', label: 'Market Stores', icon: 'fas fa-shopping-cart' },
|
||
{ id: 'ListReports', label: 'System Reports', icon: 'fas fa-file-invoice-dollar' },
|
||
{ id: 'ManageAnnouncements', label: 'Manage Announcements', icon: 'fas fa-bullhorn' },
|
||
{ id: 'SystemSettings', label: 'System Settings', icon: 'fas fa-cogs' },
|
||
{ id: 'LandingPageEditor', label: 'Landing Page Editor', icon: 'fas fa-palette' },
|
||
{ id: 'AccountingDashboard', label: 'Accounting Dashboard', icon: 'fas fa-file-invoice-dollar' },
|
||
];
|
||
|
||
const disabledPages = ref([]);
|
||
const savingPages = ref(false);
|
||
const pageSearchQuery = ref('');
|
||
|
||
// Module manager state
|
||
const modules = ref({});
|
||
const modulesSystemEnabled = ref(true);
|
||
const loadingModules = ref(false);
|
||
const savingModules = ref(false);
|
||
|
||
// Accounting themes state
|
||
const themeInfo = ref(null);
|
||
const themeDrift = ref(null);
|
||
const loadingThemes = ref(false);
|
||
const selectThemeKey = ref(null);
|
||
const switchingTheme = ref(false);
|
||
const applyingTheme = ref(false);
|
||
|
||
const loadAccountingThemes = async () => {
|
||
loadingThemes.value = true;
|
||
try {
|
||
const [infoRes, driftRes] = await Promise.all([
|
||
axios.post('/admin/accounting/theme', {}),
|
||
axios.post('/admin/accounting/theme/drift', {}),
|
||
]);
|
||
themeInfo.value = infoRes.data ?? null;
|
||
themeDrift.value = driftRes.data?.data ?? null;
|
||
if (themeInfo.value?.current && !selectThemeKey.value) {
|
||
selectThemeKey.value = themeInfo.value.current;
|
||
}
|
||
} catch (err) {
|
||
addToast('Failed to load accounting themes', 'danger', 'fas fa-exclamation-circle');
|
||
} finally {
|
||
loadingThemes.value = false;
|
||
}
|
||
};
|
||
|
||
const switchTheme = async () => {
|
||
if (!selectThemeKey.value || selectThemeKey.value === themeInfo.value?.current) return;
|
||
switchingTheme.value = true;
|
||
try {
|
||
const res = await axios.post('/admin/accounting/theme/set', { key: selectThemeKey.value });
|
||
if (res.data?.success) {
|
||
addToast('Active theme switched', 'success', 'fas fa-check-circle');
|
||
await loadAccountingThemes();
|
||
} else {
|
||
addToast(res.data?.message || 'Could not switch theme', 'danger', 'fas fa-exclamation-circle');
|
||
}
|
||
} catch (err) {
|
||
addToast(err.response?.data?.message || 'Could not switch theme', 'danger', 'fas fa-exclamation-circle');
|
||
} finally {
|
||
switchingTheme.value = false;
|
||
}
|
||
};
|
||
|
||
const reapplyTheme = async () => {
|
||
applyingTheme.value = true;
|
||
try {
|
||
const res = await axios.post('/admin/accounting/theme/apply', {});
|
||
if (res.data?.success) {
|
||
addToast(res.data.message || 'Theme applied', 'success', 'fas fa-check-circle');
|
||
await loadAccountingThemes();
|
||
} else {
|
||
addToast(res.data?.message || 'Apply failed', 'danger', 'fas fa-exclamation-circle');
|
||
}
|
||
} catch (err) {
|
||
addToast(err.response?.data?.message || 'Apply failed', 'danger', 'fas fa-exclamation-circle');
|
||
} finally {
|
||
applyingTheme.value = false;
|
||
}
|
||
};
|
||
|
||
watch(activeTab, (val) => {
|
||
if (val === 'accounting_themes' && !themeInfo.value) {
|
||
loadAccountingThemes();
|
||
}
|
||
});
|
||
|
||
const filteredPages = computed(() => {
|
||
if (!pageSearchQuery.value.trim()) return allPages;
|
||
const query = pageSearchQuery.value.toLowerCase();
|
||
return allPages.filter(page =>
|
||
page.label.toLowerCase().includes(query) ||
|
||
page.id.toLowerCase().includes(query)
|
||
);
|
||
});
|
||
|
||
const loadDisabledPages = async () => {
|
||
try {
|
||
const response = await axios.post('/admin/ultimate/system-settings', { keys: ['disabled_pages'] });
|
||
if (response.data && response.data.success) {
|
||
const raw = response.data.data.find(s => s.key === 'disabled_pages')?.value || '[]';
|
||
disabledPages.value = JSON.parse(raw);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load disabled pages:', error);
|
||
}
|
||
};
|
||
|
||
const togglePage = (pageId) => {
|
||
if (disabledPages.value.includes(pageId)) {
|
||
disabledPages.value = disabledPages.value.filter(id => id !== pageId);
|
||
} else {
|
||
disabledPages.value.push(pageId);
|
||
}
|
||
saveDisabledPages();
|
||
};
|
||
|
||
const saveDisabledPages = async () => {
|
||
savingPages.value = true;
|
||
try {
|
||
const response = await axios.post('/admin/ultimate/system-settings/update', {
|
||
settings: {
|
||
disabled_pages: JSON.stringify(disabledPages.value)
|
||
}
|
||
});
|
||
if (response.data && response.data.success) {
|
||
// Success
|
||
uiStore.setDisabledPages(disabledPages.value);
|
||
uiStore.lastSynced = new Date().toISOString();
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to save disabled pages:', error);
|
||
} finally {
|
||
savingPages.value = false;
|
||
}
|
||
};
|
||
|
||
// Merged with main onMounted below
|
||
|
||
|
||
const batchOperations = [
|
||
{ label: 'Activate All Farmers', icon: 'fas fa-tractor fa-2x', bg: 'bg-soft-success', desc: 'Batch verify all unverified farmer profiles in one click.' },
|
||
{ label: 'Send Weekly Rewards', icon: 'fas fa-gift fa-2x', bg: 'bg-soft-primary', desc: 'Transfer balance to all active users based on transaction volume.' },
|
||
{ label: 'Deactivate Inactive Stores', icon: 'fas fa-store-slash fa-2x', bg: 'bg-soft-warning', desc: 'Hide stores with no active sessions or products.' },
|
||
{ label: 'Bulk Price Update', icon: 'fas fa-tags fa-2x', bg: 'bg-soft-info', desc: 'Apply percentage-based price adjustments across categories.' },
|
||
];
|
||
|
||
const flushOptions = computed(() => [
|
||
{ id: 'transactions', label: 'Transaction History', desc: 'Purge all global transaction records.', rows: stats.value?.transactions || 0 },
|
||
{ id: 'pos_sessions', label: 'POS Sessions', desc: 'Clear all pending and completed checkout sessions.', rows: stats.value?.pos_sessions_count || 0 },
|
||
{ id: 'stores', label: 'All Stores', desc: 'Wipe every store along with its managers and product assignments (prd_str, store_managers, str).', rows: stats.value?.stores || 0 },
|
||
{ id: 'products', label: 'All Products', desc: 'Wipe the global product catalog and all per-store product links (prd_items, prd_str).', rows: stats.value?.products || 0 },
|
||
{ id: 'cooperatives', label: 'All Cooperatives', desc: 'Delete every COOPERATIVE organization and its members, votes, documents, and resolutions.', rows: stats.value?.cooperatives_count || 0 },
|
||
{ id: 'carts', label: 'All Carts', desc: 'Empty every shopping cart and its items (cart_items, carts).', rows: stats.value?.carts_count || 0 },
|
||
{ id: 'farmer_profiles', label: 'Farmer Profiles', desc: 'Wipe all farmer profile records (farmer_profiles).', rows: stats.value?.farmer_profiles_count || 0 },
|
||
{ id: 'cache', label: 'System Cache', desc: 'Flush Redis database and clear all application cache keys.', rows: '-' },
|
||
]);
|
||
|
||
const quickStats = computed(() => {
|
||
if (!stats.value) return {};
|
||
return {
|
||
'Users': stats.value.users,
|
||
'Stores': stats.value.active_stores,
|
||
'Transactions': stats.value.transactions,
|
||
'POS Sessions': stats.value.pos_sessions_count || 0,
|
||
'Sys Logs': stats.value.logs_count || 0,
|
||
'Balance': '₱' + parseFloat(stats.value.total_balance || 0).toLocaleString()
|
||
};
|
||
});
|
||
|
||
const statCards = computed(() => [
|
||
{ label: 'Products Live', value: stats.value?.products || 0, icon: 'fas fa-shopping-basket fa-2x', colorClass: 'bg-soft-primary text-primary' },
|
||
{ label: 'PHP Engine', value: stats.value?.php_version || '8.2', icon: 'fas fa-code fa-2x', colorClass: 'bg-soft-info text-info' },
|
||
{
|
||
label: stats.value?.redis?.connected ? `Redis · ${stats.value?.redis?.ping_ms ?? '–'}ms` : 'Redis Offline',
|
||
value: stats.value?.redis?.connected
|
||
? (stats.value?.redis?.used_memory_human || 'OK')
|
||
: 'DOWN',
|
||
icon: 'fas fa-database fa-2x',
|
||
colorClass: stats.value?.redis?.connected ? 'bg-soft-success text-success' : 'bg-danger-5 text-danger',
|
||
},
|
||
]);
|
||
|
||
const redisHealthy = computed(() => !!stats.value?.redis?.connected);
|
||
|
||
onMounted(async () => {
|
||
loadDisabledPages();
|
||
await getStats();
|
||
if (stats.value?.global_message) {
|
||
msgData.value.text = stats.value.global_message.text;
|
||
msgData.value.type = stats.value.global_message.type;
|
||
}
|
||
});
|
||
|
||
const broadcastMessage = async () => {
|
||
if (!msgData.value.text) {
|
||
addToast('Message cleared', 'info', 'fas fa-check-circle');
|
||
}
|
||
await sendGlobalMessage(msgData.value.text, msgData.value.type);
|
||
addToast('Global broadcast sent!', 'success', 'fas fa-paper-plane');
|
||
};
|
||
|
||
const maintenanceToggle = () => {
|
||
pendingMaintenanceVal.value = !stats.value.maintenance_mode;
|
||
showMaintenanceConfirm.value = true;
|
||
};
|
||
|
||
const confirmMaintenanceToggle = async () => {
|
||
const newVal = pendingMaintenanceVal.value;
|
||
await toggleMaintenance(newVal);
|
||
addToast(`Maintenance ${newVal ? 'ENABLED' : 'DISABLED'}`, newVal ? 'danger' : 'success', 'fas fa-power-off');
|
||
};
|
||
|
||
const initiateBackup = () => {
|
||
showBackupConfirm.value = true;
|
||
};
|
||
|
||
const confirmInitiateBackup = () => {
|
||
addToast('Backup started... Downloading shortly.', 'info', 'fas fa-cloud-download');
|
||
downloadBackup();
|
||
};
|
||
|
||
|
||
const fetchLogs = async (type = 'database') => {
|
||
activeLogType.value = type;
|
||
loadingLogs.value = true;
|
||
try {
|
||
const res = await getLogs(type);
|
||
if (res.success) {
|
||
logData.value = res.data;
|
||
}
|
||
} catch (err) {
|
||
addToast('Failed to fetch logs', 'danger', 'fas fa-exclamation-circle');
|
||
} finally {
|
||
loadingLogs.value = false;
|
||
}
|
||
};
|
||
|
||
const formatTime = (time) => {
|
||
if (!time) return 'N/A';
|
||
return new Date(time).toLocaleString();
|
||
};
|
||
|
||
const doFlush = (id) => {
|
||
pendingFlushId.value = id;
|
||
showFlushConfirm.value = true;
|
||
};
|
||
|
||
const confirmFlush = async () => {
|
||
const id = pendingFlushId.value;
|
||
const res = await flushData(id);
|
||
const affected = res.data?.affected ?? 0;
|
||
addToast(`${id} successfully flushed (${affected} records removed)`, 'success', 'fas fa-trash-alt');
|
||
await getStats();
|
||
};
|
||
|
||
|
||
const executeArtisan = async () => {
|
||
if (!artisanCommand.value) return;
|
||
try {
|
||
const res = await runCommand(artisanCommand.value);
|
||
if (res.success) {
|
||
addToast('Command executed successfully', 'success', 'fas fa-terminal');
|
||
} else {
|
||
addToast(res.message, 'danger', 'fas fa-exclamation-triangle');
|
||
}
|
||
} catch (err) {
|
||
addToast(err.response?.data?.message || 'Execution failed', 'danger', 'fas fa-bomb');
|
||
}
|
||
};
|
||
|
||
const addToast = (msg, type, icon) => {
|
||
const id = Date.now();
|
||
toasts.value.push({ id, msg, type, icon });
|
||
setTimeout(() => removeToast(id), 5000);
|
||
};
|
||
|
||
const removeToast = (id) => {
|
||
toasts.value = toasts.value.filter(t => t.id !== id);
|
||
};
|
||
|
||
const fetchBackups = async () => {
|
||
loadingBackups.value = true;
|
||
try {
|
||
const res = await getBackups();
|
||
if (res.success) {
|
||
backupData.value = res.data.map(b => ({ ...b, name: b.name || b.filename }));
|
||
}
|
||
} catch (err) {
|
||
addToast('Failed to fetch backups', 'danger', 'fas fa-exclamation-circle');
|
||
} finally {
|
||
loadingBackups.value = false;
|
||
}
|
||
}
|
||
|
||
const saveRename = async (backup) => {
|
||
try {
|
||
const res = await renameBackup(backup.hashkey, backup.name);
|
||
if (res.data.success) {
|
||
addToast('Backup renamed', 'success', 'fas fa-check-circle');
|
||
editingBackup.value = null;
|
||
}
|
||
} catch (err) {
|
||
addToast('Rename failed', 'danger', 'fas fa-bomb');
|
||
}
|
||
};
|
||
|
||
const doDeleteBackup = (hash) => {
|
||
pendingDeleteHash.value = hash;
|
||
showDeleteBackupConfirm.value = true;
|
||
};
|
||
|
||
const confirmDeleteBackup = async () => {
|
||
const hash = pendingDeleteHash.value;
|
||
try {
|
||
const res = await deleteBackup(hash);
|
||
if (res.data.success) {
|
||
addToast('Backup deleted', 'success', 'fas fa-trash-alt');
|
||
backupData.value = backupData.value.filter(b => b.hashkey !== hash);
|
||
}
|
||
} catch (err) {
|
||
addToast('Delete failed', 'danger', 'fas fa-bomb');
|
||
}
|
||
};
|
||
|
||
|
||
const formatBytes = (bytes, decimals = 2) => {
|
||
if (bytes === 0) return '0 Bytes';
|
||
const k = 1024;
|
||
const dm = decimals < 0 ? 0 : decimals;
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||
};
|
||
|
||
const fetchModules = async () => {
|
||
loadingModules.value = true;
|
||
try {
|
||
const res = await axios.get('/admin/ultimate/modules');
|
||
if (res.data && res.data.success) {
|
||
modules.value = res.data.data.modules || {};
|
||
modulesSystemEnabled.value = !!res.data.data.system_enabled;
|
||
}
|
||
} catch (err) {
|
||
addToast('Failed to load modules', 'danger', 'fas fa-exclamation-circle');
|
||
} finally {
|
||
loadingModules.value = false;
|
||
}
|
||
};
|
||
|
||
const setModuleOverride = async (key, value) => {
|
||
savingModules.value = true;
|
||
try {
|
||
const res = await axios.post('/admin/ultimate/modules/update', {
|
||
overrides: { [key]: value },
|
||
});
|
||
if (res.data && res.data.success) {
|
||
modules.value = res.data.data.modules || modules.value;
|
||
const label = modules.value[key]?.label || key;
|
||
const msg = value === null
|
||
? `${label}: using env default`
|
||
: `${label} ${value ? 'enabled' : 'disabled'}`;
|
||
addToast(msg, value === false ? 'warning' : 'success', 'fas fa-puzzle-piece');
|
||
}
|
||
} catch (err) {
|
||
addToast(err.response?.data?.message || 'Failed to update module', 'danger', 'fas fa-bomb');
|
||
} finally {
|
||
savingModules.value = false;
|
||
}
|
||
};
|
||
|
||
watch(activeTab, (newTab) => {
|
||
if (newTab === 'backups' && backupData.value.length === 0) {
|
||
fetchBackups();
|
||
}
|
||
if (newTab === 'modules' && Object.keys(modules.value).length === 0) {
|
||
fetchModules();
|
||
}
|
||
});
|
||
|
||
// Initial logs fetch if logs tab is active
|
||
computed(() => {
|
||
if (activeTab.value === 'logs' && logData.value.length === 0) {
|
||
fetchLogs('database');
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;900&display=swap');
|
||
|
||
.ultimate-console {
|
||
font-family: 'Outfit', sans-serif;
|
||
}
|
||
|
||
.fw-black { font-weight: 900; }
|
||
.text-primary-gradient {
|
||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
}
|
||
.btn-primary-gradient {
|
||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||
}
|
||
.shadow-primary-lg { box-shadow: 0 10px 30px rgba(59, 130, 246, 0.3); }
|
||
.shadow-info-lg { box-shadow: 0 10px 30px rgba(13, 202, 240, 0.3); }
|
||
|
||
.bg-soft-primary { background-color: rgba(13, 110, 253, 0.08); }
|
||
.bg-soft-success { background-color: rgba(25, 135, 84, 0.08); }
|
||
.bg-soft-info { background-color: rgba(13, 202, 240, 0.08); }
|
||
.bg-soft-warning { background-color: rgba(255, 193, 7, 0.08); }
|
||
.bg-danger-5 { background-color: rgba(220, 53, 69, 0.05); }
|
||
.white-5 { background-color: rgba(255, 255, 255, 0.05); }
|
||
|
||
.ls-tight { letter-spacing: -0.02em; }
|
||
.ls-wide { letter-spacing: 0.1em; }
|
||
|
||
.badge-pill {
|
||
background: rgba(255,255,255,0.05);
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 99px;
|
||
padding: 0.5rem 1.5rem;
|
||
}
|
||
|
||
.icon-shape {
|
||
width: 64px;
|
||
height: 64px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.icon-shape.sm {
|
||
width: 48px;
|
||
height: 48px;
|
||
}
|
||
|
||
.hover-up {
|
||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||
}
|
||
.hover-up:hover {
|
||
transform: translateY(-8px);
|
||
}
|
||
|
||
.bg-darker { background-color: #121212; }
|
||
.border-white-10 { border-color: rgba(255,255,255,0.1) !important; }
|
||
|
||
.status-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
}
|
||
.pulse {
|
||
animation: status-pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes status-pulse {
|
||
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.7); }
|
||
70% { transform: scale(1); box-shadow: 0 0 0 10px rgba(25, 135, 84, 0); }
|
||
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(25, 135, 84, 0); }
|
||
}
|
||
|
||
.counter-animation {
|
||
animation: counter-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||
}
|
||
|
||
@keyframes counter-pop {
|
||
from { transform: scale(0.8); opacity: 0; }
|
||
to { transform: scale(1); opacity: 1; }
|
||
}
|
||
|
||
.animate-fade-in { animation: fadeIn 0.8s ease-out; }
|
||
.animate-slide-up { animation: slideUp 0.6s ease-out; }
|
||
.border-0-focus:focus { border: none !important; box-shadow: none !important; }
|
||
|
||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||
@keyframes slideUp { from { transform: translateY(30px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||
|
||
.glow-text {
|
||
text-shadow: 0 0 10px rgba(25, 135, 84, 0.5);
|
||
}
|
||
|
||
.transition-all { transition: all 0.3s ease; }
|
||
.btn-close-white { filter: invert(1) grayscale(100%) brightness(200%); }
|
||
|
||
.btn-glass-premium {
|
||
background: rgba(255, 255, 255, 0.15);
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
color: white;
|
||
transition: all 0.3s ease;
|
||
}
|
||
.btn-glass-premium:hover {
|
||
background: rgba(255, 255, 255, 0.25);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||
color: white;
|
||
}
|
||
|
||
.focus-ring:focus {
|
||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
|
||
}
|
||
.focus-within-shadow:focus-within {
|
||
box-shadow: 0 10px 30px rgba(0,0,0,0.1) !important;
|
||
border-color: rgba(13, 110, 253, 0.2) !important;
|
||
}
|
||
.header-ultimate {
|
||
background: #020617; /* Deepest Indigo/Slate */
|
||
border-bottom: 4px solid #6366f1 !important;
|
||
}
|
||
|
||
.header-mesh-radial {
|
||
background-image:
|
||
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.25) 0, transparent 50%),
|
||
radial-gradient(at 50% 0%, rgba(139, 92, 246, 0.15) 0, transparent 50%),
|
||
radial-gradient(at 100% 0%, rgba(217, 70, 239, 0.15) 0, transparent 50%);
|
||
filter: blur(40px);
|
||
}
|
||
|
||
.icon-glow {
|
||
width: 60px;
|
||
height: 60px;
|
||
background: rgba(99, 102, 241, 0.3);
|
||
border-radius: 50%;
|
||
filter: blur(20px);
|
||
}
|
||
|
||
.pulse-ring-premium {
|
||
width: 80px;
|
||
height: 80px;
|
||
border: 2px solid rgba(99, 102, 241, 0.2);
|
||
border-radius: 50%;
|
||
animation: pulse-premium 3s infinite ease-out;
|
||
}
|
||
|
||
@keyframes pulse-premium {
|
||
0% { transform: translate(-50%, -50%) scale(0.8); opacity: 0; }
|
||
50% { opacity: 0.5; }
|
||
100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
|
||
}
|
||
|
||
.badge-pill-premium {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 20px;
|
||
padding: 0.75rem 1.25rem;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
.badge-pill-premium:hover {
|
||
background: rgba(255, 255, 255, 0.07);
|
||
border-color: rgba(99, 102, 241, 0.3);
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.pill-icon-box {
|
||
width: 32px;
|
||
height: 32px;
|
||
background: rgba(99, 102, 241, 0.15);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #818cf8;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.letter-spacing-2 { letter-spacing: 0.2em; }
|
||
.shadow-2xl {
|
||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
.text-white-50 { color: rgba(255, 255, 255, 0.5) !important; }
|
||
|
||
/* Page Controls Table */
|
||
.ultimate-console .page-row {
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
.ultimate-console .page-row:hover {
|
||
background-color: rgba(13, 110, 253, 0.04) !important;
|
||
}
|
||
</style>
|