Add named teams and per-user data visibility (own/team/org) with settings UI and org module.

This commit is contained in:
2026-09-26 17:15:09 +03:30
parent 499d13d1df
commit 1b160c8cd3
17 changed files with 1097 additions and 28 deletions
@@ -0,0 +1,362 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
OrgUserDto,
TeamDto,
TeamMemberRole,
} from '@mento/shared';
import {
DATA_VISIBILITY_LABELS,
DATA_VISIBILITIES,
type DataVisibility,
} from '@mento/shared';
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
import { toFaDigits } from '@/lib/jalali';
const props = defineProps<{
users: OrgUserDto[];
canEdit: boolean;
}>();
const emit = defineEmits<{
refreshUsers: [];
}>();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const teams = ref<TeamDto[]>([]);
const newName = ref('');
const newDesc = ref('');
const editingId = ref<string | null>(null);
const memberDraft = ref<Record<string, TeamMemberRole>>({});
const editableUsers = computed(() =>
props.users.filter((u) => u.isActive !== false && u.role !== 'owner' && u.role !== 'admin'),
);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<TeamDto[]>('/teams');
teams.value = data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 403) {
error.value = 'ماژول تیم‌ها و دیدپذیری برای این محیط فعال نیست.';
} else {
error.value = 'بارگذاری تیم‌ها ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createTeam() {
if (!newName.value.trim()) {
error.value = 'نام تیم را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
await api.post('/teams', {
name: newName.value.trim(),
description: newDesc.value.trim(),
});
newName.value = '';
newDesc.value = '';
ok.value = 'تیم ساخته شد.';
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'ساخت تیم ناموفق بود.')
: 'ساخت تیم ناموفق بود.';
} finally {
saving.value = false;
}
}
function startEdit(team: TeamDto) {
editingId.value = team.id;
const draft: Record<string, TeamMemberRole> = {};
for (const m of team.members) {
draft[m.userId] = m.role;
}
memberDraft.value = draft;
}
function toggleMember(userId: string) {
if (memberDraft.value[userId]) {
const next = { ...memberDraft.value };
delete next[userId];
memberDraft.value = next;
} else {
memberDraft.value = { ...memberDraft.value, [userId]: 'member' };
}
}
function setRole(userId: string, role: TeamMemberRole) {
if (!memberDraft.value[userId]) return;
memberDraft.value = { ...memberDraft.value, [userId]: role };
}
async function saveMembers() {
if (!editingId.value) return;
saving.value = true;
error.value = '';
try {
await api.put(`/teams/${editingId.value}/members`, {
members: Object.entries(memberDraft.value).map(([userId, role]) => ({
userId,
role,
})),
});
ok.value = 'اعضای تیم ذخیره شد.';
editingId.value = null;
await load();
} catch {
error.value = 'ذخیره اعضا ناموفق بود.';
} finally {
saving.value = false;
}
}
async function removeTeam(id: string) {
if (!confirm('این تیم حذف شود؟')) return;
try {
await api.delete(`/teams/${id}`);
if (editingId.value === id) editingId.value = null;
await load();
} catch {
error.value = 'حذف تیم ناموفق بود.';
}
}
async function setVisibility(userId: string, dataVisibility: DataVisibility) {
try {
await api.patch(`/users/${userId}/visibility`, { dataVisibility });
ok.value = 'سطح دید به‌روز شد. کاربر باید دوباره وارد شود تا اعمال شود.';
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
? String((e.response?.data as { message?: string })?.message ?? 'تغییر سطح دید ناموفق بود.')
: 'تغییر سطح دید ناموفق بود.';
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="teams-panel">
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<section v-if="canEdit" class="card">
<h3>تیم جدید</h3>
<div class="row">
<UiField label="نام تیم">
<UiInput v-model="newName" placeholder="مثلاً فروش تهران" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="newDesc" placeholder="اختیاری" />
</UiField>
<UiButton :loading="saving" @click="createTeam">افزودن</UiButton>
</div>
</section>
<section class="card">
<h3>تیم‌ها</h3>
<UiEmptyState
v-if="!loading && !teams.length"
title="تیمی تعریف نشده"
description="تیم بسازید تا اعضای فروش پرونده‌های هم‌تیمی را ببینند."
/>
<div v-else class="team-list">
<article v-for="t in teams" :key="t.id" class="team">
<header>
<div>
<strong>{{ t.name }}</strong>
<p class="muted">
{{ t.description || '—' }}
· {{ toFaDigits(t.memberCount) }} عضو
</p>
</div>
<div v-if="canEdit" class="actions">
<UiButton variant="secondary" @click="startEdit(t)">اعضا</UiButton>
<UiButton variant="secondary" @click="removeTeam(t.id)">حذف</UiButton>
</div>
</header>
<p class="members">
{{
t.members.length
? t.members
.map((m) => `${m.fullName}${m.role === 'manager' ? ' (سرپرست)' : ''}`)
.join('، ')
: 'بدون عضو'
}}
</p>
<div v-if="editingId === t.id" class="editor">
<p class="hint">اعضا را انتخاب کنید؛ سرپرست همان عضو تیم است.</p>
<label v-for="u in users.filter((x) => x.isActive !== false)" :key="u.id" class="person">
<input
type="checkbox"
:checked="Boolean(memberDraft[u.id])"
@change="toggleMember(u.id)"
/>
<span>{{ u.fullName }}</span>
<select
v-if="memberDraft[u.id]"
:value="memberDraft[u.id]"
@change="setRole(u.id, ($event.target as HTMLSelectElement).value as TeamMemberRole)"
>
<option value="member">عضو</option>
<option value="manager">سرپرست</option>
</select>
</label>
<div class="actions">
<UiButton :loading="saving" @click="saveMembers">ذخیره اعضا</UiButton>
<UiButton variant="secondary" @click="editingId = null">انصراف</UiButton>
</div>
</div>
</article>
</div>
</section>
<section class="card">
<h3>سطح دید داده‌ها</h3>
<p class="hint">
مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه: فقط خود، تیم‌های عضو، یا کل سازمان.
</p>
<UiEmptyState
v-if="!editableUsers.length"
title="کاربر قابل تنظیم نیست"
description="فقط نقش‌های فروش و پشتیبانی سطح دید جدا دارند."
/>
<div v-else class="vis-table">
<div v-for="u in editableUsers" :key="u.id" class="vis-row">
<span>{{ u.fullName }}</span>
<select
:value="u.dataVisibility ?? 'own'"
:disabled="!canEdit"
@change="
setVisibility(
u.id,
($event.target as HTMLSelectElement).value as DataVisibility,
)
"
>
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
{{ DATA_VISIBILITY_LABELS[v] }}
</option>
</select>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.teams-panel {
display: grid;
gap: 1rem;
}
.card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 1rem;
}
.card h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.75rem;
align-items: end;
}
.team-list {
display: grid;
gap: 0.75rem;
}
.team {
border: 1px solid #eef2f7;
border-radius: var(--radius, 12px);
padding: 0.75rem;
}
.team header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.muted,
.hint,
.members {
color: #64748b;
font-size: 0.85rem;
margin: 0.25rem 0 0;
}
.editor {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #eef2f7;
display: grid;
gap: 0.45rem;
}
.person {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.5rem;
align-items: center;
padding: 0.35rem 0;
}
.vis-table {
display: grid;
gap: 0.5rem;
}
.vis-row {
display: grid;
grid-template-columns: 1fr 220px;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
border-bottom: 1px solid #f1f5f9;
}
select {
font: inherit;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.row,
.vis-row {
grid-template-columns: 1fr;
}
}
</style>
+9
View File
@@ -350,6 +350,15 @@ export const NAV_HUBS: NavHubDef[] = [
tint: 'violet',
module: 'team_settings',
},
{
to: '/settings/team#settings-teams',
title: 'تیم‌ها و دیدپذیری',
desc: 'گروه‌های کاری و سطح مشاهده پرونده‌ها',
icon: 'users',
tint: 'sky',
module: 'teams',
admin: true,
},
{
to: '/settings/audit-logs',
title: 'لاگ فعالیت کاربران',
+20 -3
View File
@@ -23,8 +23,9 @@ import {
formatJalaliNumeric,
toFaDigits,
} from '@/lib/jalali';
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
type TabKey = 'users' | 'roles' | 'access';
type TabKey = 'users' | 'roles' | 'access' | 'teams';
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
const PAGE_SIZE = 10;
@@ -154,12 +155,20 @@ const inviteableRoles = computed(() =>
function tabFromHash(hash: string): TabKey {
if (hash.includes('settings-org') || hash.includes('access')) return 'access';
if (hash.includes('roles')) return 'roles';
if (hash.includes('teams') || hash.includes('settings-teams')) return 'teams';
return 'users';
}
function setTab(next: TabKey) {
tab.value = next;
const hash = next === 'access' ? 'settings-org' : next === 'roles' ? 'roles' : 'settings-team';
const hash =
next === 'access'
? 'settings-org'
: next === 'roles'
? 'roles'
: next === 'teams'
? 'settings-teams'
: 'settings-team';
if (route.hash.replace('#', '') !== hash) {
void router.replace({ hash: `#${hash}` });
}
@@ -501,12 +510,20 @@ onUnmounted(() => {
<nav class="tabs" aria-label="بخش‌های مدیریت کاربران">
<button type="button" :class="{ on: tab === 'users' }" @click="setTab('users')">لیست کاربران</button>
<button type="button" :class="{ on: tab === 'roles' }" @click="setTab('roles')">نقش‌ها</button>
<button type="button" :class="{ on: tab === 'teams' }" @click="setTab('teams')">تیم‌ها و دیدپذیری</button>
<button type="button" :class="{ on: tab === 'access' }" @click="setTab('access')">تنظیمات دسترسی</button>
</nav>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<TeamsSettingsPanel
v-if="tab === 'teams'"
:users="users"
:can-edit="canInvite"
@refresh-users="load"
/>
<div v-if="tab === 'access'" class="access-wrap">
<section class="card org-card">
<h2>سازمان</h2>
@@ -597,7 +614,7 @@ onUnmounted(() => {
</section>
</div>
<div v-else class="stage" :class="{ 'panel-open': Boolean(selectedRole) }">
<div v-else-if="tab === 'users' || tab === 'roles'" class="stage" :class="{ 'panel-open': Boolean(selectedRole) }">
<aside v-if="selectedRole" class="role-panel" :class="{ sheet: isNarrow }">
<div v-if="isNarrow" class="sheet-handle" />
<header class="panel-head">