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>
This commit is contained in:
@@ -13,21 +13,45 @@ import type {
|
||||
DeletionArchiveListResponse,
|
||||
DeletionRetention,
|
||||
} from '@mento/shared';
|
||||
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatClockFa, formatJalaliNumeric } from '@/lib/jalali';
|
||||
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: 'ورود و خروج' },
|
||||
@@ -61,6 +85,10 @@ const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
||||
{ 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');
|
||||
@@ -84,16 +112,7 @@ 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 attendance = ref<{ items: AttendanceRow[]; total: number } | null>(null);
|
||||
const retention = ref<DeletionRetention>('never');
|
||||
const purgeId = ref<string | null>(null);
|
||||
|
||||
@@ -118,6 +137,72 @@ 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] ?? 'مورد';
|
||||
@@ -283,9 +368,10 @@ function attendanceLabel(kind: string, afterHours: boolean) {
|
||||
return 'خروج';
|
||||
}
|
||||
|
||||
function selectTab(next: LogTab) {
|
||||
if (tab.value === next) return;
|
||||
tab.value = next;
|
||||
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;
|
||||
@@ -293,14 +379,16 @@ function selectTab(next: LogTab) {
|
||||
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();
|
||||
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() {
|
||||
@@ -350,6 +438,14 @@ async function confirmPurge() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatWhen(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return {
|
||||
clock: formatClockFa(d),
|
||||
date: formatJalaliNumeric(d),
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadActors();
|
||||
void load();
|
||||
@@ -357,503 +453,293 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audit-page">
|
||||
<UiPageHeader
|
||||
title="لاگ فعالیت کاربران"
|
||||
description="ثبت تغییرات ایجاد، ویرایش و حذف در سامانه"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load()">
|
||||
<UiIcon name="refresh" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<a-tabs :active-key="tab" @change="selectTab">
|
||||
<a-tab-pane v-for="item in visibleTabs" :key="item.key" :tab="item.label" />
|
||||
</a-tabs>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
تا پایان این مدت میتوان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین میرود.
|
||||
</p>
|
||||
</UiCard>
|
||||
</a-typography-text>
|
||||
</a-card>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<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 = ''" />
|
||||
|
||||
<UiCard v-if="loading && !data && !deletions" title="در حال بارگذاری…">
|
||||
<p class="muted">لطفاً صبر کنید.</p>
|
||||
</UiCard>
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="initialLoading" :bordered="false">
|
||||
<a-typography-text type="secondary">لطفاً صبر کنید.</a-typography-text>
|
||||
</a-card>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="showingArchive && !loading && deletions && !deletions.items.length"
|
||||
title="حذفی در این فهرست نیست"
|
||||
description="حذفها با نام انجامدهنده اینجا میمانند و تا پایان مدت نگهداری قابل بازگشتاند."
|
||||
/>
|
||||
<a-card v-else-if="showArchiveEmpty" :bordered="false">
|
||||
<a-empty
|
||||
description="حذفی در این فهرست نیست. حذفها با نام انجامدهنده اینجا میمانند و تا پایان مدت نگهداری قابل بازگشتاند."
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="showingAttendance && !loading && attendance && !attendance.items.length"
|
||||
title="ورود و خروجی ثبت نشده"
|
||||
description="با ورود یا خروج کارمندان، اینجا دیده میشود."
|
||||
/>
|
||||
<a-card v-else-if="showAttendanceEmpty" :bordered="false">
|
||||
<a-empty description="ورود و خروجی ثبت نشده. با ورود یا خروج کارمندان، اینجا دیده میشود." />
|
||||
</a-card>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!showingArchive && !showingAttendance && !loading && data && !data.items.length"
|
||||
title="رویدادی در این بخش نیست"
|
||||
description="با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده میشود."
|
||||
/>
|
||||
<a-card v-else-if="showAuditEmpty" :bordered="false">
|
||||
<a-empty description="رویدادی در این بخش نیست. با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده میشود." />
|
||||
</a-card>
|
||||
|
||||
<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 }"
|
||||
<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 }"
|
||||
>
|
||||
<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)"
|
||||
<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)"
|
||||
>
|
||||
مشاهده
|
||||
</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>
|
||||
بازگردانی
|
||||
</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>
|
||||
|
||||
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
|
||||
<p v-if="purgeItem" class="confirm">
|
||||
<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 }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
|
||||
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</div>
|
||||
</a-typography-text>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user