Files
CRM_Montzar/apps/web/src/views/AuditLogsView.vue
T
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:28 +03:30

746 lines
25 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 { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
Modal as AModal,
Pagination as APagination,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Table as ATable,
Tabs as ATabs,
TabPane as ATabPane,
Tag as ATag,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { ReloadOutlined } from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
type LogTab = 'all' | 'creates' | 'edits' | 'deletes' | 'attendance';
type AttendanceRow = {
id: string;
fullName: string;
kind: 'login' | 'logout' | 'forced_logout';
at: string;
afterHours: boolean;
};
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 AUDIT_COL_LABELS = ['زمان', 'کاربر', 'عملیات', 'موضوع', 'شرح'] as const;
registerTableColumns('audit-logs', [...AUDIT_COL_LABELS]);
const tableCols = useTableColumns(() => 'audit-logs');
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: AttendanceRow[]; 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,
);
const actorOptions = computed(() => [
{ value: '', label: 'همه' },
...actors.value.map((user) => ({ value: user.id, label: user.fullName })),
]);
const auditColumns = computed<TableColumnsType<AuditLogDto>>(() => {
const all: TableColumnsType<AuditLogDto> = [
{ title: 'زمان', key: 'زمان', width: 120 },
{ title: 'کاربر', key: 'کاربر' },
{ title: 'عملیات', key: 'عملیات', width: 110 },
{ title: 'موضوع', key: 'موضوع' },
{ title: 'شرح', key: 'شرح' },
{ title: '', key: 'مشاهده', width: 100 },
];
return all.filter((col) => {
const key = String(col.key ?? '');
if (key === 'مشاهده') return true;
if (!AUDIT_COL_LABELS.includes(key as (typeof AUDIT_COL_LABELS)[number])) return true;
return tableCols.isVisible(key);
});
});
const deletionColumns: TableColumnsType<DeletionArchiveDto> = [
{ title: 'زمان', key: 'زمان', width: 120 },
{ title: 'انجام‌دهنده', key: 'انجام‌دهنده' },
{ title: 'نوع', key: 'نوع' },
{ title: 'عنوان', key: 'عنوان' },
{ title: 'شرح', key: 'شرح' },
{ title: '', key: 'عملیات', width: 220 },
];
const attendanceColumns: TableColumnsType<AttendanceRow> = [
{ title: 'زمان', key: 'زمان', width: 120 },
{ title: 'کاربر', key: 'کاربر' },
{ title: 'رویداد', key: 'رویداد' },
];
const initialLoading = computed(
() => loading.value && !data.value && !deletions.value && !attendance.value,
);
const showAuditEmpty = computed(
() =>
!showingArchive.value &&
!showingAttendance.value &&
!loading.value &&
data.value &&
!data.value.items.length,
);
const showArchiveEmpty = computed(
() =>
showingArchive.value &&
!loading.value &&
deletions.value &&
!deletions.value.items.length,
);
const showAttendanceEmpty = computed(
() =>
showingAttendance.value &&
!loading.value &&
attendance.value &&
!attendance.value.items.length,
);
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: string | number) {
const key = String(next) as LogTab;
if (tab.value === key) return;
tab.value = key;
okMsg.value = '';
data.value = null;
deletions.value = null;
attendance.value = null;
void load(true);
}
function onPaginationChange(nextPage: number, nextSize: number) {
const sizeChanged = nextSize !== pageSize.value;
page.value = nextPage;
if (sizeChanged) {
pageSize.value = nextSize;
if (page.value !== 1) {
page.value = 1;
}
}
void load(sizeChanged);
}
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 = '';
}
}
function formatWhen(iso: string) {
const d = new Date(iso);
return {
clock: formatClockFa(d),
date: formatJalaliNumeric(d),
};
}
onMounted(() => {
void loadActors();
void load();
});
</script>
<template>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
<a-typography-title :level="3" :style="{ margin: 0 }">لاگ فعالیت کاربران</a-typography-title>
<a-button :disabled="loading" @click="load()">
<template #icon><ReloadOutlined /></template>
بروزرسانی
</a-button>
</a-flex>
<a-tabs :active-key="tab" @change="selectTab">
<a-tab-pane v-for="item in visibleTabs" :key="item.key" :tab="item.label" />
</a-tabs>
<a-card v-if="showingArchive" :bordered="false">
<a-form layout="vertical">
<a-row :gutter="[16, 0]" align="bottom">
<a-col :xs="24" :md="12">
<a-form-item label="حذف خودکار از این فهرست">
<a-select
v-model:value="retention"
:options="RETENTION_OPTIONS"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label=" ">
<a-button type="primary" :loading="savingRetention" @click="saveRetention">
ذخیره
</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-typography-text type="secondary">
تا پایان این مدت می‌توان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین می‌رود.
</a-typography-text>
</a-card>
<a-card :bordered="false">
<a-form layout="vertical" @finish="load(true)">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="جستجو">
<a-input v-model:value="q" placeholder="نام، عنوان یا شرح تغییر…" allow-clear />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="انجام‌دهنده">
<a-select
v-model:value="actorId"
:options="actorOptions"
style="width: 100%"
show-search
option-filter-prop="label"
@change="load(true)"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="4">
<a-form-item label=" ">
<a-button type="primary" html-type="submit" :disabled="loading" block>
اعمال فیلتر
</a-button>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-card>
<a-alert v-if="okMsg" type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-spin :spinning="loading">
<a-card v-if="initialLoading" :bordered="false">
<a-typography-text type="secondary">لطفاً صبر کنید.</a-typography-text>
</a-card>
<a-card v-else-if="showArchiveEmpty" :bordered="false">
<a-empty
description="حذفی در این فهرست نیست. حذف‌ها با نام انجام‌دهنده اینجا می‌مانند و تا پایان مدت نگهداری قابل بازگشت‌اند."
/>
</a-card>
<a-card v-else-if="showAttendanceEmpty" :bordered="false">
<a-empty description="ورود و خروجی ثبت نشده. با ورود یا خروج کارمندان، اینجا دیده می‌شود." />
</a-card>
<a-card v-else-if="showAuditEmpty" :bordered="false">
<a-empty description="رویدادی در این بخش نیست. با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده می‌شود." />
</a-card>
<a-card
v-else-if="showingArchive && deletions?.items.length"
title="حذف‌ها"
:bordered="false"
>
<a-table
:columns="deletionColumns"
:data-source="deletions.items"
:pagination="false"
row-key="id"
:scroll="{ x: 720 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'زمان'">
<a-flex vertical>
<a-typography-text strong>{{ formatWhen((record as DeletionArchiveDto).deletedAt).clock }}</a-typography-text>
<a-typography-text type="secondary">
{{ formatWhen((record as DeletionArchiveDto).deletedAt).date }}
</a-typography-text>
</a-flex>
</template>
<template v-else-if="column.key === 'انجام‌دهنده'">
{{ (record as DeletionArchiveDto).deletedByName || 'سیستم' }}
</template>
<template v-else-if="column.key === 'نوع'">
{{ (record as DeletionArchiveDto).modelLabel }}
</template>
<template v-else-if="column.key === 'عنوان'">
{{ (record as DeletionArchiveDto).label }}
</template>
<template v-else-if="column.key === 'شرح'">
{{ deletionStory(record as DeletionArchiveDto) }}
</template>
<template v-else-if="column.key === 'عملیات'">
<a-space wrap>
<a-button
:disabled="busyId === (record as DeletionArchiveDto).id"
@click="restore(record as DeletionArchiveDto)"
>
بازگردانی
</a-button>
<a-button
danger
:disabled="busyId === (record as DeletionArchiveDto).id"
@click="purgeId = (record as DeletionArchiveDto).id"
>
حذف کامل
</a-button>
</a-space>
</template>
</template>
</a-table>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginTop: '12px' }">
<a-pagination
:current="page"
:total="deletions.total"
:page-size="pageSize"
:show-size-changer="true"
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
@change="onPaginationChange"
/>
<a-typography-text type="secondary">
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
</a-typography-text>
</a-flex>
</a-card>
<a-card
v-else-if="showingAttendance && attendance?.items.length"
title="ورود و خروج"
:bordered="false"
>
<a-table
:columns="attendanceColumns"
:data-source="attendance.items"
:pagination="false"
row-key="id"
:scroll="{ x: 480 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'زمان'">
<a-flex vertical>
<a-typography-text strong>
{{ formatWhen((record as AttendanceRow).at).clock }}
</a-typography-text>
<a-typography-text type="secondary">
{{ formatWhen((record as AttendanceRow).at).date }}
</a-typography-text>
</a-flex>
</template>
<template v-else-if="column.key === 'کاربر'">
{{ (record as AttendanceRow).fullName }}
</template>
<template v-else-if="column.key === 'رویداد'">
{{
attendanceLabel(
(record as AttendanceRow).kind,
(record as AttendanceRow).afterHours,
)
}}
</template>
</template>
</a-table>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginTop: '12px' }">
<a-pagination
:current="page"
:total="attendance.total"
:page-size="pageSize"
:show-size-changer="true"
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
@change="onPaginationChange"
/>
<a-typography-text type="secondary">
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
</a-typography-text>
</a-flex>
</a-card>
<a-card v-else-if="data?.items.length" title="رویدادها" :bordered="false">
<a-flex justify="flex-end" :style="{ marginBottom: '12px' }">
<TableColumnsMenu table-key="audit-logs" align="end" />
</a-flex>
<a-table
:columns="auditColumns"
:data-source="data.items"
:pagination="false"
row-key="id"
:scroll="{ x: 720 }"
:custom-row="(record: AuditLogDto) => ({
style: recordPath(record) ? { cursor: 'pointer' } : undefined,
onClick: () => openRecord(record),
})"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'زمان'">
<a-flex vertical>
<a-typography-text strong>{{ formatWhen((record as AuditLogDto).createdAt).clock }}</a-typography-text>
<a-typography-text type="secondary">
{{ formatWhen((record as AuditLogDto).createdAt).date }}
</a-typography-text>
</a-flex>
</template>
<template v-else-if="column.key === 'کاربر'">
{{ (record as AuditLogDto).actor?.fullName ?? '—' }}
</template>
<template v-else-if="column.key === 'عملیات'">
<a-tag color="processing" :bordered="false">{{ (record as AuditLogDto).action }}</a-tag>
</template>
<template v-else-if="column.key === 'موضوع'">
{{ rowName(record as AuditLogDto) || entityLabel((record as AuditLogDto).entityType) }}
</template>
<template v-else-if="column.key === 'شرح'">
{{ rowStory(record as AuditLogDto) }}
</template>
<template v-else-if="column.key === 'مشاهده'">
<a-button
v-if="recordPath(record as AuditLogDto)"
type="link"
@click.stop="openRecord(record as AuditLogDto)"
>
مشاهده
</a-button>
</template>
</template>
</a-table>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginTop: '12px' }">
<a-pagination
:current="page"
:total="data.total"
:page-size="pageSize"
:show-size-changer="true"
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
@change="onPaginationChange"
/>
<a-typography-text type="secondary">
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
</a-typography-text>
</a-flex>
</a-card>
</a-spin>
<a-modal
:open="!!purgeItem"
title="حذف کامل"
ok-text="حذف کامل"
cancel-text="انصراف"
ok-type="danger"
:confirm-loading="!!busyId"
destroy-on-close
@ok="confirmPurge"
@cancel="purgeId = null"
>
<a-typography-text v-if="purgeItem">
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
</a-typography-text>
</a-modal>
</a-flex>
</template>