elearning/frontend_management/pages/admin/audit-log/index.vue
Missez 031ca5c984
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 6s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 1s
feat: Add initial e-learning frontend setup including admin and instructor services, layouts, and pages.
2026-02-24 09:25:02 +07:00

452 lines
14 KiB
Vue

<template>
<div class="p-6">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Audit Logs</h1>
<p class="text-gray-600 mt-1">ประวการใชงานระบบและกจกรรมตางๆ</p>
</div>
<div class="flex gap-2">
<q-btn
color="primary"
outline
icon="refresh"
label="รีเฟรช"
@click="refreshData"
/>
<q-btn
color="negative"
flat
icon="delete_sweep"
label="ล้างประวัติเก่า"
@click="openCleanupDialog"
/>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6 mb-8">
<q-card class="bg-primary text-white">
<q-card-section>
<div class="text-h6">Logs งหมด</div>
<div class="text-h3 text-weight-bold">{{ stats.totalLogs }}</div>
</q-card-section>
</q-card>
<q-card class="bg-secondary text-white">
<q-card-section>
<div class="text-h6">Logs นน</div>
<div class="text-h3 text-weight-bold">{{ stats.todayLogs }}</div>
</q-card-section>
</q-card>
</div>
<!-- Filter Bar -->
<q-card class="mb-6">
<q-card-section>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
<q-input
v-model.number="filters.userId"
label="User ID"
outlined
dense
type="number"
clearable
/>
<q-select
v-model="filters.action"
label="Action"
outlined
dense
:options="actionOptions"
use-input
fill-input
hide-selected
input-debounce="0"
@filter="filterActions"
clearable
/>
<q-input
v-model="filters.entityType"
label="Entity Type"
outlined
dense
clearable
/>
<q-input
v-model="filters.startDate"
label="Start Date"
outlined
dense
type="date"
stack-label
clearable
/>
<q-input
v-model="filters.endDate"
label="End Date"
outlined
dense
type="date"
stack-label
clearable
/>
</div>
<div class="flex justify-end mt-4 gap-2">
<q-btn flat label="ล้างตัวกรอง" color="grey" @click="resetFilters" />
<q-btn color="primary" icon="search" label="ค้นหา" @click="fetchLogs" />
</div>
</q-card-section>
</q-card>
<!-- Data Table -->
<q-table
:rows="logs"
:columns="columns"
row-key="id"
:loading="loading"
v-model:pagination="pagination"
@request="onRequest"
:rows-per-page-options="[5, 10, 20, 50, 0]"
flat
bordered
>
<!-- Action Custom Column -->
<template v-slot:body-cell-action="props">
<q-td :props="props">
<q-badge :color="getActionColor(props.value)">
{{ props.value }}
</q-badge>
</q-td>
</template>
<!-- User Custom Column -->
<template v-slot:body-cell-user="props">
<q-td :props="props">
<div v-if="props.row.user">
<div class="font-bold">{{ props.row.user.username }}</div>
<div class="text-xs text-gray-500">{{ props.row.user.email }} (ID: {{ props.row.user.id }})</div>
</div>
<span v-else class="text-gray-400">-</span>
</q-td>
</template>
<!-- Created At Custom Column -->
<template v-slot:body-cell-created_at="props">
<q-td :props="props">
{{ formatDate(props.value) }}
</q-td>
</template>
<!-- Actions Column -->
<template v-slot:body-cell-actions="props">
<q-td :props="props" align="center">
<q-btn flat round color="primary" icon="visibility" @click="viewDetails(props.row)">
<q-tooltip>รายละเอยด</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<!-- Details Dialog -->
<q-dialog v-model="detailsDialog" full-width>
<q-card v-if="selectedLog">
<q-card-section>
<div class="text-h6">รายละเอยด Audit Log #{{ selectedLog.id }}</div>
</q-card-section>
<q-card-section class="q-pt-none">
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-subtitle2 text-grey">User</div>
<div>{{ selectedLog.user?.username }} ({{ selectedLog.user?.email }})</div>
</div>
<div>
<div class="text-subtitle2 text-grey">Action</div>
<q-badge :color="getActionColor(selectedLog.action)">{{ selectedLog.action }}</q-badge>
</div>
<div>
<div class="text-subtitle2 text-grey">Time</div>
<div>{{ formatDate(selectedLog.created_at) }}</div>
</div>
<div>
<div class="text-subtitle2 text-grey">Entity</div>
<div>{{ selectedLog.entity_type }} #{{ selectedLog.entity_id }}</div>
</div>
</div>
<q-separator class="my-4" />
<!-- Changes -->
<div v-if="selectedLog.old_value || selectedLog.new_value" class="grid grid-cols-2 gap-4">
<div>
<div class="text-subtitle2 text-grey mb-1">Old Value</div>
<pre class="bg-red-50 p-2 rounded text-xs overflow-auto max-h-40 border border-red-100">{{ tryFormatJson(selectedLog.old_value) }}</pre>
</div>
<div>
<div class="text-subtitle2 text-grey mb-1">New Value</div>
<pre class="bg-green-50 p-2 rounded text-xs overflow-auto max-h-40 border border-green-100">{{ tryFormatJson(selectedLog.new_value) }}</pre>
</div>
</div>
<!-- Metadata -->
<div v-if="selectedLog.metadata" class="mt-4">
<div class="text-subtitle2 text-grey mb-1">Metadata</div>
<pre class="bg-gray-50 p-2 rounded text-xs overflow-auto max-h-40 border border-gray-200">{{ tryFormatJson(selectedLog.metadata) }}</pre>
</div>
</q-card-section>
<q-card-actions align="right" class="bg-white text-primary">
<q-btn flat label="ปิด" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Cleanup Dialog -->
<q-dialog v-model="cleanupDialog">
<q-card style="min-width: 350px">
<q-card-section>
<div class="text-h6">างประวเก</div>
</q-card-section>
<q-card-section class="q-pt-none">
<p>เลอกระยะเวลาทองการเกบไว (ลบขอมลทเกากวากำหนด):</p>
<q-select
v-model="cleanupDays"
:options="[7, 15, 30, 60, 90, 180, 365]"
label="จำนวนวัน"
suffix="วัน"
outlined
dense
/>
<div class="bg-red-50 text-red-800 p-3 rounded mt-4 text-sm">
<q-icon name="warning" class="mr-1"/>
การกระทำนไมสามารถเรยกคนขอมลได
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="ยกเลิก" v-close-popup />
<q-btn flat label="ลบข้อมูล" color="negative" @click="confirmCleanup" />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import { adminService, type AuditLog, type AuditLogStats } from '~/services/admin.service';
definePageMeta({
layout: 'admin',
middleware: 'auth'
});
const $q = useQuasar();
// State
const logs = ref<AuditLog[]>([]);
const stats = ref<AuditLogStats>({
totalLogs: 0,
todayLogs: 0,
actionSummary: [],
recentActivity: []
});
const loading = ref(false);
const detailsDialog = ref(false);
const selectedLog = ref<AuditLog | null>(null);
const cleanupDialog = ref(false);
const cleanupDays = ref(90);
// Filters
const filters = reactive({
userId: undefined as number | undefined,
action: null as string | null,
entityType: '',
startDate: '',
endDate: ''
});
// Pagination for q-table
const pagination = ref({
sortBy: 'created_at',
descending: true,
page: 1,
rowsPerPage: 20,
rowsNumber: 0
});
// Table setup
const columns = [
{ name: 'id', label: 'ID', field: 'id', align: 'left', style: 'width: 60px' },
{ name: 'action', label: 'Action', field: 'action', align: 'left' },
{ name: 'user', label: 'User', field: 'user', align: 'left' },
{ name: 'entity_type', label: 'Entity Type', field: 'entity_type', align: 'left' },
{ name: 'entity_id', label: 'Entity ID', field: 'entity_id', align: 'left' },
{ name: 'created_at', label: 'Time', field: 'created_at', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'center' }
];
// Actions options (for filtering)
const actionOptionsList = [
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'ERROR',
'ENROLL', 'UNENROLL', 'SUBMIT_QUIZ',
'APPROVE_COURSE', 'REJECT_COURSE',
'UPLOAD_FILE', 'DELETE_FILE',
'CHANGE_PASSWORD', 'RESET_PASSWORD',
'VERIFY_EMAIL', 'DEACTIVATE_USER', 'ACTIVATE_USER'
];
const actionOptions = ref(actionOptionsList);
const filterActions = (val: string, update: Function) => {
if (val === '') {
update(() => {
actionOptions.value = actionOptionsList;
});
return;
}
update(() => {
const needle = val.toLowerCase();
actionOptions.value = actionOptionsList.filter(v => v.toLowerCase().indexOf(needle) > -1);
});
};
// Methods
const fetchLogs = async () => {
loading.value = true;
try {
const { page, rowsPerPage } = pagination.value;
const response = await adminService.getAuditLogs(page, rowsPerPage, {
userId: filters.userId,
action: filters.action || undefined,
entityType: filters.entityType || undefined,
startDate: filters.startDate ? new Date(filters.startDate).toISOString() : undefined,
endDate: filters.endDate ? new Date(filters.endDate).toISOString() : undefined
});
logs.value = response.data;
pagination.value.rowsNumber = response.pagination.total;
} catch (error) {
console.error('Failed to fetch logs:', error);
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูล Logs ได้',
position: 'top'
});
} finally {
loading.value = false;
}
};
const fetchStats = async () => {
try {
stats.value = await adminService.getAuditLogStats();
} catch (error) {
console.error('Failed to fetch stats:', error);
}
};
const refreshData = () => {
fetchStats();
fetchLogs();
};
const onRequest = (props: any) => {
const { page, rowsPerPage } = props.pagination;
pagination.value.page = page;
pagination.value.rowsPerPage = rowsPerPage;
fetchLogs();
};
const resetFilters = () => {
filters.userId = undefined;
filters.action = null;
filters.entityType = '';
filters.startDate = '';
filters.endDate = '';
fetchLogs();
};
const viewDetails = (log: AuditLog) => {
selectedLog.value = log;
detailsDialog.value = true;
};
const openCleanupDialog = () => {
cleanupDialog.value = true;
};
const confirmCleanup = async () => {
try {
await adminService.cleanupAuditLogs(cleanupDays.value);
$q.notify({
type: 'positive',
message: `ลบข้อมูลที่เก่ากว่า ${cleanupDays.value} วันเรียบร้อยแล้ว`,
position: 'top'
});
cleanupDialog.value = false;
refreshData();
} catch (error) {
$q.notify({
type: 'negative',
message: 'ไม่สามารถลบข้อมูลได้',
position: 'top'
});
}
};
// Utilities
const tryFormatJson = (str: string | null) => {
if (!str) return '-';
try {
const obj = JSON.parse(str);
return JSON.stringify(obj, null, 2);
} catch (e) {
return str;
}
};
const formatDate = (date: string) => {
if (!date) return '-';
return new Date(date).toLocaleString('th-TH');
};
const getActionColor = (action: string) => {
if (!action) return 'grey';
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative';
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
if (action.includes('LOGIN')) return 'info';
return 'grey-8';
};
// Check for deep link to detail
const route = useRoute();
onMounted(() => {
fetchStats();
fetchLogs();
});
</script>
<style scoped>
/* Hide scrollbar for number input */
:deep(input[type=number]::-webkit-outer-spin-button),
:deep(input[type=number]::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:deep(input[type=number]) {
-moz-appearance: textfield;
}
</style>