feat: log staff sign-in and sign-out and force logout at a set time
This commit is contained in:
@@ -26,13 +26,14 @@ import {
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatClockFa, formatJalaliNumeric } from '@/lib/jalali';
|
||||
|
||||
type LogTab = 'all' | 'creates' | 'edits' | 'deletes';
|
||||
type LogTab = 'all' | 'creates' | 'edits' | 'deletes' | 'attendance';
|
||||
|
||||
const TABS: Array<{ key: LogTab; label: string; action?: string }> = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'creates', label: 'ایجاد', action: 'ایجاد' },
|
||||
{ key: 'edits', label: 'ویرایش', action: 'ویرایش' },
|
||||
{ key: 'deletes', label: 'حذف', action: 'حذف' },
|
||||
{ key: 'attendance', label: 'ورود و خروج' },
|
||||
];
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
@@ -77,13 +78,28 @@ 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 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(() =>
|
||||
showingArchive.value ? deletions.value?.total : data.value?.total,
|
||||
showingAttendance.value
|
||||
? attendance.value?.total
|
||||
: showingArchive.value
|
||||
? deletions.value?.total
|
||||
: data.value?.total,
|
||||
);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
@@ -202,7 +218,23 @@ async function load(resetPage = false) {
|
||||
const current = tab.value;
|
||||
const archive = current === 'deletes' && isOwner.value;
|
||||
try {
|
||||
if (archive) {
|
||||
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,
|
||||
@@ -229,6 +261,7 @@ async function load(resetPage = false) {
|
||||
if (tab.value !== current) return;
|
||||
data.value = res;
|
||||
deletions.value = null;
|
||||
attendance.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
if (tab.value !== current) return;
|
||||
@@ -238,12 +271,19 @@ async function load(resetPage = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function attendanceLabel(kind: string, afterHours: boolean) {
|
||||
if (kind === 'login') return afterHours ? 'ورود بعد از ساعت خروج' : 'ورود';
|
||||
if (kind === 'forced_logout') return 'خروج در ساعت تعیینشده';
|
||||
return 'خروج';
|
||||
}
|
||||
|
||||
function selectTab(next: LogTab) {
|
||||
if (tab.value === next) return;
|
||||
tab.value = next;
|
||||
okMsg.value = '';
|
||||
data.value = null;
|
||||
deletions.value = null;
|
||||
attendance.value = null;
|
||||
void load(true);
|
||||
}
|
||||
|
||||
@@ -382,7 +422,13 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!showingArchive && !loading && data && !data.items.length"
|
||||
v-else-if="showingAttendance && !loading && attendance && !attendance.items.length"
|
||||
title="ورود و خروجی ثبت نشده"
|
||||
description="با ورود یا خروج کارمندان، اینجا دیده میشود."
|
||||
/>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!showingArchive && !showingAttendance && !loading && data && !data.items.length"
|
||||
title="رویدادی در این بخش نیست"
|
||||
description="با انجام کار در سامانه، ایجاد و ویرایش اینجا دیده میشود."
|
||||
/>
|
||||
@@ -437,6 +483,39 @@ onMounted(() => {
|
||||
/>
|
||||
</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" />
|
||||
|
||||
@@ -28,7 +28,11 @@ const remember = ref(true);
|
||||
const otpCode = ref('');
|
||||
const challenge = ref<LoginChallengeResponse | null>(null);
|
||||
const pendingChoice = ref<LoginOrganizationChoiceResponse | null>(null);
|
||||
const error = ref('');
|
||||
const error = ref(
|
||||
route.query.reason === 'hours'
|
||||
? 'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.'
|
||||
: '',
|
||||
);
|
||||
const loading = ref(false);
|
||||
const otpSecondsLeft = ref(0);
|
||||
let otpTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@@ -492,6 +492,23 @@ async function toggleStatus(user: OrgUserDto) {
|
||||
}
|
||||
}
|
||||
|
||||
async function changeLogoutTime(user: OrgUserDto, raw: string) {
|
||||
if (!canInvite.value || user.role === 'owner') return;
|
||||
const next = raw.trim();
|
||||
if (next === (user.logoutTime ?? '')) return;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null });
|
||||
ok.value = next
|
||||
? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.`
|
||||
: `محدودیت خروج «${user.fullName}» برداشته شد.`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره ساعت خروج ناموفق بود');
|
||||
}
|
||||
}
|
||||
|
||||
async function changeCommission(user: OrgUserDto, raw: string) {
|
||||
if (!canInvite.value) return;
|
||||
const next = Number(raw);
|
||||
@@ -980,6 +997,7 @@ onUnmounted(() => {
|
||||
<span :class="{ flip: !sortLoginDesc }">▴</span>
|
||||
</button>
|
||||
</th>
|
||||
<th>ساعت خروج</th>
|
||||
<th class="ops" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1019,6 +1037,19 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</td>
|
||||
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
|
||||
<td @click.stop>
|
||||
<span v-if="user.role === 'owner'" class="muted">—</span>
|
||||
<input
|
||||
v-else
|
||||
class="logout-time"
|
||||
type="time"
|
||||
dir="ltr"
|
||||
:value="user.logoutTime || ''"
|
||||
:disabled="!canInvite"
|
||||
:aria-label="`ساعت خروج ${user.fullName}`"
|
||||
@change="changeLogoutTime(user, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</td>
|
||||
<td class="ops" @click.stop>
|
||||
<UiDropdownMenu
|
||||
:open="menuUserId === user.id"
|
||||
@@ -1825,6 +1856,22 @@ tbody tr + tr td {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.logout-time {
|
||||
width: 7.2rem;
|
||||
min-height: 2rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0 0.45rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logout-time:disabled {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.roles-grid,
|
||||
.roles-tree {
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user