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

775 lines
21 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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';
const TABS: Array<{ key: LogTab; label: string; action?: string }> = [
{ key: 'all', 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 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 retention = ref<DeletionRetention>('never');
const purgeId = ref<string | null>(null);
const showingArchive = computed(() => tab.value === 'deletes' && isOwner.value);
const listTotal = computed(() =>
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 (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;
}
} catch (e) {
if (tab.value !== current) return;
error.value = apiError(e, archive ? 'بارگذاری حذف‌ها ناموفق بود.' : 'بارگذاری لاگ ناموفق بود.');
} finally {
if (tab.value === current) loading.value = false;
}
}
function selectTab(next: LogTab) {
if (tab.value === next) return;
tab.value = next;
okMsg.value = '';
data.value = null;
deletions.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 TABS"
: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="!showingArchive && !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="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>