feat: log staff sign-in and sign-out and force logout at a set time

This commit is contained in:
2026-10-19 13:34:55 +03:30
parent 3863c5e7d1
commit 8590579605
25 changed files with 588 additions and 15 deletions
+83 -4
View File
@@ -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" />
+5 -1
View File
@@ -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;
+47
View File
@@ -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;