Selection of multiple officials and role sub-categories
This commit is contained in:
@@ -717,7 +717,11 @@ onUnmounted(() => {
|
||||
<span>{{ dayText(item) }}</span>
|
||||
</div>
|
||||
<div class="who">
|
||||
<strong>{{ item.assignee?.fullName || '—' }}</strong>
|
||||
<strong>{{
|
||||
item.assignees?.length
|
||||
? item.assignees.map((a) => a.fullName).join('، ')
|
||||
: item.assignee?.fullName || '—'
|
||||
}}</strong>
|
||||
<span v-if="item.company || item.person">
|
||||
<UiIcon name="building" :size="12" />
|
||||
{{ item.company?.name || item.person?.fullName }}
|
||||
|
||||
@@ -30,7 +30,18 @@ const commentOpen = ref(false);
|
||||
const postponeOpen = ref(false);
|
||||
const noteOpen = ref(true);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const assignUserId = ref('');
|
||||
const assignUserIds = ref<string[]>([]);
|
||||
|
||||
const assigneeNames = computed(() => {
|
||||
const a = activity.value;
|
||||
if (!a) return '—';
|
||||
const list = a.assignees?.length
|
||||
? a.assignees.map((u) => u.fullName)
|
||||
: a.assignee
|
||||
? [a.assignee.fullName]
|
||||
: [];
|
||||
return list.length ? list.join('، ') : '—';
|
||||
});
|
||||
|
||||
const typeLabel: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
@@ -95,7 +106,11 @@ async function load() {
|
||||
try {
|
||||
const { data } = await api.get<ActivityDto>(`/activities/${id.value}`);
|
||||
activity.value = data;
|
||||
assignUserId.value = data.assignee?.id ?? '';
|
||||
assignUserIds.value = data.assignees?.length
|
||||
? data.assignees.map((u) => u.id)
|
||||
: data.assignee?.id
|
||||
? [data.assignee.id]
|
||||
: [];
|
||||
} catch {
|
||||
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
|
||||
activity.value = null;
|
||||
@@ -144,11 +159,16 @@ async function reopen() {
|
||||
|
||||
async function reassign() {
|
||||
if (!id.value || !canReassign.value) return;
|
||||
if (!assignUserIds.value.length) {
|
||||
error.value = 'حداقل یک مسئول لازم است.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/activities/${id.value}`, {
|
||||
assigneeId: assignUserId.value || null,
|
||||
assigneeIds: [...assignUserIds.value],
|
||||
assigneeId: assignUserIds.value[0] || null,
|
||||
});
|
||||
await load();
|
||||
} catch {
|
||||
@@ -271,25 +291,25 @@ onMounted(() => {
|
||||
<div>
|
||||
<dt>
|
||||
<UiIcon name="user" :size="15" />
|
||||
مسئول
|
||||
مسئولان
|
||||
</dt>
|
||||
<dd class="person">
|
||||
<template v-if="canReassign">
|
||||
<select
|
||||
v-model="assignUserId"
|
||||
class="assign-select"
|
||||
v-model="assignUserIds"
|
||||
class="assign-select multi"
|
||||
multiple
|
||||
:disabled="saving"
|
||||
@change="reassign"
|
||||
>
|
||||
<option value="">بدون مسئول</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="assign-hint">با Ctrl چند نفر را انتخاب کنید</small>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UserAvatar v-if="activity.assignee" :name="activity.assignee.fullName" :size="28" />
|
||||
{{ activity.assignee?.fullName || '—' }}
|
||||
{{ assigneeNames }}
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -586,6 +606,7 @@ dd {
|
||||
.person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
@@ -600,6 +621,18 @@ dd {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.assign-select.multi {
|
||||
min-height: 5.5rem;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.assign-hint {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.flag.low,
|
||||
.priority.low {
|
||||
color: #64748b;
|
||||
|
||||
@@ -77,7 +77,7 @@ const dueTime = ref('10:30');
|
||||
const durationMinutes = ref(30);
|
||||
const personId = ref('');
|
||||
const dealId = ref('');
|
||||
const assigneeId = ref('');
|
||||
const assigneeIds = ref<string[]>([]);
|
||||
const isDone = ref(false);
|
||||
const needsNote = ref(false);
|
||||
const reminderAt = ref('');
|
||||
@@ -109,7 +109,17 @@ const priorityOptions: Array<{ value: ActivityPriority; label: string; tone: str
|
||||
|
||||
const typeMeta = computed(() => typeOptions.find((o) => o.value === type.value));
|
||||
const selectedPerson = computed(() => people.value.find((p) => p.id === personId.value) ?? null);
|
||||
const selectedAssignee = computed(() => users.value.find((u) => u.id === assigneeId.value) ?? null);
|
||||
const selectedAssignees = computed(() =>
|
||||
assigneeIds.value
|
||||
.map((id) => users.value.find((u) => u.id === id))
|
||||
.filter((u): u is (typeof users.value)[number] => Boolean(u)),
|
||||
);
|
||||
const assigneePickerLabel = computed(() => {
|
||||
const list = selectedAssignees.value;
|
||||
if (!list.length) return 'انتخاب مسئول';
|
||||
if (list.length === 1) return list[0]!.fullName;
|
||||
return `${list[0]!.fullName} و ${toFaDigits(list.length - 1)} نفر دیگر`;
|
||||
});
|
||||
const selectedDeal = computed(() => deals.value.find((d) => d.id === dealId.value) ?? null);
|
||||
const personDeals = computed(() =>
|
||||
deals.value.filter((d) => !personId.value || d.person?.id === personId.value),
|
||||
@@ -161,7 +171,7 @@ function resetForm() {
|
||||
durationMinutes.value = 30;
|
||||
personId.value = '';
|
||||
dealId.value = '';
|
||||
assigneeId.value = auth.user?.id ?? '';
|
||||
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
|
||||
isDone.value = false;
|
||||
needsNote.value = false;
|
||||
reminderAt.value = '';
|
||||
@@ -213,7 +223,13 @@ async function load() {
|
||||
reminderNotifySms.value = data.reminderNotifySms ?? false;
|
||||
personId.value = data.person?.id ?? '';
|
||||
dealId.value = data.deal?.id ?? '';
|
||||
assigneeId.value = data.assignee?.id ?? auth.user?.id ?? '';
|
||||
assigneeIds.value = data.assignees?.length
|
||||
? data.assignees.map((a) => a.id)
|
||||
: data.assignee?.id
|
||||
? [data.assignee.id]
|
||||
: auth.user?.id
|
||||
? [auth.user.id]
|
||||
: [];
|
||||
isDone.value = data.status === 'done';
|
||||
} else {
|
||||
resetForm();
|
||||
@@ -272,15 +288,26 @@ function payload(): CreateActivityRequest {
|
||||
personId: personId.value || null,
|
||||
companyId: selectedPerson.value?.company?.id ?? null,
|
||||
dealId: dealId.value || null,
|
||||
assigneeId: assigneeId.value || null,
|
||||
assigneeIds: [...assigneeIds.value],
|
||||
assigneeId: assigneeIds.value[0] || null,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleAssignee(userId: string) {
|
||||
const idx = assigneeIds.value.indexOf(userId);
|
||||
if (idx >= 0) {
|
||||
if (assigneeIds.value.length <= 1) return;
|
||||
assigneeIds.value = assigneeIds.value.filter((id) => id !== userId);
|
||||
return;
|
||||
}
|
||||
assigneeIds.value = [...assigneeIds.value, userId];
|
||||
}
|
||||
|
||||
function validate() {
|
||||
if (!type.value) return 'نوع فعالیت را انتخاب کنید.';
|
||||
if (title.value.trim().length < 2) return 'عنوان فعالیت را بنویسید.';
|
||||
if (!dueDate.value || !dueTime.value) return 'تاریخ و ساعت را مشخص کنید.';
|
||||
if (!assigneeId.value) return 'مسئول فعالیت را انتخاب کنید.';
|
||||
if (!assigneeIds.value.length) return 'حداقل یک مسئول فعالیت را انتخاب کنید.';
|
||||
if (isNew.value && !personId.value) return 'شخص یا مشتری مرتبط را انتخاب کنید.';
|
||||
if (needsNote.value && !description.value.trim()) return 'توضیح این فعالیت را بنویسید.';
|
||||
if (reminderAt.value && !reminderNotifyApp.value && !reminderNotifySms.value) {
|
||||
@@ -480,22 +507,56 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label>مسئول انجام این فعالیت <i>*</i></label>
|
||||
<label>مسئولان انجام این فعالیت <i>*</i></label>
|
||||
<button class="picker person-pick" type="button" @click.stop="assigneeOpen = !assigneeOpen">
|
||||
<UserAvatar :name="selectedAssignee?.fullName || 'مسئول'" :size="32" />
|
||||
<span :class="{ ghost: !selectedAssignee }">
|
||||
{{ selectedAssignee?.fullName || 'انتخاب مسئول' }}
|
||||
<div class="assignee-avatars">
|
||||
<UserAvatar
|
||||
v-for="user in selectedAssignees.slice(0, 3)"
|
||||
:key="user.id"
|
||||
:name="user.fullName"
|
||||
:size="28"
|
||||
/>
|
||||
<UserAvatar
|
||||
v-if="!selectedAssignees.length"
|
||||
name="مسئول"
|
||||
:size="28"
|
||||
/>
|
||||
</div>
|
||||
<span :class="{ ghost: !selectedAssignees.length }">
|
||||
{{ assigneePickerLabel }}
|
||||
</span>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
<ul v-if="assigneeOpen" class="menu">
|
||||
<ul v-if="assigneeOpen" class="menu multi-menu" @click.stop>
|
||||
<li v-for="user in users" :key="user.id">
|
||||
<button type="button" @click="assigneeId = user.id; assigneeOpen = false">
|
||||
<button
|
||||
type="button"
|
||||
class="multi-option"
|
||||
:class="{ on: assigneeIds.includes(user.id) }"
|
||||
@click="toggleAssignee(user.id)"
|
||||
>
|
||||
<span class="check" :class="{ on: assigneeIds.includes(user.id) }">
|
||||
<UiIcon v-if="assigneeIds.includes(user.id)" name="check-plain" :size="12" />
|
||||
</span>
|
||||
<UserAvatar :name="user.fullName" :size="28" />
|
||||
{{ user.fullName }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<small class="hint">میتوانید چند نفر را بهعنوان مسئول انتخاب کنید.</small>
|
||||
<div v-if="selectedAssignees.length" class="assignee-chips">
|
||||
<span v-for="user in selectedAssignees" :key="user.id" class="chip">
|
||||
{{ user.fullName }}
|
||||
<button
|
||||
v-if="assigneeIds.length > 1"
|
||||
type="button"
|
||||
aria-label="حذف مسئول"
|
||||
@click="toggleAssignee(user.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -622,8 +683,14 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
<dd>{{ toFaDigits(durationMinutes) }} دقیقه</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>مسئول</dt>
|
||||
<dd>{{ selectedAssignee?.fullName || '—' }}</dd>
|
||||
<dt>مسئولان</dt>
|
||||
<dd>
|
||||
{{
|
||||
selectedAssignees.length
|
||||
? selectedAssignees.map((u) => u.fullName).join('، ')
|
||||
: '—'
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>مشتری مرتبط</dt>
|
||||
@@ -825,6 +892,81 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.assignee-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.assignee-avatars :deep(.avatar),
|
||||
.assignee-avatars > * {
|
||||
margin-inline-end: -0.35rem;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.assignee-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.assignee-chips .chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assignee-chips .chip button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multi-menu .check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.multi-menu .check.on {
|
||||
background: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option.on {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.type-ico {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -77,6 +77,7 @@ const roleFormId = ref<string | null>(null);
|
||||
const roleFormName = ref('');
|
||||
const roleFormDesc = ref('');
|
||||
const roleFormColor = ref<RoleColor>('teal');
|
||||
const roleFormParentId = ref('');
|
||||
const roleFormPerms = ref<RolePermissionKey[]>(ROLE_PERMISSION_ITEMS.map((p) => p.key));
|
||||
|
||||
const deleteOpen = ref(false);
|
||||
@@ -148,10 +149,42 @@ const visibleMembers = computed(() => {
|
||||
return showAllMembers.value ? role.members : role.members.slice(0, 3);
|
||||
});
|
||||
|
||||
const selectedChildRoles = computed(() => {
|
||||
const id = selectedRole.value?.id;
|
||||
if (!id) return [] as OrgRoleDto[];
|
||||
return roles.value.filter((r) => r.parentId === id);
|
||||
});
|
||||
|
||||
/** گزینههای والد در فرم نقش — خود نقش و زیرنقشهایش حذف میشوند */
|
||||
const parentRoleOptions = computed(() => {
|
||||
const editingId = roleFormId.value;
|
||||
const blocked = new Set<string>();
|
||||
if (editingId) {
|
||||
blocked.add(editingId);
|
||||
const queue = [editingId];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const role of roles.value) {
|
||||
if (role.parentId === current && !blocked.has(role.id)) {
|
||||
blocked.add(role.id);
|
||||
queue.push(role.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return roles.value.filter((r) => !blocked.has(r.id) && (r.depth ?? 0) < 4);
|
||||
});
|
||||
|
||||
const inviteableRoles = computed(() =>
|
||||
roles.value.filter((r) => r.systemKey !== 'owner' || auth.user?.role === 'owner'),
|
||||
);
|
||||
|
||||
function roleTreeLabel(role: OrgRoleDto) {
|
||||
const depth = role.depth ?? 0;
|
||||
const prefix = depth > 0 ? `${'— '.repeat(depth)}` : '';
|
||||
return `${prefix}${role.name}`;
|
||||
}
|
||||
|
||||
function tabFromHash(hash: string): TabKey {
|
||||
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
|
||||
if (hash.includes('roles')) return 'roles';
|
||||
@@ -344,15 +377,41 @@ async function toggleStatus(user: OrgUserDto) {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateRole() {
|
||||
async function changeCommission(user: OrgUserDto, raw: string) {
|
||||
if (!canInvite.value) return;
|
||||
const next = Number(raw);
|
||||
if (!Number.isFinite(next) || next === Number(user.commissionPercent ?? 0)) return;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.patch(`/users/${user.id}/commission`, { commissionPercent: next });
|
||||
ok.value = `پورسانت «${user.fullName}» ذخیره شد.`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره پورسانت ناموفق بود');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateRole(parentId?: string | null) {
|
||||
const parent = parentId ? roles.value.find((r) => r.id === parentId) : null;
|
||||
roleFormId.value = null;
|
||||
roleFormName.value = '';
|
||||
roleFormDesc.value = '';
|
||||
roleFormColor.value = 'teal';
|
||||
roleFormPerms.value = ROLE_PERMISSION_ITEMS.map((p) => p.key);
|
||||
roleFormColor.value = (parent && ROLE_COLOR_OPTIONS.some((c) => c.value === parent.color)
|
||||
? parent.color
|
||||
: 'teal') as RoleColor;
|
||||
roleFormParentId.value = parent?.id ?? '';
|
||||
roleFormPerms.value = parent
|
||||
? [...parent.permissions]
|
||||
: ROLE_PERMISSION_ITEMS.map((p) => p.key);
|
||||
roleFormOpen.value = true;
|
||||
}
|
||||
|
||||
function openCreateChildRole() {
|
||||
if (!selectedRole.value) return;
|
||||
openCreateRole(selectedRole.value.id);
|
||||
}
|
||||
|
||||
function openEditRole() {
|
||||
const role = selectedRole.value;
|
||||
if (!role) return;
|
||||
@@ -362,6 +421,7 @@ function openEditRole() {
|
||||
roleFormColor.value = (ROLE_COLOR_OPTIONS.some((c) => c.value === role.color)
|
||||
? role.color
|
||||
: 'teal') as RoleColor;
|
||||
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
|
||||
roleFormPerms.value = [...role.permissions];
|
||||
roleFormOpen.value = true;
|
||||
}
|
||||
@@ -384,12 +444,24 @@ async function submitRoleForm() {
|
||||
roleFormSaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const body = {
|
||||
const editing = roleFormId.value
|
||||
? roles.value.find((r) => r.id === roleFormId.value)
|
||||
: null;
|
||||
const body: {
|
||||
name: string;
|
||||
description: string;
|
||||
color: RoleColor;
|
||||
permissions: RolePermissionKey[];
|
||||
parentId?: string | null;
|
||||
} = {
|
||||
name: roleFormName.value.trim(),
|
||||
description: roleFormDesc.value.trim(),
|
||||
color: roleFormColor.value,
|
||||
permissions: roleFormPerms.value,
|
||||
};
|
||||
if (!editing?.systemKey) {
|
||||
body.parentId = roleFormParentId.value || null;
|
||||
}
|
||||
if (roleFormId.value) {
|
||||
const { data } = await api.patch<OrgRoleDto>(`/org-roles/${roleFormId.value}`, body);
|
||||
selectedRoleId.value = data.id;
|
||||
@@ -564,7 +636,7 @@ onUnmounted(() => {
|
||||
:aria-selected="accessFocusRole?.id === role.id"
|
||||
@click="accessFocusId = role.id"
|
||||
>
|
||||
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
||||
<span class="pill" :data-color="role.color">{{ roleTreeLabel(role) }}</span>
|
||||
<small>{{ permCountLabel(role) }}</small>
|
||||
</button>
|
||||
</div>
|
||||
@@ -590,7 +662,7 @@ onUnmounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>مجوز</th>
|
||||
<th v-for="role in roles" :key="role.id">{{ role.name }}</th>
|
||||
<th v-for="role in roles" :key="role.id">{{ roleTreeLabel(role) }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -638,9 +710,42 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<h3>{{ selectedRole.name }}</h3>
|
||||
<p>{{ selectedRole.description || '—' }}</p>
|
||||
<p v-if="selectedRole.parentName" class="parent-line">
|
||||
زیرمجموعهٔ
|
||||
<button
|
||||
type="button"
|
||||
class="linkish"
|
||||
@click="pickRole(selectedRole.parentId!)"
|
||||
>
|
||||
{{ selectedRole.parentName }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="selectedChildRoles.length || canEditRoles" class="panel-block">
|
||||
<div class="block-head">
|
||||
<h4>زیرنقشها</h4>
|
||||
<button
|
||||
v-if="canEditRoles && (selectedRole.depth ?? 0) < 4"
|
||||
type="button"
|
||||
class="linkish"
|
||||
@click="openCreateChildRole"
|
||||
>
|
||||
+ زیرنقش
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="selectedChildRoles.length" class="child-roles">
|
||||
<li v-for="child in selectedChildRoles" :key="child.id">
|
||||
<button type="button" class="child-role-btn" @click="pickRole(child.id)">
|
||||
<span class="pill" :data-color="child.color">{{ child.name }}</span>
|
||||
<small>{{ toFaDigits(child.userCount) }} کاربر</small>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="empty">هنوز زیرنقشی تعریف نشده.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel-block">
|
||||
<h4>مجوزهای دسترسی</h4>
|
||||
<ul class="perms">
|
||||
@@ -677,6 +782,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<footer v-if="canEditRoles" class="panel-actions">
|
||||
<button
|
||||
v-if="(selectedRole.depth ?? 0) < 4"
|
||||
type="button"
|
||||
class="btn outline"
|
||||
@click="openCreateChildRole"
|
||||
>
|
||||
+ زیرنقش
|
||||
</button>
|
||||
<button type="button" class="btn outline" @click="openEditRole">
|
||||
<UiIcon name="pencil" :size="16" />
|
||||
ویرایش نقش
|
||||
@@ -740,6 +853,7 @@ onUnmounted(() => {
|
||||
<tr>
|
||||
<th>نام و نام خانوادگی</th>
|
||||
<th>نقش</th>
|
||||
<th>پورسانت %</th>
|
||||
<th>وضعیت</th>
|
||||
<th>
|
||||
<button type="button" class="sort" @click="sortLoginDesc = !sortLoginDesc">
|
||||
@@ -763,6 +877,21 @@ onUnmounted(() => {
|
||||
{{ user.orgRoleName || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td @click.stop>
|
||||
<label class="comm-field">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
dir="ltr"
|
||||
:value="user.commissionPercent ?? 0"
|
||||
:disabled="!canInvite"
|
||||
:aria-label="`پورسانت ${user.fullName}`"
|
||||
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status" :class="{ off: user.isActive === false }">
|
||||
<i />
|
||||
@@ -788,7 +917,7 @@ onUnmounted(() => {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !pagedUsers.length">
|
||||
<td colspan="5" class="empty">کاربری یافت نشد.</td>
|
||||
<td colspan="6" class="empty">کاربری یافت نشد.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -805,26 +934,51 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status" :class="{ off: user.isActive === false }">
|
||||
<i />
|
||||
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
||||
</span>
|
||||
<div class="card-meta" @click.stop>
|
||||
<label class="comm-field">
|
||||
<span>پورسانت %</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
dir="ltr"
|
||||
:value="user.commissionPercent ?? 0"
|
||||
:disabled="!canInvite"
|
||||
:aria-label="`پورسانت ${user.fullName}`"
|
||||
@change="changeCommission(user, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span class="status" :class="{ off: user.isActive === false }">
|
||||
<i />
|
||||
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'roles'" class="roles-grid">
|
||||
<div v-if="tab === 'roles'" class="roles-tree">
|
||||
<button
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
type="button"
|
||||
class="role-card"
|
||||
:class="{ on: selectedRole?.id === role.id }"
|
||||
:class="{ on: selectedRole?.id === role.id, child: (role.depth ?? 0) > 0 }"
|
||||
:style="{ '--role-depth': String(role.depth ?? 0) }"
|
||||
@click="pickRole(role.id)"
|
||||
>
|
||||
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
||||
<div class="role-card-top">
|
||||
<span class="pill" :data-color="role.color">{{ role.name }}</span>
|
||||
<span v-if="role.childCount" class="child-badge">
|
||||
{{ toFaDigits(role.childCount) }} زیرنقش
|
||||
</span>
|
||||
</div>
|
||||
<p>{{ role.description || 'بدون توضیح' }}</p>
|
||||
<small>{{ toFaDigits(role.userCount) }} کاربر</small>
|
||||
<small>
|
||||
{{ toFaDigits(role.userCount) }} کاربر
|
||||
<template v-if="role.parentName"> · زیرمجموعهٔ {{ role.parentName }}</template>
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -859,7 +1013,9 @@ onUnmounted(() => {
|
||||
<label>
|
||||
نقش
|
||||
<select v-model="inviteRoleId">
|
||||
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">{{ role.name }}</option>
|
||||
<option v-for="role in inviteableRoles" :key="role.id" :value="role.id">
|
||||
{{ roleTreeLabel(role) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
@@ -871,10 +1027,23 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="roleFormOpen" :title="roleFormId ? 'ویرایش نقش' : 'نقش جدید'" @close="roleFormOpen = false">
|
||||
<UiModal
|
||||
:open="roleFormOpen"
|
||||
:title="roleFormId ? 'ویرایش نقش' : roleFormParentId ? 'زیرنقش جدید' : 'نقش جدید'"
|
||||
@close="roleFormOpen = false"
|
||||
>
|
||||
<form class="form" @submit.prevent="submitRoleForm">
|
||||
<label>نام نقش<input v-model="roleFormName" required minlength="2" /></label>
|
||||
<label>توضیح<textarea v-model="roleFormDesc" rows="3" /></label>
|
||||
<label v-if="!(roleFormId && roles.find((r) => r.id === roleFormId)?.systemKey)">
|
||||
نقش والد
|
||||
<select v-model="roleFormParentId">
|
||||
<option value="">بدون والد (نقش اصلی)</option>
|
||||
<option v-for="role in parentRoleOptions" :key="role.id" :value="role.id">
|
||||
{{ roleTreeLabel(role) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
رنگ
|
||||
<select v-model="roleFormColor">
|
||||
@@ -1450,6 +1619,7 @@ tbody tr + tr td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
@@ -1457,12 +1627,46 @@ tbody tr + tr td {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
.card-meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.comm-field {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.comm-field input {
|
||||
width: 4.8rem;
|
||||
min-height: 2rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0 0.45rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.comm-field input:disabled {
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.roles-grid,
|
||||
.roles-tree {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.roles-tree {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
text-align: right;
|
||||
padding: 1rem;
|
||||
@@ -1470,11 +1674,83 @@ tbody tr + tr td {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.roles-tree .role-card {
|
||||
margin-inline-start: calc(var(--role-depth, 0) * 1.25rem);
|
||||
border-inline-start: 3px solid transparent;
|
||||
}
|
||||
|
||||
.roles-tree .role-card.child {
|
||||
border-inline-start-color: #99f6e4;
|
||||
background: #f8fffd;
|
||||
}
|
||||
|
||||
.role-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.child-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
background: #ccfbf1;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.55rem;
|
||||
}
|
||||
|
||||
.role-card.on {
|
||||
border-color: #99f6e4;
|
||||
box-shadow: 0 0 0 2px #ccfbf1;
|
||||
}
|
||||
|
||||
.parent-line {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.block-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.block-head h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.child-roles {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.child-role-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.child-role-btn small {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.access-wrap,
|
||||
.org-grid,
|
||||
.form {
|
||||
@@ -1635,7 +1911,8 @@ textarea {
|
||||
}
|
||||
|
||||
.org-grid,
|
||||
.roles-grid {
|
||||
.roles-grid,
|
||||
.roles-tree {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user