feat(acl): deep data access by section for roles and users
Role default + user override with OR rules; covers customers, leads, sales, activities, notes, routines, projects, and tickets.
This commit is contained in:
@@ -0,0 +1,542 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type {
|
||||
DataAccessOwnerKind,
|
||||
DataAccessRule,
|
||||
DataAccessSection,
|
||||
PersonMood,
|
||||
SectionDataAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
PERSON_IMPORT_SOURCE_LABELS,
|
||||
PERSON_IMPORT_SOURCES,
|
||||
PERSON_MOOD_LABELS,
|
||||
PERSON_MOODS,
|
||||
sectionHasDataAccessLimits,
|
||||
} from '@mento/shared';
|
||||
import { UiButton } from '@/components/ui';
|
||||
|
||||
export type IdOption = { id: string; name: string };
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: SectionDataAccess;
|
||||
section?: DataAccessSection;
|
||||
groups?: IdOption[];
|
||||
stages?: IdOption[];
|
||||
pipelines?: IdOption[];
|
||||
boards?: IdOption[];
|
||||
activityTypes?: IdOption[];
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{
|
||||
section: 'customers',
|
||||
groups: () => [],
|
||||
stages: () => [],
|
||||
pipelines: () => [],
|
||||
boards: () => [],
|
||||
activityTypes: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [SectionDataAccess];
|
||||
}>();
|
||||
|
||||
const sectionLabel = computed(() => DATA_ACCESS_SECTION_LABELS[props.section]);
|
||||
|
||||
const showGroups = computed(() => props.section === 'customers');
|
||||
const showMoods = computed(() => props.section === 'customers');
|
||||
const showImportSources = computed(() => props.section === 'customers');
|
||||
const showLeadSources = computed(() => props.section === 'leads');
|
||||
const showNoteTypes = computed(() => props.section === 'notes');
|
||||
const showTicketStatuses = computed(() => props.section === 'tickets');
|
||||
const showStages = computed(
|
||||
() =>
|
||||
props.section === 'leads' ||
|
||||
props.section === 'sales' ||
|
||||
props.section === 'activities' ||
|
||||
props.section === 'projects',
|
||||
);
|
||||
const showPipelines = computed(() => props.section === 'sales');
|
||||
const showBoards = computed(
|
||||
() => props.section === 'activities' || props.section === 'projects',
|
||||
);
|
||||
const showTypes = computed(
|
||||
() => props.section === 'activities' || props.section === 'routines',
|
||||
);
|
||||
|
||||
const ownerSelfLabel = computed(() => {
|
||||
if (
|
||||
props.section === 'leads' ||
|
||||
props.section === 'activities' ||
|
||||
props.section === 'routines' ||
|
||||
props.section === 'tickets'
|
||||
) {
|
||||
return 'فقط مسئول خودش';
|
||||
}
|
||||
if (props.section === 'notes') return 'فقط نویسنده خودش';
|
||||
return 'فقط پروندههای خود';
|
||||
});
|
||||
|
||||
const noteTypeOptions = [
|
||||
{ value: 'general', label: 'عمومی' },
|
||||
{ value: 'call_result', label: 'نتیجه تماس' },
|
||||
{ value: 'meeting', label: 'جلسه' },
|
||||
{ value: 'important', label: 'مهم' },
|
||||
{ value: 'warning', label: 'هشدار' },
|
||||
{ value: 'next_action', label: 'اقدام بعدی' },
|
||||
{ value: 'follow_up', label: 'پیگیری' },
|
||||
{ value: 'idea', label: 'ایده' },
|
||||
{ value: 'suggestion', label: 'پیشنهاد' },
|
||||
{ value: 'personal', label: 'شخصی' },
|
||||
];
|
||||
|
||||
const ticketStatusOptions = [
|
||||
{ value: 'open', label: 'باز' },
|
||||
{ value: 'in_progress', label: 'در حال انجام' },
|
||||
{ value: 'waiting', label: 'در انتظار' },
|
||||
{ value: 'resolved', label: 'حلشده' },
|
||||
{ value: 'closed', label: 'بسته' },
|
||||
];
|
||||
|
||||
const typesLegend = computed(() =>
|
||||
props.section === 'routines' ? 'دسته روتین' : 'نوع فعالیت',
|
||||
);
|
||||
|
||||
const mode = computed({
|
||||
get: () => (props.modelValue?.mode === 'rules' ? 'rules' : 'all'),
|
||||
set: (next: 'all' | 'rules') => {
|
||||
if (next === 'all') {
|
||||
emit('update:modelValue', { mode: 'all', rules: [] });
|
||||
return;
|
||||
}
|
||||
const rules = props.modelValue?.rules?.length
|
||||
? props.modelValue.rules
|
||||
: [emptyRule()];
|
||||
emit('update:modelValue', { mode: 'rules', rules });
|
||||
},
|
||||
});
|
||||
|
||||
const rules = computed(() => props.modelValue?.rules ?? []);
|
||||
|
||||
function emptyRule(): DataAccessRule {
|
||||
return {};
|
||||
}
|
||||
|
||||
function patchRule(index: number, patch: Partial<DataAccessRule>) {
|
||||
const next = rules.value.map((r, i) => (i === index ? { ...r, ...patch } : { ...r }));
|
||||
emit('update:modelValue', { mode: 'rules', rules: next });
|
||||
}
|
||||
|
||||
function toggleOwner(index: number, kind: DataAccessOwnerKind) {
|
||||
const current = new Set(rules.value[index]?.owners ?? []);
|
||||
if (current.has(kind)) current.delete(kind);
|
||||
else current.add(kind);
|
||||
const owners = [...current] as DataAccessOwnerKind[];
|
||||
patchRule(index, { owners: owners.length ? owners : undefined });
|
||||
}
|
||||
|
||||
type ListKey =
|
||||
| 'groupIds'
|
||||
| 'sources'
|
||||
| 'moods'
|
||||
| 'stageIds'
|
||||
| 'pipelineIds'
|
||||
| 'boardIds'
|
||||
| 'typeIds';
|
||||
|
||||
function toggleInList(index: number, key: ListKey, value: string) {
|
||||
const rule = rules.value[index] ?? {};
|
||||
const current = new Set((rule[key] as string[] | undefined) ?? []);
|
||||
if (current.has(value)) current.delete(value);
|
||||
else current.add(value);
|
||||
const list = [...current];
|
||||
patchRule(index, { [key]: list.length ? list : undefined });
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
emit('update:modelValue', {
|
||||
mode: 'rules',
|
||||
rules: [...rules.value, emptyRule()],
|
||||
});
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
const next = rules.value.filter((_, i) => i !== index);
|
||||
emit('update:modelValue', {
|
||||
mode: next.length ? 'rules' : 'all',
|
||||
rules: next,
|
||||
});
|
||||
}
|
||||
|
||||
function nameOf(list: IdOption[], id: string) {
|
||||
return list.find((x) => x.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
function ruleSummary(rule: DataAccessRule): string {
|
||||
const parts: string[] = [];
|
||||
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value.replace('فقط ', ''));
|
||||
if (rule.owners?.includes('team')) parts.push('تیم');
|
||||
if (rule.groupIds?.length) {
|
||||
parts.push(
|
||||
`گروه: ${rule.groupIds
|
||||
.slice(0, 2)
|
||||
.map((id) => nameOf(props.groups, id))
|
||||
.join('، ')}${rule.groupIds.length > 2 ? '…' : ''}`,
|
||||
);
|
||||
}
|
||||
if (rule.sources?.length) {
|
||||
const labelMap: Record<string, string> = {
|
||||
...(PERSON_IMPORT_SOURCE_LABELS as Record<string, string>),
|
||||
...Object.fromEntries(noteTypeOptions.map((o) => [o.value, o.label])),
|
||||
...Object.fromEntries(ticketStatusOptions.map((o) => [o.value, o.label])),
|
||||
};
|
||||
const labels = rule.sources.map((s) => labelMap[s] ?? s).slice(0, 2);
|
||||
const prefix =
|
||||
props.section === 'notes'
|
||||
? 'نوع'
|
||||
: props.section === 'tickets'
|
||||
? 'وضعیت'
|
||||
: 'منبع';
|
||||
parts.push(`${prefix}: ${labels.join('، ')}${rule.sources.length > 2 ? '…' : ''}`);
|
||||
}
|
||||
if (rule.moods?.length) {
|
||||
parts.push(
|
||||
`حال: ${rule.moods.map((m) => PERSON_MOOD_LABELS[m as PersonMood] ?? m).join('، ')}`,
|
||||
);
|
||||
}
|
||||
if (rule.pipelineIds?.length) {
|
||||
parts.push(
|
||||
`پایپلاین: ${rule.pipelineIds
|
||||
.slice(0, 2)
|
||||
.map((id) => nameOf(props.pipelines, id))
|
||||
.join('، ')}`,
|
||||
);
|
||||
}
|
||||
if (rule.stageIds?.length) {
|
||||
parts.push(
|
||||
`مرحله: ${rule.stageIds
|
||||
.slice(0, 2)
|
||||
.map((id) => nameOf(props.stages, id))
|
||||
.join('، ')}`,
|
||||
);
|
||||
}
|
||||
if (rule.boardIds?.length) {
|
||||
parts.push(
|
||||
`بورد: ${rule.boardIds
|
||||
.slice(0, 2)
|
||||
.map((id) => nameOf(props.boards, id))
|
||||
.join('، ')}`,
|
||||
);
|
||||
}
|
||||
if (rule.typeIds?.length) {
|
||||
parts.push(
|
||||
`نوع: ${rule.typeIds
|
||||
.slice(0, 2)
|
||||
.map((id) => nameOf(props.activityTypes, id))
|
||||
.join('، ')}`,
|
||||
);
|
||||
}
|
||||
return parts.length ? parts.join(' · ') : 'قانون خالی (اثر ندارد)';
|
||||
}
|
||||
|
||||
const sourceOptions = computed(() =>
|
||||
PERSON_IMPORT_SOURCES.map((s) => ({
|
||||
value: s,
|
||||
label: PERSON_IMPORT_SOURCE_LABELS[s],
|
||||
})),
|
||||
);
|
||||
|
||||
const hasLimits = computed(() => sectionHasDataAccessLimits(props.modelValue));
|
||||
|
||||
const customSource = ref('');
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
customSource.value = '';
|
||||
},
|
||||
);
|
||||
|
||||
function addCustomSource(index: number) {
|
||||
const v = customSource.value.trim();
|
||||
if (!v) return;
|
||||
toggleInList(index, 'sources', v);
|
||||
customSource.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dae" :class="{ compact }">
|
||||
<div class="dae-mode">
|
||||
<label>
|
||||
<input v-model="mode" type="radio" value="all" :disabled="disabled" />
|
||||
همهٔ {{ sectionLabel }} (بدون محدودیت عمقی)
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="mode" type="radio" value="rules" :disabled="disabled" />
|
||||
فقط با قوانین (چند قانون با OR)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="mode === 'rules'">
|
||||
<p class="hint">
|
||||
قوانین با هم «یا» میشوند. داخل هر قانون، گزینههای پرشده با هم «و» هستند.
|
||||
</p>
|
||||
<article v-for="(rule, index) in rules" :key="index" class="dae-rule">
|
||||
<header>
|
||||
<strong>قانون {{ index + 1 }}</strong>
|
||||
<span class="muted">{{ ruleSummary(rule) }}</span>
|
||||
<UiButton v-if="!disabled" variant="secondary" @click="removeRule(index)">
|
||||
حذف
|
||||
</UiButton>
|
||||
</header>
|
||||
|
||||
<fieldset :disabled="disabled">
|
||||
<legend>مسئول / مالک</legend>
|
||||
<label class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.owners?.includes('self')"
|
||||
@change="toggleOwner(index, 'self')"
|
||||
/>
|
||||
{{ ownerSelfLabel }}
|
||||
</label>
|
||||
<label class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.owners?.includes('team')"
|
||||
@change="toggleOwner(index, 'team')"
|
||||
/>
|
||||
تیمهای عضو
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showGroups" :disabled="disabled">
|
||||
<legend>گروهها</legend>
|
||||
<p v-if="!groups.length" class="muted">گروهی تعریف نشده.</p>
|
||||
<label v-for="g in groups" :key="g.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.groupIds?.includes(g.id)"
|
||||
@change="toggleInList(index, 'groupIds', g.id)"
|
||||
/>
|
||||
{{ g.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showImportSources" :disabled="disabled">
|
||||
<legend>منبع</legend>
|
||||
<label v-for="s in sourceOptions" :key="s.value" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.sources?.includes(s.value)"
|
||||
@change="toggleInList(index, 'sources', s.value)"
|
||||
/>
|
||||
{{ s.label }}
|
||||
</label>
|
||||
<div class="custom-source">
|
||||
<input
|
||||
v-model="customSource"
|
||||
type="text"
|
||||
placeholder="منبع سفارشی (sourceRef)…"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.prevent="addCustomSource(index)"
|
||||
/>
|
||||
<UiButton v-if="!disabled" variant="secondary" @click="addCustomSource(index)">
|
||||
افزودن
|
||||
</UiButton>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showLeadSources" :disabled="disabled">
|
||||
<legend>منبع لید</legend>
|
||||
<div class="custom-source">
|
||||
<input
|
||||
v-model="customSource"
|
||||
type="text"
|
||||
placeholder="مثلاً اینستاگرام، سایت…"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.prevent="addCustomSource(index)"
|
||||
/>
|
||||
<UiButton v-if="!disabled" variant="secondary" @click="addCustomSource(index)">
|
||||
افزودن
|
||||
</UiButton>
|
||||
</div>
|
||||
<label v-for="s in rule.sources ?? []" :key="s" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
@change="toggleInList(index, 'sources', s)"
|
||||
/>
|
||||
{{ s }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showNoteTypes" :disabled="disabled">
|
||||
<legend>نوع یادداشت</legend>
|
||||
<label v-for="t in noteTypeOptions" :key="t.value" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.sources?.includes(t.value)"
|
||||
@change="toggleInList(index, 'sources', t.value)"
|
||||
/>
|
||||
{{ t.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showTicketStatuses" :disabled="disabled">
|
||||
<legend>وضعیت تیکت</legend>
|
||||
<label v-for="t in ticketStatusOptions" :key="t.value" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.sources?.includes(t.value)"
|
||||
@change="toggleInList(index, 'sources', t.value)"
|
||||
/>
|
||||
{{ t.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showMoods" :disabled="disabled">
|
||||
<legend>حال مشتری</legend>
|
||||
<label v-for="m in PERSON_MOODS" :key="m" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.moods?.includes(m)"
|
||||
@change="toggleInList(index, 'moods', m)"
|
||||
/>
|
||||
{{ PERSON_MOOD_LABELS[m] }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showPipelines" :disabled="disabled">
|
||||
<legend>پایپلاین</legend>
|
||||
<p v-if="!pipelines.length" class="muted">پایپلاینی نیست.</p>
|
||||
<label v-for="p in pipelines" :key="p.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.pipelineIds?.includes(p.id)"
|
||||
@change="toggleInList(index, 'pipelineIds', p.id)"
|
||||
/>
|
||||
{{ p.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showStages" :disabled="disabled">
|
||||
<legend>مرحله</legend>
|
||||
<p v-if="!stages.length" class="muted">مرحلهای نیست.</p>
|
||||
<label v-for="s in stages" :key="s.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.stageIds?.includes(s.id)"
|
||||
@change="toggleInList(index, 'stageIds', s.id)"
|
||||
/>
|
||||
{{ s.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showBoards" :disabled="disabled">
|
||||
<legend>بورد فعالیت</legend>
|
||||
<p v-if="!boards.length" class="muted">بوردی نیست.</p>
|
||||
<label v-for="b in boards" :key="b.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.boardIds?.includes(b.id)"
|
||||
@change="toggleInList(index, 'boardIds', b.id)"
|
||||
/>
|
||||
{{ b.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="showTypes" :disabled="disabled">
|
||||
<legend>{{ typesLegend }}</legend>
|
||||
<p v-if="!activityTypes.length" class="muted">موردی تعریف نشده.</p>
|
||||
<label v-for="t in activityTypes" :key="t.id" class="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rule.typeIds?.includes(t.id)"
|
||||
@change="toggleInList(index, 'typeIds', t.id)"
|
||||
/>
|
||||
{{ t.name }}
|
||||
</label>
|
||||
</fieldset>
|
||||
</article>
|
||||
|
||||
<UiButton v-if="!disabled" variant="secondary" @click="addRule">+ قانون جدید</UiButton>
|
||||
</template>
|
||||
|
||||
<p v-else-if="!hasLimits" class="hint">
|
||||
بدون محدودیت عمقی — همهٔ {{ sectionLabel }} قابل مشاهدهاند.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dae {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.dae-mode {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.dae-mode label,
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
margin-inline-end: 0.55rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.hint,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
.dae-rule {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.dae-rule header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
fieldset {
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
margin: 0;
|
||||
}
|
||||
legend {
|
||||
padding-inline: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.custom-source {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.custom-source input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.compact .dae-rule {
|
||||
padding: 0.55rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type {
|
||||
DataAccessPolicy,
|
||||
DataAccessSection,
|
||||
RolePermissionKey,
|
||||
SectionDataAccess,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
DATA_ACCESS_SECTIONS,
|
||||
emptySectionDataAccess,
|
||||
parseDataAccessPolicy,
|
||||
} from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { useCustomerGroups } from '@/lib/customer-groups';
|
||||
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DataAccessPolicy;
|
||||
/** فقط بخشهایی که این مجوزها را دارند نشان بده؛ خالی = همه */
|
||||
enabledPermissions?: RolePermissionKey[];
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [DataAccessPolicy];
|
||||
}>();
|
||||
|
||||
/** بخشهایی که به RolePermissionKey وصلاند؛ بقیه (روتین/پروژه/تیکت) همیشه دیده میشوند */
|
||||
const SECTION_PERM: Partial<Record<DataAccessSection, RolePermissionKey>> = {
|
||||
customers: 'customers',
|
||||
leads: 'leads',
|
||||
sales: 'sales',
|
||||
activities: 'activities',
|
||||
notes: 'notes',
|
||||
};
|
||||
|
||||
const visibleSections = computed(() => {
|
||||
const perms = props.enabledPermissions;
|
||||
if (!perms?.length) return DATA_ACCESS_SECTIONS;
|
||||
return DATA_ACCESS_SECTIONS.filter((s) => {
|
||||
const key = SECTION_PERM[s];
|
||||
if (!key) return true;
|
||||
return perms.includes(key);
|
||||
});
|
||||
});
|
||||
|
||||
const active = ref<DataAccessSection>('customers');
|
||||
|
||||
watch(
|
||||
visibleSections,
|
||||
(list) => {
|
||||
if (!list.includes(active.value) && list[0]) active.value = list[0];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const policy = computed(() => parseDataAccessPolicy(props.modelValue));
|
||||
|
||||
function sectionValue(key: DataAccessSection): SectionDataAccess {
|
||||
return policy.value[key] ?? emptySectionDataAccess();
|
||||
}
|
||||
|
||||
function setSection(key: DataAccessSection, next: SectionDataAccess) {
|
||||
emit('update:modelValue', {
|
||||
...policy.value,
|
||||
[key]: next,
|
||||
});
|
||||
}
|
||||
|
||||
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
|
||||
const leadStages = ref<IdOption[]>([]);
|
||||
const pipelines = ref<IdOption[]>([]);
|
||||
const dealStages = ref<IdOption[]>([]);
|
||||
const activityBoards = ref<IdOption[]>([]);
|
||||
const activityStages = ref<IdOption[]>([]);
|
||||
const activityTypes = ref<IdOption[]>([]);
|
||||
const projectBoards = ref<IdOption[]>([]);
|
||||
const projectStages = ref<IdOption[]>([]);
|
||||
const routineCategories = ref<IdOption[]>([]);
|
||||
|
||||
async function loadOptions() {
|
||||
await loadCustomerGroups().catch(() => undefined);
|
||||
try {
|
||||
const { data } = await api.get<Array<{ id: string; name: string }>>('/leads/stages');
|
||||
leadStages.value = data.map((s) => ({ id: s.id, name: s.name }));
|
||||
} catch {
|
||||
leadStages.value = [];
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
stages?: Array<{ id: string; name: string }>;
|
||||
}>
|
||||
>('/pipelines');
|
||||
pipelines.value = data.map((p) => ({ id: p.id, name: p.name }));
|
||||
dealStages.value = data.flatMap((p) =>
|
||||
(p.stages ?? []).map((s) => ({ id: s.id, name: `${p.name} · ${s.name}` })),
|
||||
);
|
||||
} catch {
|
||||
pipelines.value = [];
|
||||
dealStages.value = [];
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<Array<{ id: string; name: string }>>('/activities/boards');
|
||||
activityBoards.value = data.map((b) => ({ id: b.id, name: b.name }));
|
||||
} catch {
|
||||
activityBoards.value = [];
|
||||
}
|
||||
try {
|
||||
const stageLists = await Promise.all(
|
||||
activityBoards.value.map(async (b) => {
|
||||
const { data } = await api.get<Array<{ id: string; name: string }>>(
|
||||
'/activities/stages',
|
||||
{ params: { boardId: b.id } },
|
||||
);
|
||||
return data.map((s) => ({
|
||||
id: s.id,
|
||||
name: `${b.name} · ${s.name}`,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
activityStages.value = stageLists.flat();
|
||||
} catch {
|
||||
activityStages.value = [];
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<Array<{ id: string; name: string }>>('/activities/types');
|
||||
activityTypes.value = data.map((t) => ({ id: t.id, name: t.name }));
|
||||
} catch {
|
||||
activityTypes.value = [];
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
stages?: Array<{ id: string; name: string }>;
|
||||
}>
|
||||
>('/project-boards');
|
||||
projectBoards.value = data.map((b) => ({ id: b.id, name: b.name }));
|
||||
projectStages.value = data.flatMap((b) =>
|
||||
(b.stages ?? []).map((s) => ({ id: s.id, name: `${b.name} · ${s.name}` })),
|
||||
);
|
||||
} catch {
|
||||
projectBoards.value = [];
|
||||
projectStages.value = [];
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<Array<{ id: string; name: string }>>('/routines/categories');
|
||||
routineCategories.value = data.map((c) => ({ id: c.id, name: c.name }));
|
||||
} catch {
|
||||
routineCategories.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadOptions();
|
||||
});
|
||||
|
||||
const stagesForActive = computed(() => {
|
||||
if (active.value === 'leads') return leadStages.value;
|
||||
if (active.value === 'sales') return dealStages.value;
|
||||
if (active.value === 'activities') return activityStages.value;
|
||||
if (active.value === 'projects') return projectStages.value;
|
||||
return [];
|
||||
});
|
||||
|
||||
const boardsForActive = computed(() => {
|
||||
if (active.value === 'activities') return activityBoards.value;
|
||||
if (active.value === 'projects') return projectBoards.value;
|
||||
return [];
|
||||
});
|
||||
|
||||
const typesForActive = computed(() => {
|
||||
if (active.value === 'activities') return activityTypes.value;
|
||||
if (active.value === 'routines') return routineCategories.value;
|
||||
return [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dap">
|
||||
<p v-if="!visibleSections.length" class="muted">
|
||||
برای تنظیم محدوده، ابتدا مجوز بخش مربوط را روشن کنید.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
v-for="s in visibleSections"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: active === s }"
|
||||
role="tab"
|
||||
:aria-selected="active === s"
|
||||
@click="active = s"
|
||||
>
|
||||
{{ DATA_ACCESS_SECTION_LABELS[s] }}
|
||||
</button>
|
||||
</div>
|
||||
<DataAccessEditor
|
||||
:key="active"
|
||||
:section="active"
|
||||
:model-value="sectionValue(active)"
|
||||
:groups="customerGroups"
|
||||
:stages="stagesForActive"
|
||||
:pipelines="pipelines"
|
||||
:boards="boardsForActive"
|
||||
:activity-types="typesForActive"
|
||||
:disabled="disabled"
|
||||
:compact="compact"
|
||||
@update:model-value="setSection(active, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dap {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.tab {
|
||||
font: inherit;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
cursor: pointer;
|
||||
color: #475569;
|
||||
}
|
||||
.tab.on {
|
||||
border-color: #0f766e;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -5,21 +5,21 @@ import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import type {
|
||||
DataAccessPolicy,
|
||||
OrganizationDto,
|
||||
OrgUserDto,
|
||||
TeamDto,
|
||||
TeamMemberRole,
|
||||
UpdatePriorArchiveCutoffRequest,
|
||||
UpdateUserVisibilityRequest,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
DATA_SCOPE_LABELS,
|
||||
DATA_SCOPES,
|
||||
DATA_VISIBILITY_LABELS,
|
||||
DATA_VISIBILITIES,
|
||||
type DataVisibility,
|
||||
DATA_ACCESS_SECTION_LABELS,
|
||||
DATA_ACCESS_SECTIONS,
|
||||
emptySectionDataAccess,
|
||||
sectionHasDataAccessLimits,
|
||||
} from '@mento/shared';
|
||||
import { UiButton, UiEmptyState, UiField, UiInput } from '@/components/ui';
|
||||
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
|
||||
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -96,7 +96,6 @@ async function setPriorArchive(userId: string, enabled: boolean) {
|
||||
function cutoffPayload(value: string): UpdatePriorArchiveCutoffRequest {
|
||||
const parsed = parseLocalInput(value);
|
||||
if (!parsed) return { cutoffAt: null };
|
||||
// پایان همان روز محلی تا ایمپورت همان روز هم قبل از تاریخ قطع شمرده شود
|
||||
parsed.setHours(23, 59, 59, 999);
|
||||
return { cutoffAt: parsed.toISOString() };
|
||||
}
|
||||
@@ -280,25 +279,75 @@ async function removeTeam(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const expandedVisibility = ref<string | null>(null);
|
||||
const expandedAccess = ref<string | null>(null);
|
||||
const draftAccess = ref<DataAccessPolicy>({});
|
||||
const accessMode = ref<'inherit' | 'custom'>('inherit');
|
||||
const accessSaving = ref(false);
|
||||
|
||||
function scopeOverrides(u: OrgUserDto): number {
|
||||
return Object.keys(u.dataVisibilityScopes ?? {}).length;
|
||||
function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
|
||||
const src = policy ?? {};
|
||||
const out: DataAccessPolicy = {};
|
||||
for (const [key, section] of Object.entries(src)) {
|
||||
if (!section) continue;
|
||||
out[key as keyof DataAccessPolicy] = {
|
||||
mode: section.mode,
|
||||
rules: (section.rules ?? []).map((r) => ({ ...r })),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function setVisibility(userId: string, payload: UpdateUserVisibilityRequest) {
|
||||
function accessSummary(u: OrgUserDto): string {
|
||||
const override = u.dataAccessOverride ?? {};
|
||||
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
|
||||
const effective = u.effectiveDataAccess ?? {};
|
||||
const limited = DATA_ACCESS_SECTIONS.filter((s) =>
|
||||
sectionHasDataAccessLimits(effective[s] ?? emptySectionDataAccess()),
|
||||
);
|
||||
if (!hasOverride && !limited.length) return 'مثل نقش · بدون محدودیت';
|
||||
if (!hasOverride) {
|
||||
return `مثل نقش · ${toFaDigits(limited.length)} بخش محدود`;
|
||||
}
|
||||
const labels = limited.map((s) => DATA_ACCESS_SECTION_LABELS[s]).slice(0, 2);
|
||||
return `سفارشی · ${labels.join('، ') || 'بدون محدودیت'}${limited.length > 2 ? '…' : ''}`;
|
||||
}
|
||||
|
||||
function openAccessEditor(u: OrgUserDto) {
|
||||
if (expandedAccess.value === u.id) {
|
||||
expandedAccess.value = null;
|
||||
return;
|
||||
}
|
||||
const override = u.dataAccessOverride ?? {};
|
||||
const hasOverride = DATA_ACCESS_SECTIONS.some((s) => Boolean(override[s]));
|
||||
accessMode.value = hasOverride ? 'custom' : 'inherit';
|
||||
draftAccess.value = hasOverride
|
||||
? clonePolicy(override)
|
||||
: clonePolicy(u.effectiveDataAccess);
|
||||
expandedAccess.value = u.id;
|
||||
}
|
||||
|
||||
async function saveUserAccess(userId: string) {
|
||||
accessSaving.value = true;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.patch(`/users/${userId}/visibility`, payload);
|
||||
ok.value = 'سطح دسترسی به پروندهها بهروز شد.';
|
||||
const payload =
|
||||
accessMode.value === 'inherit'
|
||||
? { dataAccessOverride: null }
|
||||
: { dataAccessOverride: draftAccess.value };
|
||||
await api.patch(`/users/${userId}/data-access`, payload);
|
||||
ok.value = 'محدوده داده بهروز شد.';
|
||||
expandedAccess.value = null;
|
||||
emit('refreshUsers');
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? String(
|
||||
(e.response?.data as { message?: string })?.message ??
|
||||
'تغییر سطح دسترسی به پروندهها ناموفق بود.',
|
||||
'ذخیره محدوده داده ناموفق بود.',
|
||||
)
|
||||
: 'تغییر سطح دسترسی به پروندهها ناموفق بود.';
|
||||
: 'ذخیره محدوده داده ناموفق بود.';
|
||||
} finally {
|
||||
accessSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,70 +497,45 @@ onMounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>سطح دسترسی به پروندهها</h3>
|
||||
<h3>محدوده داده (مشتریان / لید / معامله / فعالیت)</h3>
|
||||
<p class="hint">
|
||||
مالک و مدیر همیشه همه پروندهها را میبینند. برای بقیه یک سطح پیشفرض (فقط خود، تیمهای عضو،
|
||||
یا کل سازمان) تعیین کنید و در صورت نیاز با «تنظیم تفکیکی» برای هر بخش سطح جداگانه بگذارید؛
|
||||
مثلاً همه مشتریان را ببیند ولی معاملات فقط مال خودش.
|
||||
مالک و مدیر همیشه همه را میبینند. پیشفرض از نقش میآید؛ برای کاربر خاص میتوانید override
|
||||
بگذارید.
|
||||
</p>
|
||||
<UiEmptyState
|
||||
v-if="!editableUsers.length"
|
||||
title="کاربر قابل تنظیم نیست"
|
||||
description="فقط نقشهای فروش و پشتیبانی سطح دسترسی جدا به پروندهها دارند."
|
||||
description="فقط نقشهای فروش و پشتیبانی محدوده داده جدا دارند."
|
||||
/>
|
||||
<div v-else class="vis-table">
|
||||
<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>
|
||||
<small class="vis-badge">{{ accessSummary(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 v-if="canEdit" variant="secondary" @click="openAccessEditor(u)">
|
||||
{{ expandedAccess === 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>
|
||||
<div v-if="expandedAccess === u.id" class="vis-scopes">
|
||||
<label class="chip-row">
|
||||
<input v-model="accessMode" type="radio" value="inherit" />
|
||||
مثل نقش (بدون override)
|
||||
</label>
|
||||
<label class="chip-row">
|
||||
<input v-model="accessMode" type="radio" value="custom" />
|
||||
سفارشی برای این کاربر
|
||||
</label>
|
||||
<DataAccessPolicyPanel
|
||||
v-if="accessMode === 'custom'"
|
||||
v-model="draftAccess"
|
||||
compact
|
||||
/>
|
||||
<div class="actions">
|
||||
<UiButton :loading="accessSaving" @click="saveUserAccess(u.id)">ذخیره</UiButton>
|
||||
<UiButton variant="secondary" @click="expandedAccess = null">انصراف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -642,7 +666,7 @@ onMounted(() => {
|
||||
}
|
||||
.vis-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 240px auto;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0;
|
||||
@@ -657,21 +681,16 @@ onMounted(() => {
|
||||
}
|
||||
.vis-scopes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.5rem 1rem;
|
||||
gap: 0.55rem;
|
||||
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;
|
||||
.chip-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
select {
|
||||
font: inherit;
|
||||
|
||||
@@ -12,6 +12,7 @@ import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiDropdownMenu, UiModal } from '@/components/ui';
|
||||
import {
|
||||
ROLE_PERMISSION_ITEMS,
|
||||
type DataAccessPolicy,
|
||||
type InviteUserRequest,
|
||||
type OrganizationDto,
|
||||
type OrgModuleKey,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import TeamsSettingsPanel from '@/components/TeamsSettingsPanel.vue';
|
||||
import DataAccessPolicyPanel from '@/components/DataAccessPolicyPanel.vue';
|
||||
|
||||
type TabKey = 'users' | 'roles' | 'access' | 'teams';
|
||||
type RoleColor = 'teal' | 'violet' | 'rose' | 'sky' | 'amber' | 'slate' | 'pink';
|
||||
@@ -89,6 +91,8 @@ const orgName = ref('');
|
||||
const businessField = ref('');
|
||||
const orgSaving = ref(false);
|
||||
const accessSaving = ref(false);
|
||||
const roleFormDataAccess = ref<DataAccessPolicy>({});
|
||||
const focusDataAccess = ref<DataAccessPolicy>({});
|
||||
|
||||
const tab = ref<TabKey>('users');
|
||||
const query = ref('');
|
||||
@@ -583,6 +587,7 @@ function openCreateRole(parentId?: string | null) {
|
||||
roleFormPerms.value = parent
|
||||
? [...parent.permissions]
|
||||
: visibleRolePermissions.value.map((p) => p.key);
|
||||
roleFormDataAccess.value = clonePolicy(parent?.dataAccessPolicy);
|
||||
roleFormOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -602,6 +607,7 @@ function openEditRole() {
|
||||
: 'teal') as RoleColor;
|
||||
roleFormParentId.value = role.systemKey ? '' : (role.parentId ?? '');
|
||||
roleFormPerms.value = [...role.permissions];
|
||||
roleFormDataAccess.value = clonePolicy(role.dataAccessPolicy);
|
||||
roleFormOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -631,12 +637,14 @@ async function submitRoleForm() {
|
||||
description: string;
|
||||
color: RoleColor;
|
||||
permissions: RolePermissionKey[];
|
||||
dataAccessPolicy?: OrgRoleDto['dataAccessPolicy'];
|
||||
parentId?: string | null;
|
||||
} = {
|
||||
name: roleFormName.value.trim(),
|
||||
description: roleFormDesc.value.trim(),
|
||||
color: roleFormColor.value,
|
||||
permissions: roleFormPerms.value,
|
||||
dataAccessPolicy: roleFormDataAccess.value,
|
||||
};
|
||||
if (!editing?.systemKey) {
|
||||
body.parentId = roleFormParentId.value || null;
|
||||
@@ -693,6 +701,40 @@ async function toggleAccess(role: OrgRoleDto, key: RolePermissionKey) {
|
||||
}
|
||||
}
|
||||
|
||||
function clonePolicy(policy?: DataAccessPolicy | null): DataAccessPolicy {
|
||||
const src = policy ?? {};
|
||||
const out: DataAccessPolicy = {};
|
||||
for (const [key, section] of Object.entries(src)) {
|
||||
if (!section) continue;
|
||||
out[key as keyof DataAccessPolicy] = {
|
||||
mode: section.mode,
|
||||
rules: (section.rules ?? []).map((r) => ({ ...r })),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncFocusDataAccess() {
|
||||
focusDataAccess.value = clonePolicy(accessFocusRole.value?.dataAccessPolicy);
|
||||
}
|
||||
|
||||
async function saveFocusDataAccess() {
|
||||
const role = accessFocusRole.value;
|
||||
if (!role || !canEditRoles.value) return;
|
||||
accessSaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/org-roles/${role.id}`, { dataAccessPolicy: focusDataAccess.value });
|
||||
ok.value = `محدوده داده برای نقش «${role.name}» ذخیره شد.`;
|
||||
await load();
|
||||
syncFocusDataAccess();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره محدوده داده ناموفق بود');
|
||||
} finally {
|
||||
accessSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDocClick() {
|
||||
menuUserId.value = null;
|
||||
}
|
||||
@@ -728,10 +770,15 @@ watch(
|
||||
if (!list.some((r) => r.id === accessFocusId.value)) {
|
||||
accessFocusId.value = list[0].id;
|
||||
}
|
||||
syncFocusDataAccess();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(accessFocusId, () => {
|
||||
syncFocusDataAccess();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
syncViewport();
|
||||
window.addEventListener('resize', syncViewport);
|
||||
@@ -862,6 +909,29 @@ onUnmounted(() => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="accessFocusRole" class="data-access-block">
|
||||
<div class="matrix-head">
|
||||
<h3>محدوده داده — {{ accessFocusRole.name }}</h3>
|
||||
<button
|
||||
v-if="canEditRoles"
|
||||
type="button"
|
||||
class="btn primary"
|
||||
:disabled="accessSaving"
|
||||
@click="saveFocusDataAccess"
|
||||
>
|
||||
{{ accessSaving ? 'در حال ذخیره…' : 'ذخیره محدوده' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">
|
||||
زیرمجموعهٔ مجوز هر بخش: اگر تنظیم نشود همه را میبیند؛ قوانین با هم «یا» میشوند.
|
||||
</p>
|
||||
<DataAccessPolicyPanel
|
||||
v-model="focusDataAccess"
|
||||
:enabled-permissions="accessFocusRole.permissions"
|
||||
:disabled="!canEditRoles"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1362,6 +1432,14 @@ onUnmounted(() => {
|
||||
{{ perm.label }}
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="perm-set">
|
||||
<legend>محدوده داده</legend>
|
||||
<DataAccessPolicyPanel
|
||||
v-model="roleFormDataAccess"
|
||||
:enabled-permissions="roleFormPerms"
|
||||
compact
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button type="button" class="btn ghost" @click="roleFormOpen = false">انصراف</button>
|
||||
@@ -1390,6 +1468,20 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-access-block {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eef2f7;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.data-access-block .hint {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user