Unified search algorithm across all lists; per-section record visibility (contacts, deals, activities, leads, finance, tickets, notes, projects)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
|
||||
export type PipelineAccent = 'green' | 'orange' | 'purple';
|
||||
|
||||
export type PipelineDeal = {
|
||||
@@ -14,6 +16,8 @@ export type PipelineDeal = {
|
||||
assigneeNames?: string[];
|
||||
ownerId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
/** متنهای قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
|
||||
searchFields?: Array<string | null | undefined>;
|
||||
};
|
||||
|
||||
export type PipelineUiCopy = {
|
||||
@@ -190,6 +194,12 @@ export function mapBoardToStages(
|
||||
assigneeNames,
|
||||
ownerId: deal.owner?.id ?? null,
|
||||
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
|
||||
searchFields: [
|
||||
deal.notes,
|
||||
deal.company?.name,
|
||||
deal.person?.fullName,
|
||||
...assigneeNames,
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -246,6 +256,13 @@ export function mapProjectBoardToStages(
|
||||
assigneeNames,
|
||||
ownerId: card.owner?.id ?? null,
|
||||
assigneeIds: (card.assignees ?? []).map((a) => a.id),
|
||||
searchFields: [
|
||||
card.notes,
|
||||
card.company?.name,
|
||||
card.person?.fullName,
|
||||
card.deal?.title,
|
||||
...assigneeNames,
|
||||
],
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -343,11 +360,7 @@ export function filterStages(
|
||||
(d.assigneeIds ?? []).includes(mineUserId);
|
||||
if (!isMine) return false;
|
||||
}
|
||||
if (
|
||||
q &&
|
||||
!d.title.toLowerCase().includes(q) &&
|
||||
!d.description.toLowerCase().includes(q)
|
||||
) {
|
||||
if (q && !matchesSearch(q, [d.title, d.description, ...(d.searchFields ?? [])])) {
|
||||
return false;
|
||||
}
|
||||
if (min != null && !Number.isNaN(min) && (d.amount == null || d.amount < min)) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* همان الگوریتم جستجوی سرور (apps/api/src/common/search.ts) برای فیلترهای سمت کاربر:
|
||||
* کلمهبهکلمه، بخشی از کلمه کافی است، حروف عربی/فارسی و ارقام فارسی/لاتین یکساناند.
|
||||
*/
|
||||
|
||||
function toAsciiDigits(s: string): string {
|
||||
return s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(
|
||||
/[٠-٩]/g,
|
||||
(d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSearchText(raw: unknown): string {
|
||||
return toAsciiDigits(String(raw ?? ''))
|
||||
.toLowerCase()
|
||||
.replace(/[يىئ]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/ة/g, 'ه')
|
||||
.replace(/[أإٱ]/g, 'ا')
|
||||
.replace(/ؤ/g, 'و')
|
||||
.replace(/[\u064B-\u065F\u0670]/g, '')
|
||||
.replace(/[\u200c\u200f\u200e]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function phoneCore(token: string): string | null {
|
||||
if (!/^\+?[0-9]+$/.test(token)) return null;
|
||||
const core = token.replace(/^\+/, '').replace(/^(0098|98|0)/, '');
|
||||
return core.length >= 3 ? core : null;
|
||||
}
|
||||
|
||||
/** آیا همه کلمات عبارت جستجو در یکی از فیلدها پیدا میشوند؟ عبارت خالی = true */
|
||||
export function matchesSearch(query: string, fields: unknown[]): boolean {
|
||||
const q = normalizeSearchText(query);
|
||||
if (!q) return true;
|
||||
const hay = normalizeSearchText(fields.filter((f) => f != null && f !== '').join(' \u0001 '));
|
||||
return q.split(' ').every((token) => {
|
||||
if (hay.includes(token)) return true;
|
||||
const core = phoneCore(token);
|
||||
return core ? hay.includes(core) : false;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -181,9 +182,9 @@ const canPostInActive = computed(() => {
|
||||
});
|
||||
|
||||
const filteredPeers = computed(() => {
|
||||
const q = peerPickerQuery.value.trim().toLowerCase();
|
||||
const q = peerPickerQuery.value.trim();
|
||||
if (!q) return peers.value;
|
||||
return peers.value.filter((p) => p.fullName.toLowerCase().includes(q));
|
||||
return peers.value.filter((p) => matchesSearch(q, [p.fullName]));
|
||||
});
|
||||
|
||||
const selectedGroupPeers = computed(() =>
|
||||
@@ -251,34 +252,32 @@ const sortedRooms = computed(() =>
|
||||
);
|
||||
|
||||
const filteredRooms = computed(() => {
|
||||
const q = roomListQuery.value.trim().toLowerCase();
|
||||
const q = roomListQuery.value.trim();
|
||||
return sortedRooms.value.filter((room) => {
|
||||
if (roomListTab.value === 'dm' && room.kind !== 'dm') return false;
|
||||
if (roomListTab.value === 'group' && room.kind !== 'group') return false;
|
||||
if (roomListTab.value === 'channel' && room.kind !== 'channel') return false;
|
||||
if (!q) return true;
|
||||
const title = roomTitle(room).toLowerCase();
|
||||
const preview = (room.lastMessagePreview || '').toLowerCase();
|
||||
return title.includes(q) || preview.includes(q);
|
||||
return matchesSearch(q, [roomTitle(room), room.lastMessagePreview]);
|
||||
});
|
||||
});
|
||||
|
||||
const mentionCandidates = computed(() => {
|
||||
const state = mentionState.value;
|
||||
if (!state) return [] as ConversationPeerDto[];
|
||||
const q = state.query.trim().toLowerCase();
|
||||
const q = state.query.trim();
|
||||
return peers.value
|
||||
.filter((p) => !q || p.fullName.toLowerCase().includes(q))
|
||||
.filter((p) => !q || matchesSearch(q, [p.fullName]))
|
||||
.slice(0, 8);
|
||||
});
|
||||
|
||||
const chatSearchHits = computed(() => {
|
||||
const q = chatSearchQuery.value.trim().toLowerCase();
|
||||
const q = chatSearchQuery.value.trim();
|
||||
if (!q || !chatSearchOpen.value) return [] as ChatMessage[];
|
||||
return messages.value.filter((m) => {
|
||||
if (m.deleted) return false;
|
||||
if (m.type === 'text') return (m.content || '').toLowerCase().includes(q);
|
||||
return previewLabelForMessage(m).toLowerCase().includes(q);
|
||||
if (m.type === 'text') return matchesSearch(q, [m.content]);
|
||||
return matchesSearch(q, [previewLabelForMessage(m)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -764,9 +763,13 @@ async function searchCrmLinks() {
|
||||
} else {
|
||||
const { data } = await api.get<PipelineBoardDto>('/pipelines/board');
|
||||
const deals = (data.stages || []).flatMap((s) => s.deals || []);
|
||||
const ql = q.toLowerCase();
|
||||
crmLinkResults.value = deals
|
||||
.filter((d) => !ql || (d.title || '').toLowerCase().includes(ql) || d.id.includes(q))
|
||||
.filter(
|
||||
(d) =>
|
||||
!q.trim() ||
|
||||
d.id.includes(q) ||
|
||||
matchesSearch(q, [d.title, d.person?.fullName, d.company?.name]),
|
||||
)
|
||||
.slice(0, 20)
|
||||
.map((d) => ({ id: d.id, title: d.title || 'معامله' }));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -219,13 +220,10 @@ const currencySelectOptions = computed(() => {
|
||||
});
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const q = productSearch.value.trim().toLowerCase();
|
||||
const q = productSearch.value.trim();
|
||||
if (!q) return products.value;
|
||||
return products.value.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.sku && p.sku.toLowerCase().includes(q)) ||
|
||||
(p.description && p.description.toLowerCase().includes(q)),
|
||||
return products.value.filter((p) =>
|
||||
matchesSearch(q, [p.name, p.sku, p.description, p.category]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
@@ -204,13 +205,9 @@ const filtered = computed(() => {
|
||||
if (Number.isFinite(amax) && amountMax.value !== '') {
|
||||
list = list.filter((r) => Math.abs(r.amount) <= amax);
|
||||
}
|
||||
const query = q.value.trim().toLowerCase();
|
||||
if (query) {
|
||||
if (q.value.trim()) {
|
||||
list = list.filter((r) =>
|
||||
[r.number, r.customerName, r.personName, r.statusLabel, String(r.amount)]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
matchesSearch(q.value, [r.number, r.customerName, r.personName, r.statusLabel, String(r.amount)]),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -79,7 +80,7 @@ const visibleItems = computed(() => {
|
||||
if (statusFilter.value === 'active' && !row.isActive) return false;
|
||||
if (statusFilter.value === 'inactive' && row.isActive) return false;
|
||||
if (!q) return true;
|
||||
return `${row.name} ${row.description ?? ''} ${row.body}`.includes(q);
|
||||
return matchesSearch(q, [row.name, row.description, row.body]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -59,15 +60,8 @@ const categories = computed(() => {
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = [...products.value];
|
||||
const query = q.value.trim().toLowerCase();
|
||||
if (query) {
|
||||
list = list.filter((p) => {
|
||||
const hay = [p.name, p.sku, p.description, p.category]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(query);
|
||||
});
|
||||
if (q.value.trim()) {
|
||||
list = list.filter((p) => matchesSearch(q.value, [p.name, p.sku, p.description, p.category]));
|
||||
}
|
||||
if (category.value) {
|
||||
list = list.filter((p) => p.category === category.value);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
@@ -93,9 +94,8 @@ const filteredGoals = computed(() => {
|
||||
if (statusFilter.value !== 'all') {
|
||||
list = list.filter((g) => g.status === statusFilter.value);
|
||||
}
|
||||
const query = q.value.trim().toLowerCase();
|
||||
if (query) {
|
||||
list = list.filter((g) => g.title.toLowerCase().includes(query));
|
||||
if (q.value.trim()) {
|
||||
list = list.filter((g) => matchesSearch(q.value, [g.title]));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
@@ -123,13 +124,7 @@ const filteredUsers = computed(() => {
|
||||
const q = query.value.trim();
|
||||
let list = users.value;
|
||||
if (q) {
|
||||
const needle = q.toLowerCase();
|
||||
list = list.filter(
|
||||
(u) =>
|
||||
u.fullName.toLowerCase().includes(needle) ||
|
||||
(u.mobile ?? '').includes(needle) ||
|
||||
(u.orgRoleName ?? '').includes(q),
|
||||
);
|
||||
list = list.filter((u) => matchesSearch(q, [u.fullName, u.mobile, u.email, u.orgRoleName]));
|
||||
}
|
||||
return [...list].sort((a, b) => {
|
||||
const at = a.lastLoginAt ? new Date(a.lastLoginAt).getTime() : 0;
|
||||
|
||||
Reference in New Issue
Block a user