feat: compact logout-time cell and optional login block after logout time

Owners set the daily logout time in a modal and can block re-login until the next Tehran day.
This commit is contained in:
2026-10-21 14:20:15 +03:30
parent 7ae6a5837a
commit ce27df20ca
9 changed files with 195 additions and 25 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false;
+2
View File
@@ -375,6 +375,8 @@ model Membership {
priorArchiveLens String @default("current")
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
logoutTime String?
/// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست
blockLoginAfterLogout Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+24 -3
View File
@@ -1,4 +1,4 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
@@ -13,6 +13,9 @@ import {
export const WORKDAY_ENDED_MESSAGE =
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
type RecordInput = {
organizationId: string;
userId: string;
@@ -61,6 +64,18 @@ export class AttendanceService {
});
}
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد می‌شود. */
async assertLoginAllowed(organizationId: string, userId: string, at = new Date()) {
const membership = await this.prisma.membership.findFirst({
where: { organizationId, userId, isActive: true },
select: { role: true, logoutTime: true, blockLoginAfterLogout: true },
});
if (!membership || membership.role === 'owner' || !membership.blockLoginAfterLogout) return;
if (loggedInAfterCutoff(at, membership.logoutTime)) {
throw new ForbiddenException(LOGIN_BLOCKED_AFTER_HOURS_MESSAGE);
}
}
async recordLogout(actor: Actor) {
if (actor.viewAsReadOnly) return { ok: true };
const at = new Date();
@@ -81,14 +96,20 @@ export class AttendanceService {
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { logoutTime: true, role: true },
select: { logoutTime: true, role: true, blockLoginAfterLogout: true },
});
const logoutTime = membership?.logoutTime;
if (!logoutTime || membership?.role === 'owner') return;
const now = new Date();
const cutoff = tehranCutoff(now, logoutTime);
if (!cutoff || now.getTime() < cutoff.getTime()) return;
if (actor.sessionStartedAt.getTime() >= cutoff.getTime()) return;
// نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
if (
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
!membership?.blockLoginAfterLogout
) {
return;
}
await this.write({
organizationId: actor.organizationId,
+3
View File
@@ -723,6 +723,9 @@ export class AuthService {
const subscription = await this.billing.requireActiveForOrganization(
organizationId,
);
if (!opts?.skipLastLogin) {
await this.attendance.assertLoginAllowed(organizationId, userId);
}
await this.memberships.syncUserLastOrg(
userId,
organizationId,
+5
View File
@@ -95,6 +95,11 @@ export class UpdateLogoutTimeDto {
@IsString()
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'ساعت خروج معتبر نیست' })
logoutTime?: string | null;
/** بدون مقدار = بدون تغییر */
@IsOptional()
@IsBoolean()
blockLoginAfterLogout?: boolean;
}
export class UpdateUserVisibilityDto {
+1
View File
@@ -79,6 +79,7 @@ export class UsersController {
user.role,
id,
dto.logoutTime ?? null,
dto.blockLoginAfterLogout,
);
}
+13 -1
View File
@@ -46,6 +46,7 @@ type MembershipRow = {
dataVisibilityScopes?: unknown;
priorArchiveEnabled?: boolean;
logoutTime?: string | null;
blockLoginAfterLogout?: boolean;
createdAt: Date;
orgRole: { id: string; name: string; color: string } | null;
user: {
@@ -248,6 +249,7 @@ export class UsersService {
actorRole: UserRole,
targetId: string,
logoutTime: string | null,
blockLoginAfterLogout?: boolean,
): Promise<OrgUserDto> {
if (actorRole !== 'owner') {
throw new ForbiddenException('فقط مالک می‌تواند ساعت خروج را تعیین کند');
@@ -263,7 +265,15 @@ export class UsersService {
}
await this.prisma.membership.update({
where: { id: target.id },
data: { logoutTime: value },
data: {
logoutTime: value,
// بدون ساعت خروج، مسدودسازی معنی ندارد
...(!value
? { blockLoginAfterLogout: false }
: blockLoginAfterLogout !== undefined
? { blockLoginAfterLogout }
: {}),
},
});
return this.dtoOf(organizationId, targetId);
}
@@ -533,6 +543,8 @@ export class UsersService {
: parseVisibilityScopes(row.dataVisibilityScopes),
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
blockLoginAfterLogout:
row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout),
createdAt: row.createdAt.toISOString(),
};
}
+143 -21
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useEscClose } from '@/lib/modal-stack';
import { matchesSearch } from '@/lib/search';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
@@ -504,20 +505,54 @@ async function toggleStatus(user: OrgUserDto) {
}
}
const logoutModal = ref<{ user: OrgUserDto; time: string } | null>(null);
const logoutSaving = ref(false);
function openLogoutModal(user: OrgUserDto) {
if (!canSetLogoutTime.value || user.role === 'owner') return;
logoutModal.value = { user, time: user.logoutTime || '18:00' };
}
async function changeLogoutTime(user: OrgUserDto, raw: string) {
if (!canSetLogoutTime.value || user.role === 'owner') return;
const next = raw.trim();
if (next === (user.logoutTime ?? '')) return;
if (next === (user.logoutTime ?? '')) {
logoutModal.value = null;
return;
}
error.value = '';
ok.value = '';
logoutSaving.value = true;
try {
await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null });
ok.value = next
? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.`
: `محدودیت خروج «${user.fullName}» برداشته شد.`;
logoutModal.value = null;
await load();
} catch (e) {
error.value = apiError(e, 'ذخیره ساعت خروج ناموفق بود');
} finally {
logoutSaving.value = false;
}
}
async function toggleBlockAfterLogout(user: OrgUserDto) {
if (!canSetLogoutTime.value || user.role === 'owner' || !user.logoutTime) return;
const next = !user.blockLoginAfterLogout;
error.value = '';
ok.value = '';
try {
await api.patch(`/users/${user.id}/logout-time`, {
logoutTime: user.logoutTime,
blockLoginAfterLogout: next,
});
ok.value = next
? `«${user.fullName}» بعد از ساعت ${toFaDigits(user.logoutTime)} دیگر نمی‌تواند وارد شود.`
: `«${user.fullName}» بعد از ساعت خروج هم می‌تواند وارد شود.`;
await load();
} catch (e) {
error.value = apiError(e, 'تغییر محدودیت ورود ناموفق بود');
}
}
@@ -1051,15 +1086,34 @@ onUnmounted(() => {
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td v-if="canSetLogoutTime" @click.stop>
<span v-if="user.role === 'owner'" class="muted">—</span>
<input
v-else
class="logout-time"
type="time"
dir="ltr"
:value="user.logoutTime || ''"
:aria-label="`ساعت خروج ${user.fullName}`"
@change="changeLogoutTime(user, ($event.target as HTMLInputElement).value)"
/>
<span v-else class="logout-cell">
<button
type="button"
class="logout-btn"
:class="{ set: Boolean(user.logoutTime) }"
:aria-label="user.logoutTime ? `ویرایش ساعت خروج ${user.fullName}` : `تعیین ساعت خروج ${user.fullName}`"
:title="user.logoutTime ? 'ویرایش ساعت خروج' : 'تعیین ساعت خروج'"
@click="openLogoutModal(user)"
>
<span v-if="user.logoutTime" dir="ltr">{{ toFaDigits(user.logoutTime) }}</span>
<UiIcon v-else name="clock" :size="16" />
</button>
<button
v-if="user.logoutTime"
type="button"
class="block-btn"
:class="{ on: user.blockLoginAfterLogout }"
:aria-pressed="Boolean(user.blockLoginAfterLogout)"
:title="
user.blockLoginAfterLogout
? 'ورود بعد از ساعت خروج مسدود است — برای آزاد کردن بزنید'
: 'ورود بعد از ساعت خروج آزاد است — برای مسدود کردن بزنید'
"
@click="toggleBlockAfterLogout(user)"
>
<UiIcon :name="user.blockLoginAfterLogout ? 'check' : 'x'" :size="14" />
</button>
</span>
</td>
<td class="ops" @click.stop>
<UiDropdownMenu
@@ -1205,6 +1259,43 @@ onUnmounted(() => {
</template>
</UiModal>
<UiModal
:open="Boolean(logoutModal)"
:title="logoutModal ? `ساعت خروج ${logoutModal.user.fullName}` : 'ساعت خروج'"
@close="logoutModal = null"
>
<form v-if="logoutModal" class="form" @submit.prevent="changeLogoutTime(logoutModal.user, logoutModal.time)">
<label>
ساعت خروج روزانه
<JalaliDateInput v-model="logoutModal.time" mode="time" :clearable="false" />
</label>
<p class="hint">
در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج می‌شود. با تیک کنار ساعت می‌توانید
ورود دوباره تا پایان همان روز را هم ببندید.
</p>
</form>
<template #footer>
<button
v-if="logoutModal?.user.logoutTime"
type="button"
class="btn ghost"
:disabled="logoutSaving"
@click="logoutModal && changeLogoutTime(logoutModal.user, '')"
>
حذف ساعت خروج
</button>
<button type="button" class="btn ghost" @click="logoutModal = null">انصراف</button>
<button
type="button"
class="btn primary"
:disabled="logoutSaving || !logoutModal?.time"
@click="logoutModal && changeLogoutTime(logoutModal.user, logoutModal.time)"
>
{{ logoutSaving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
</template>
</UiModal>
<UiModal :open="editOpen" title="ویرایش مشخصات کاربر" @close="editOpen = false">
<form class="form" @submit.prevent="submitEditUser">
<label>نام و نام خانوادگی<input v-model="editName" required minlength="2" /></label>
@@ -1875,22 +1966,53 @@ 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-cell {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.logout-time:disabled {
background: #f8fafc;
.logout-btn,
.block-btn {
display: inline-grid;
place-items: center;
height: 1.9rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #64748b;
font: inherit;
cursor: pointer;
}
.logout-btn {
min-width: 1.9rem;
padding: 0 0.4rem;
}
.logout-btn.set {
color: #0f172a;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.logout-btn:hover,
.block-btn:hover {
border-color: #0d9488;
color: #0f766e;
}
.block-btn {
width: 1.9rem;
color: #94a3b8;
}
.block-btn.on {
border-color: #99f6e4;
background: #f0fdfa;
color: #0f766e;
}
.roles-grid,
.roles-tree {
display: grid;