2026-09-26 14:59:48 +03:30
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, ref } from 'vue';
|
2026-10-15 14:18:56 +03:30
|
|
|
|
import { useRouter } from 'vue-router';
|
2026-10-05 11:56:49 +03:30
|
|
|
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
2026-09-26 14:59:48 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
2026-10-15 14:18:56 +03:30
|
|
|
|
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
|
|
import type {
|
|
|
|
|
|
AuditLogDto,
|
|
|
|
|
|
AuditLogListResponse,
|
|
|
|
|
|
DeletionArchiveDto,
|
|
|
|
|
|
DeletionArchiveListResponse,
|
|
|
|
|
|
DeletionRetention,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-11-04 10:01:11 +03:30
|
|
|
|
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
|
|
|
|
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
2026-09-26 14:59:48 +03:30
|
|
|
|
import {
|
2026-11-04 10:01:11 +03:30
|
|
|
|
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;
|
2026-10-15 14:18:56 +03:30
|
|
|
|
|
2026-10-19 13:34:55 +03:30
|
|
|
|
type LogTab = 'all' | 'creates' | 'edits' | 'deletes' | 'attendance';
|
2026-10-15 14:18:56 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
type AttendanceRow = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
fullName: string;
|
|
|
|
|
|
kind: 'login' | 'logout' | 'forced_logout';
|
|
|
|
|
|
at: string;
|
|
|
|
|
|
afterHours: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const TABS: Array<{ key: LogTab; label: string; action?: string }> = [
|
|
|
|
|
|
{ key: 'all', label: 'همه' },
|
2026-10-19 13:54:09 +03:30
|
|
|
|
{ key: 'attendance', label: 'ورود و خروج' },
|
2026-10-15 14:18:56 +03:30
|
|
|
|
{ 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: 'هرگز' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
const AUDIT_COL_LABELS = ['زمان', 'کاربر', 'عملیات', 'موضوع', 'شرح'] as const;
|
|
|
|
|
|
registerTableColumns('audit-logs', [...AUDIT_COL_LABELS]);
|
|
|
|
|
|
const tableCols = useTableColumns(() => 'audit-logs');
|
|
|
|
|
|
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const isOwner = computed(() => auth.user?.role === 'owner');
|
2026-10-21 13:45:16 +03:30
|
|
|
|
/** ورود و خروج فقط برای مالک و مدیر؛ بقیه با مجوز «لاگ فعالیت» فقط لاگها را میبینند */
|
|
|
|
|
|
const visibleTabs = computed(() =>
|
|
|
|
|
|
auth.user?.role === 'owner' || auth.user?.role === 'admin'
|
|
|
|
|
|
? TABS
|
|
|
|
|
|
: TABS.filter((item) => item.key !== 'attendance'),
|
|
|
|
|
|
);
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
|
|
|
|
|
const loading = ref(false);
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const savingRetention = ref(false);
|
|
|
|
|
|
const busyId = ref('');
|
2026-09-26 14:59:48 +03:30
|
|
|
|
const error = ref('');
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const okMsg = ref('');
|
2026-09-26 14:59:48 +03:30
|
|
|
|
const q = ref('');
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const actorId = ref('');
|
|
|
|
|
|
const actors = ref<Array<{ id: string; fullName: string }>>([]);
|
|
|
|
|
|
const tab = ref<LogTab>('all');
|
2026-09-26 14:59:48 +03:30
|
|
|
|
const page = ref(1);
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const pageSize = usePageSize('audit-logs');
|
2026-09-26 14:59:48 +03:30
|
|
|
|
const data = ref<AuditLogListResponse | null>(null);
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const deletions = ref<DeletionArchiveListResponse | null>(null);
|
2026-11-04 10:01:11 +03:30
|
|
|
|
const attendance = ref<{ items: AttendanceRow[]; total: number } | null>(null);
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const retention = ref<DeletionRetention>('never');
|
|
|
|
|
|
const purgeId = ref<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const showingArchive = computed(() => tab.value === 'deletes' && isOwner.value);
|
2026-10-19 13:34:55 +03:30
|
|
|
|
const showingAttendance = computed(() => tab.value === 'attendance');
|
2026-10-15 14:18:56 +03:30
|
|
|
|
|
|
|
|
|
|
const listTotal = computed(() =>
|
2026-10-19 13:34:55 +03:30
|
|
|
|
showingAttendance.value
|
|
|
|
|
|
? attendance.value?.total
|
|
|
|
|
|
: showingArchive.value
|
|
|
|
|
|
? deletions.value?.total
|
|
|
|
|
|
: data.value?.total,
|
2026-10-15 14:18:56 +03:30
|
|
|
|
);
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
|
|
|
|
|
const totalPages = computed(() => {
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const total = listTotal.value;
|
|
|
|
|
|
if (total == null) return 1;
|
|
|
|
|
|
return Math.max(1, Math.ceil(total / pageSize.value));
|
2026-09-26 14:59:48 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const purgeItem = computed(
|
|
|
|
|
|
() => deletions.value?.items.find((item) => item.id === purgeId.value) ?? null,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
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,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-15 14:18:56 +03:30
|
|
|
|
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 = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 14:59:48 +03:30
|
|
|
|
async function load(resetPage = false) {
|
|
|
|
|
|
if (resetPage) page.value = 1;
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
2026-10-15 14:18:56 +03:30
|
|
|
|
const current = tab.value;
|
|
|
|
|
|
const archive = current === 'deletes' && isOwner.value;
|
2026-09-26 14:59:48 +03:30
|
|
|
|
try {
|
2026-10-19 13:34:55 +03:30
|
|
|
|
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) {
|
2026-10-15 14:18:56 +03:30
|
|
|
|
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;
|
2026-09-26 14:59:48 +03:30
|
|
|
|
} else {
|
2026-10-15 14:18:56 +03:30
|
|
|
|
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;
|
2026-10-19 13:34:55 +03:30
|
|
|
|
attendance.value = null;
|
2026-09-26 14:59:48 +03:30
|
|
|
|
}
|
2026-10-15 14:18:56 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (tab.value !== current) return;
|
|
|
|
|
|
error.value = apiError(e, archive ? 'بارگذاری حذفها ناموفق بود.' : 'بارگذاری لاگ ناموفق بود.');
|
2026-09-26 14:59:48 +03:30
|
|
|
|
} finally {
|
2026-10-15 14:18:56 +03:30
|
|
|
|
if (tab.value === current) loading.value = false;
|
2026-09-26 14:59:48 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-19 13:34:55 +03:30
|
|
|
|
function attendanceLabel(kind: string, afterHours: boolean) {
|
|
|
|
|
|
if (kind === 'login') return afterHours ? 'ورود بعد از ساعت خروج' : 'ورود';
|
|
|
|
|
|
if (kind === 'forced_logout') return 'خروج در ساعت تعیینشده';
|
|
|
|
|
|
return 'خروج';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
function selectTab(next: string | number) {
|
|
|
|
|
|
const key = String(next) as LogTab;
|
|
|
|
|
|
if (tab.value === key) return;
|
|
|
|
|
|
tab.value = key;
|
2026-10-15 14:18:56 +03:30
|
|
|
|
okMsg.value = '';
|
|
|
|
|
|
data.value = null;
|
|
|
|
|
|
deletions.value = null;
|
2026-10-19 13:34:55 +03:30
|
|
|
|
attendance.value = null;
|
2026-10-15 14:18:56 +03:30
|
|
|
|
void load(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
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);
|
2026-10-15 14:18:56 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
function formatWhen(iso: string) {
|
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
|
return {
|
|
|
|
|
|
clock: formatClockFa(d),
|
|
|
|
|
|
date: formatJalaliNumeric(d),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-26 14:59:48 +03:30
|
|
|
|
onMounted(() => {
|
2026-10-15 14:18:56 +03:30
|
|
|
|
void loadActors();
|
2026-09-26 14:59:48 +03:30
|
|
|
|
void load();
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-tabs :active-key="tab" @change="selectTab">
|
|
|
|
|
|
<a-tab-pane v-for="item in visibleTabs" :key="item.key" :tab="item.label" />
|
|
|
|
|
|
</a-tabs>
|
2026-10-15 14:18:56 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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">
|
2026-10-15 14:18:56 +03:30
|
|
|
|
تا پایان این مدت میتوان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین میرود.
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
</a-card>
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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>
|
2026-10-15 14:18:56 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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 = ''" />
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-spin :spinning="loading">
|
|
|
|
|
|
<a-card v-if="initialLoading" :bordered="false">
|
|
|
|
|
|
<a-typography-text type="secondary">لطفاً صبر کنید.</a-typography-text>
|
|
|
|
|
|
</a-card>
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-card v-else-if="showArchiveEmpty" :bordered="false">
|
|
|
|
|
|
<a-empty
|
|
|
|
|
|
description="حذفی در این فهرست نیست. حذفها با نام انجامدهنده اینجا میمانند و تا پایان مدت نگهداری قابل بازگشتاند."
|
|
|
|
|
|
/>
|
|
|
|
|
|
</a-card>
|
2026-09-26 14:59:48 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-card v-else-if="showAttendanceEmpty" :bordered="false">
|
|
|
|
|
|
<a-empty description="ورود و خروجی ثبت نشده. با ورود یا خروج کارمندان، اینجا دیده میشود." />
|
|
|
|
|
|
</a-card>
|
2026-10-19 13:34:55 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<a-card v-else-if="showAuditEmpty" :bordered="false">
|
|
|
|
|
|
<a-empty description="رویدادی در این بخش نیست. با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده میشود." />
|
|
|
|
|
|
</a-card>
|
2026-10-15 14:18:56 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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 }"
|
2026-10-05 20:00:21 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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)"
|
2026-10-15 14:18:56 +03:30
|
|
|
|
>
|
2026-11-04 10:01:11 +03:30
|
|
|
|
بازگردانی
|
|
|
|
|
|
</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>
|
2026-10-15 14:18:56 +03:30
|
|
|
|
|
2026-11-04 10:01:11 +03:30
|
|
|
|
<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">
|
2026-10-15 14:18:56 +03:30
|
|
|
|
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
2026-11-04 10:01:11 +03:30
|
|
|
|
</a-typography-text>
|
|
|
|
|
|
</a-modal>
|
|
|
|
|
|
</a-flex>
|
2026-09-26 14:59:48 +03:30
|
|
|
|
</template>
|