Unified search algorithm across all lists; per-section record visibility (contacts, deals, activities, leads, finance, tickets, notes, projects)

This commit is contained in:
2026-10-06 15:04:46 +03:30
parent 43717392b2
commit e08f1452ef
38 changed files with 699 additions and 420 deletions
+100 -23
View File
@@ -6,8 +6,11 @@ import type {
OrgUserDto,
TeamDto,
TeamMemberRole,
UpdateUserVisibilityRequest,
} from '@mento/shared';
import {
DATA_SCOPE_LABELS,
DATA_SCOPES,
DATA_VISIBILITY_LABELS,
DATA_VISIBILITIES,
type DataVisibility,
@@ -173,11 +176,17 @@ async function removeTeam(id: string) {
}
}
async function setVisibility(userId: string, dataVisibility: DataVisibility) {
const expandedVisibility = ref<string | null>(null);
function scopeOverrides(u: OrgUserDto): number {
return Object.keys(u.dataVisibilityScopes ?? {}).length;
}
async function setVisibility(userId: string, payload: UpdateUserVisibilityRequest) {
error.value = '';
try {
await api.patch(`/users/${userId}/visibility`, { dataVisibility });
ok.value =
'سطح دسترسی به پرونده‌ها به‌روز شد. کاربر باید دوباره وارد شود تا اعمال شود.';
await api.patch(`/users/${userId}/visibility`, payload);
ok.value = 'سطح دسترسی به پرونده‌ها به‌روز شد.';
emit('refreshUsers');
} catch (e) {
error.value = axios.isAxiosError(e)
@@ -290,7 +299,9 @@ onMounted(() => {
<section class="card">
<h3>سطح دسترسی به پرونده‌ها</h3>
<p class="hint">
مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه: فقط خود، تیم‌های عضو، یا کل سازمان.
مالک و مدیر همیشه همه پرونده‌ها را می‌بینند. برای بقیه یک سطح پیش‌فرض (فقط خود، تیم‌های عضو،
یا کل سازمان) تعیین کنید و در صورت نیاز با «تنظیم تفکیکی» برای هر بخش سطح جداگانه بگذارید؛
مثلاً همه مشتریان را ببیند ولی معاملات فقط مال خودش.
</p>
<UiEmptyState
v-if="!editableUsers.length"
@@ -298,22 +309,59 @@ onMounted(() => {
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 v-for="u in editableUsers" :key="u.id" class="vis-user">
<div class="vis-row">
<span>
{{ u.fullName }}
<small v-if="scopeOverrides(u)" class="vis-badge">
{{ toFaDigits(scopeOverrides(u)) }} بخش تفکیکی
</small>
</span>
<select
:value="u.dataVisibility ?? 'own'"
:disabled="!canEdit"
title="سطح پیش‌فرض"
@change="
setVisibility(u.id, {
dataVisibility: ($event.target as HTMLSelectElement).value as DataVisibility,
})
"
>
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
پیش‌فرض: {{ DATA_VISIBILITY_LABELS[v] }}
</option>
</select>
<UiButton
variant="secondary"
@click="expandedVisibility = expandedVisibility === u.id ? null : u.id"
>
{{ expandedVisibility === u.id ? 'بستن' : 'تنظیم تفکیکی' }}
</UiButton>
</div>
<div v-if="expandedVisibility === u.id" class="vis-scopes">
<label v-for="s in DATA_SCOPES" :key="s" class="vis-scope">
<span>{{ DATA_SCOPE_LABELS[s] }}</span>
<select
:value="u.dataVisibilityScopes?.[s] ?? ''"
:disabled="!canEdit"
:class="{ custom: Boolean(u.dataVisibilityScopes?.[s]) }"
@change="
setVisibility(u.id, {
scopes: {
[s]: (($event.target as HTMLSelectElement).value || null) as DataVisibility | null,
},
})
"
>
<option value="">
مثل پیش‌فرض ({{ DATA_VISIBILITY_LABELS[u.dataVisibility ?? 'own'] }})
</option>
<option v-for="v in DATA_VISIBILITIES" :key="v" :value="v">
{{ DATA_VISIBILITY_LABELS[v] }}
</option>
</select>
</label>
</div>
</div>
</div>
</section>
@@ -386,13 +434,42 @@ onMounted(() => {
display: grid;
gap: 0.5rem;
}
.vis-user {
border-bottom: 1px solid #f1f5f9;
padding-bottom: 0.45rem;
}
.vis-row {
display: grid;
grid-template-columns: 1fr 220px;
grid-template-columns: 1fr 240px auto;
gap: 0.75rem;
align-items: center;
padding: 0.45rem 0;
border-bottom: 1px solid #f1f5f9;
}
.vis-badge {
margin-inline-start: 0.4rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #eef2ff;
color: #4338ca;
font-size: 0.72rem;
}
.vis-scopes {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.5rem 1rem;
padding: 0.6rem 0.75rem;
background: #f8fafc;
border-radius: 10px;
}
.vis-scope {
display: grid;
gap: 0.25rem;
font-size: 0.85rem;
color: #334155;
}
select.custom {
border-color: #6366f1;
background: #eef2ff;
}
select {
font: inherit;
+3 -3
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { matchesSearch } from '@/lib/search';
type PickUser = { id: string; fullName: string };
@@ -35,9 +36,8 @@ const selected = computed(() =>
props.modelValue.map((id) => byId.value.get(id) ?? { id, fullName: '—' }),
);
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return props.users;
return props.users.filter((u) => u.fullName.toLowerCase().includes(q));
if (!query.value.trim()) return props.users;
return props.users.filter((u) => matchesSearch(query.value, [u.fullName]));
});
const canRemove = computed(() => props.modelValue.length > props.min);