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:
2026-11-04 10:05:28 +03:30
co-authored by Cursor
parent b11affbdfd
commit cb3a488b21
46 changed files with 6024 additions and 7679 deletions
+403 -517
View File
@@ -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>