Files
CRM_Montzar/apps/web/src/views/AuditLogsView.vue
T

860 lines
24 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { useAuthStore } from '@/stores/auth';
import type {
AuditLogDto,
AuditLogListResponse,
DeletionArchiveDto,
DeletionArchiveListResponse,
DeletionRetention,
} from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiModal,
UiPageHeader,
UiPagination,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatClockFa, formatJalaliNumeric } from '@/lib/jalali';
type LogTab = 'all' | 'creates' | 'edits' | 'deletes' | 'attendance';
const TABS: Array<{ key: LogTab; label: string; action?: string }> = [
{ key: 'all', label: 'همه' },
{ key: 'attendance', label: 'ورود و خروج' },
{ key: 'creates', label: 'ایجاد', action: 'ایجاد' },
{ key: 'edits', label: 'ویرایش', action: 'ویرایش' },
{ key: 'deletes', label: 'حذف', action: 'حذف' },
];
const ENTITY_LABELS: Record<string, string> = {
auth: 'حساب کاربری',
people: 'مشتری',
companies: 'شرکت',
leads: 'لید',
deals: 'معامله',
activities: 'فعالیت',
notes: 'یادداشت',
tickets: 'تیکت',
quotes: 'پیش‌فاکتور',
invoices: 'فاکتور',
products: 'محصول',
users: 'کاربر',
teams: 'تیم',
};
const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
{ value: 'month', label: 'یک ماه' },
{ value: 'two_months', label: 'دو ماه' },
{ value: 'three_months', label: 'سه ماه' },
{ value: 'six_months', label: 'شش ماه' },
{ value: 'year', label: 'یک سال' },
{ value: 'never', label: 'هرگز' },
];
const router = useRouter();
const auth = useAuthStore();
const isOwner = computed(() => auth.user?.role === 'owner');
/** ورود و خروج فقط برای مالک و مدیر؛ بقیه با مجوز «لاگ فعالیت» فقط لاگ‌ها را می‌بینند */
const visibleTabs = computed(() =>
auth.user?.role === 'owner' || auth.user?.role === 'admin'
? TABS
: TABS.filter((item) => item.key !== 'attendance'),
);
const loading = ref(false);
const savingRetention = ref(false);
const busyId = ref('');
const error = ref('');
const okMsg = ref('');
const q = ref('');
const actorId = ref('');
const actors = ref<Array<{ id: string; fullName: string }>>([]);
const tab = ref<LogTab>('all');
const page = ref(1);
const pageSize = usePageSize('audit-logs');
const data = ref<AuditLogListResponse | null>(null);
const deletions = ref<DeletionArchiveListResponse | null>(null);
const attendance = ref<{
items: Array<{
id: string;
fullName: string;
kind: 'login' | 'logout' | 'forced_logout';
at: string;
afterHours: boolean;
}>;
total: number;
} | null>(null);
const retention = ref<DeletionRetention>('never');
const purgeId = ref<string | null>(null);
const showingArchive = computed(() => tab.value === 'deletes' && isOwner.value);
const showingAttendance = computed(() => tab.value === 'attendance');
const listTotal = computed(() =>
showingAttendance.value
? attendance.value?.total
: showingArchive.value
? deletions.value?.total
: data.value?.total,
);
const totalPages = computed(() => {
const total = listTotal.value;
if (total == null) return 1;
return Math.max(1, Math.ceil(total / pageSize.value));
});
const purgeItem = computed(
() => deletions.value?.items.find((item) => item.id === purgeId.value) ?? null,
);
function entityLabel(entityType: string | null) {
if (!entityType) return 'مورد';
return ENTITY_LABELS[entityType] ?? 'مورد';
}
function rowName(row: AuditLogDto) {
const label = row.label?.trim() ?? '';
const actor = row.actor?.fullName?.trim() ?? '';
if (!label || label === actor || label.includes('/api/')) return '';
return label;
}
function actionDone(action: string) {
if (action === 'ایجاد') return 'ایجاد شد';
if (action === 'حذف') return 'حذف شد';
return 'ویرایش شد';
}
function rowStory(row: AuditLogDto) {
const kind = entityLabel(row.entityType);
const name = rowName(row);
const verb = actionDone(row.action);
return name ? `${kind} «${name}» ${verb}` : `${kind} ${verb}`;
}
function deletionStory(item: DeletionArchiveDto) {
const kind = item.modelLabel?.trim() || 'مورد';
const name = item.label?.trim() ?? '';
return name && name !== kind ? `${kind} «${name}» حذف شد` : `${kind} حذف شد`;
}
function looksLikeId(id: string | null | undefined) {
return !!id && /^c[a-z0-9]{20,}$/i.test(id);
}
function recordPath(row: AuditLogDto) {
const parts = row.path.replace(/^\/api\/?/, '').split('?')[0]?.split('/').filter(Boolean) ?? [];
const settings: Record<string, string> = {
tahesab: '/settings/tahesab',
woocommerce: '/settings/woocommerce',
montify: '/settings/montify',
voip: '/settings/voip',
};
if (parts[0] === 'integrations' && settings[parts[1] ?? '']) return settings[parts[1] ?? ''];
if (parts[0] === 'automations') return '/settings/automations';
if (parts[0] === 'message-automations') return '/settings/message-automations';
if (parts[0] === 'custom-fields') return '/settings/custom-fields';
if (parts[0] === 'faq') return '/settings/faq';
if (parts[0] === 'pipelines') return '/settings/pipelines';
if (parts[0] === 'users' || parts[0] === 'teams' || parts[0] === 'org-roles') {
return '/settings/team';
}
if (parts[0] === 'auth') return '/profile';
const entityId = looksLikeId(parts[1]) ? parts[1] : looksLikeId(row.entityId) ? row.entityId : null;
if (!entityId || !row.entityType) return null;
const routes: Record<string, string> = {
people: `/contacts/people/${entityId}`,
companies: `/contacts/companies/${entityId}`,
leads: `/leads/${entityId}`,
deals: `/deals/${entityId}`,
activities: `/activities/${entityId}`,
tickets: `/tickets/${entityId}`,
quotes: `/quotes/${entityId}`,
invoices: `/invoices/${entityId}`,
products: `/settings/products/${entityId}/edit`,
'project-cards': `/project-cards/${entityId}`,
routines: `/routines/manage/${entityId}/edit`,
'lead-forms': `/settings/lead-forms/${entityId}`,
};
return routes[row.entityType] ?? null;
}
function openRecord(row: AuditLogDto) {
const path = recordPath(row);
if (path) void router.push(path);
}
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ');
if (typeof msg === 'string' && msg) return msg;
}
return fallback;
}
async function loadActors() {
try {
const { data: rows } = await api.get<Array<{ id: string; fullName: string; isActive?: boolean }>>(
'/users',
);
actors.value = rows
.filter((row) => row.fullName && row.isActive !== false)
.sort((a, b) => a.fullName.localeCompare(b.fullName, 'fa'));
} catch {
actors.value = [];
}
}
async function load(resetPage = false) {
if (resetPage) page.value = 1;
loading.value = true;
error.value = '';
const current = tab.value;
const archive = current === 'deletes' && isOwner.value;
try {
if (current === 'attendance') {
const { data: res } = await api.get<{
items: NonNullable<typeof attendance.value>['items'];
total: number;
}>('/audit-logs/attendance', {
params: {
q: q.value.trim() || undefined,
actorUserId: actorId.value || undefined,
page: page.value,
limit: pageSize.value,
},
});
if (tab.value !== current) return;
attendance.value = res;
data.value = null;
deletions.value = null;
} else if (archive) {
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', {
params: {
q: q.value.trim() || undefined,
deletedById: actorId.value || undefined,
page: page.value,
limit: pageSize.value,
},
});
if (tab.value !== current) return;
deletions.value = res;
data.value = null;
retention.value = res.retention;
} else {
const action = TABS.find((item) => item.key === current)?.action;
const { data: res } = await api.get<AuditLogListResponse>('/audit-logs', {
params: {
q: q.value.trim() || undefined,
action: action || undefined,
actorUserId: actorId.value || undefined,
page: page.value,
limit: pageSize.value,
},
});
if (tab.value !== current) return;
data.value = res;
deletions.value = null;
attendance.value = null;
}
} catch (e) {
if (tab.value !== current) return;
error.value = apiError(e, archive ? 'بارگذاری حذف‌ها ناموفق بود.' : 'بارگذاری لاگ ناموفق بود.');
} finally {
if (tab.value === current) loading.value = false;
}
}
function attendanceLabel(kind: string, afterHours: boolean) {
if (kind === 'login') return afterHours ? 'ورود بعد از ساعت خروج' : 'ورود';
if (kind === 'forced_logout') return 'خروج در ساعت تعیین‌شده';
return 'خروج';
}
function selectTab(next: LogTab) {
if (tab.value === next) return;
tab.value = next;
okMsg.value = '';
data.value = null;
deletions.value = null;
attendance.value = null;
void load(true);
}
function onPage(next: number) {
page.value = next;
void load();
}
function onPageSize(size: number) {
pageSize.value = size;
if (page.value === 1) void load();
}
async function saveRetention() {
savingRetention.value = true;
error.value = '';
okMsg.value = '';
try {
await api.patch('/deletions/settings', { retention: retention.value });
okMsg.value = 'مدت نگهداری ذخیره شد.';
} catch (e) {
error.value = apiError(e, 'ذخیره مدت نگهداری ناموفق بود.');
} finally {
savingRetention.value = false;
}
}
async function restore(item: DeletionArchiveDto) {
busyId.value = item.id;
error.value = '';
okMsg.value = '';
try {
await api.post(`/deletions/${item.id}/restore`);
okMsg.value = 'مورد برگردانده شد.';
await load();
} catch (e) {
error.value = apiError(e, 'بازگردانی ناموفق بود.');
} finally {
busyId.value = '';
}
}
async function confirmPurge() {
const id = purgeId.value;
if (!id) return;
busyId.value = id;
error.value = '';
okMsg.value = '';
try {
await api.delete(`/deletions/${id}`);
purgeId.value = null;
okMsg.value = 'این مورد برای همیشه پاک شد.';
await load();
} catch (e) {
error.value = apiError(e, 'حذف کامل ناموفق بود.');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void loadActors();
void load();
});
</script>
<template>
<div class="audit-page">
<UiPageHeader
title="لاگ فعالیت کاربران"
description="ثبت تغییرات ایجاد، ویرایش و حذف در سامانه"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load()">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<nav class="tabs" aria-label="نوع رویداد">
<button
v-for="item in visibleTabs"
:key="item.key"
type="button"
:class="{ on: tab === item.key }"
@click="selectTab(item.key)"
>
{{ item.label }}
</button>
</nav>
<UiCard v-if="showingArchive">
<div class="retention">
<label for="audit-retention">حذف خودکار از این فهرست</label>
<select id="audit-retention" v-model="retention">
<option v-for="option in RETENTION_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<UiButton size="sm" :loading="savingRetention" @click="saveRetention">ذخیره</UiButton>
</div>
<p class="hint">
تا پایان این مدت می‌توان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین می‌رود.
</p>
</UiCard>
<UiCard class="filters">
<form class="filters-grid" @submit.prevent="load(true)">
<UiField label="جستجو">
<UiInput v-model="q" placeholder="نام، عنوان یا شرح تغییر…" />
</UiField>
<UiField label="انجام‌دهنده">
<select v-model="actorId" class="actor" @change="load(true)">
<option value="">همه</option>
<option v-for="user in actors" :key="user.id" :value="user.id">{{ user.fullName }}</option>
</select>
</UiField>
<div class="filters-actions">
<UiButton type="submit" :disabled="loading">اعمال فیلتر</UiButton>
</div>
</form>
</UiCard>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<UiCard v-if="loading && !data && !deletions" title="در حال بارگذاری…">
<p class="muted">لطفاً صبر کنید.</p>
</UiCard>
<UiEmptyState
v-else-if="showingArchive && !loading && deletions && !deletions.items.length"
title="حذفی در این فهرست نیست"
description="حذف‌ها با نام انجام‌دهنده اینجا می‌مانند و تا پایان مدت نگهداری قابل بازگشت‌اند."
/>
<UiEmptyState
v-else-if="showingAttendance && !loading && attendance && !attendance.items.length"
title="ورود و خروجی ثبت نشده"
description="با ورود یا خروج کارمندان، اینجا دیده می‌شود."
/>
<UiEmptyState
v-else-if="!showingArchive && !showingAttendance && !loading && data && !data.items.length"
title="رویدادی در این بخش نیست"
description="با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده می‌شود."
/>
<UiCard v-else-if="showingArchive && deletions?.items.length" title="حذف‌ها">
<div class="table-wrap">
<table data-no-rownum>
<thead>
<tr>
<th class="when-col">زمان</th>
<th>انجام‌دهنده</th>
<th>نوع</th>
<th>عنوان</th>
<th>شرح</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="item in deletions.items" :key="item.id">
<td class="when" data-label="زمان">
<span class="clock">{{ formatClockFa(new Date(item.deletedAt)) }}</span>
<span>{{ formatJalaliNumeric(new Date(item.deletedAt)) }}</span>
</td>
<td data-label="انجام‌دهنده">{{ item.deletedByName || 'سیستم' }}</td>
<td data-label="نوع">{{ item.modelLabel }}</td>
<td data-label="عنوان">
<span class="clip" :title="item.label">{{ item.label }}</span>
</td>
<td data-label="شرح">
<span class="clip" :title="deletionStory(item)">{{ deletionStory(item) }}</span>
</td>
<td class="ops" data-label="عملیات">
<UiButton size="sm" variant="secondary" :disabled="busyId === item.id" @click="restore(item)">
بازگردانی
</UiButton>
<UiButton size="sm" variant="danger" :disabled="busyId === item.id" @click="purgeId = item.id">
حذف کامل
</UiButton>
</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
:page="page"
:total-pages="totalPages"
:total-items="deletions.total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="onPage"
@update:page-size="onPageSize"
/>
</UiCard>
<UiCard v-else-if="showingAttendance && attendance?.items.length" title="ورود و خروج">
<div class="table-wrap">
<table data-no-rownum>
<thead>
<tr>
<th class="when-col">زمان</th>
<th>کاربر</th>
<th>رویداد</th>
</tr>
</thead>
<tbody>
<tr v-for="item in attendance.items" :key="item.id">
<td class="when" data-label="زمان">
<span class="clock">{{ formatClockFa(new Date(item.at)) }}</span>
<span>{{ formatJalaliNumeric(new Date(item.at)) }}</span>
</td>
<td data-label="کاربر">{{ item.fullName }}</td>
<td data-label="رویداد">{{ attendanceLabel(item.kind, item.afterHours) }}</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
:page="page"
:total-pages="totalPages"
:total-items="attendance.total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="onPage"
@update:page-size="onPageSize"
/>
</UiCard>
<UiCard v-else-if="data?.items.length" title="رویدادها">
<div class="tc-row">
<TableColumnsMenu table-key="audit-logs" align="end" />
</div>
<div class="table-wrap">
<table
v-table-cols="'audit-logs'"
data-no-rownum
:style="{ '--row-start': (page - 1) * pageSize }"
>
<thead>
<tr>
<th class="when-col">زمان</th>
<th>کاربر</th>
<th>عملیات</th>
<th>موضوع</th>
<th>شرح</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
<tr
v-for="row in data.items"
:key="row.id"
:class="{ link: !!recordPath(row) }"
@click="openRecord(row)"
>
<td class="when" data-label="زمان">
<span class="clock">{{ formatClockFa(new Date(row.createdAt)) }}</span>
<span>{{ formatJalaliNumeric(new Date(row.createdAt)) }}</span>
</td>
<td data-label="کاربر">{{ row.actor?.fullName ?? '—' }}</td>
<td data-label="عملیات">
<span class="pill">{{ row.action }}</span>
</td>
<td data-label="موضوع">
<span class="clip" :title="rowName(row) || entityLabel(row.entityType)">
{{ rowName(row) || entityLabel(row.entityType) }}
</span>
</td>
<td data-label="شرح">
<span class="clip" :title="rowStory(row)">{{ rowStory(row) }}</span>
</td>
<td class="ops">
<button
v-if="recordPath(row)"
type="button"
class="view"
@click.stop="openRecord(row)"
>
مشاهده
</button>
</td>
</tr>
</tbody>
</table>
</div>
<UiPagination
:page="page"
:total-pages="totalPages"
:total-items="data.total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="onPage"
@update:page-size="onPageSize"
/>
</UiCard>
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
<p v-if="purgeItem" class="confirm">
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
</p>
<template #footer>
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
</template>
</UiModal>
</div>
</template>
<style scoped>
.audit-page {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
min-width: 0;
}
.tabs {
display: flex;
width: 100%;
min-width: 0;
overflow-x: auto;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
flex: 1 1 0;
min-width: 4.5rem;
border: 0;
background: none;
padding: 0.55rem 0.35rem 0.7rem;
color: #64748b;
font: inherit;
font-weight: 700;
cursor: pointer;
box-shadow: inset 0 -2px 0 transparent;
}
.tabs button.on {
color: #0f766e;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.retention {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.retention label {
font-weight: 700;
}
.retention select,
.actor {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #0f172a;
font: inherit;
padding: 0.45rem 0.7rem;
min-width: 0;
max-width: 100%;
}
.retention select {
width: auto;
min-width: 8.5rem;
}
.actor {
width: 100%;
}
.hint,
.muted {
margin: 0.55rem 0 0;
color: #64748b;
font-size: 0.85rem;
}
.filters-grid {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem;
}
.filters-grid :deep(.ui-field) {
flex: 1 1 14rem;
min-width: 0;
}
.filters-grid :deep(.ui-field:nth-child(2)) {
flex: 0 1 16rem;
max-width: 16rem;
}
.filters-actions {
display: flex;
align-items: end;
}
.filters-actions :deep(button) {
min-height: 2.4rem;
}
.ok {
margin: 0;
color: #0f766e;
font-weight: 700;
}
.error {
color: #b91c1c;
margin: 0;
font-weight: 600;
}
.table-wrap {
overflow: auto;
max-width: 100%;
}
table {
width: 100%;
min-width: 46rem;
border-collapse: collapse;
font-size: 0.9rem;
table-layout: fixed;
}
th,
td {
text-align: right;
padding: 0.65rem 0.55rem;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
}
.when-col,
.when {
width: 6.2rem;
}
.when span {
display: block;
line-height: 1.35;
}
.clock {
font-weight: 800;
}
.actions,
td.ops {
width: 11rem;
text-align: left;
}
.clip {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
th {
color: #64748b;
font-weight: 700;
font-size: 0.8rem;
white-space: nowrap;
}
.nowrap {
white-space: nowrap;
}
.detail {
color: #334155;
line-height: 1.7;
}
.pill {
display: inline-flex;
padding: 0.15rem 0.55rem;
border-radius: var(--radius);
background: #f0fdfa;
color: #0f766e;
font-weight: 700;
font-size: 0.8rem;
}
.ops {
display: flex;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 0.4rem;
white-space: nowrap;
}
tr.link {
cursor: pointer;
}
tr.link:hover td {
background: #f8fffd;
}
.view {
border: 0;
background: none;
padding: 0;
color: #0f766e;
font: inherit;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
}
.confirm {
margin: 0;
line-height: 1.8;
}
@media (max-width: 720px) {
.filters-grid :deep(.ui-field),
.filters-grid :deep(.ui-field:nth-child(2)) {
flex: 1 1 100%;
max-width: none;
}
.filters-actions,
.filters-actions :deep(button) {
width: 100%;
}
.table-wrap {
overflow: visible;
}
table {
min-width: 0;
}
table thead {
display: none;
}
table,
tbody,
tr,
td {
display: block;
width: auto;
}
tbody tr {
border: 1px solid #e7eef2;
border-radius: 14px;
margin-bottom: 0.65rem;
padding: 0.35rem 0.7rem 0.55rem;
background: #fff;
}
td {
display: flex;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 0;
padding: 0.35rem 0;
text-align: start;
}
td::before {
content: attr(data-label);
flex: 0 0 auto;
color: #64748b;
font-size: 0.75rem;
font-weight: 700;
}
td.ops {
justify-content: flex-end;
}
.view {
display: none;
}
tr.link {
cursor: pointer;
}
.when {
flex-direction: column;
align-items: flex-end;
width: auto;
}
.clip {
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
</style>