Files
CRM_Montzar/apps/web/src/components/DataAccessPolicyPanel.vue
T

420 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import type {
DataAccessPolicy,
DataAccessSection,
FinancialAccountDto,
PersonImportSourceStatDto,
RolePermissionKey,
SectionDataAccess,
UserTreasuryAccess,
} from '@mento/shared';
import {
DATA_ACCESS_SECTION_LABELS,
DATA_ACCESS_SECTIONS,
ROLE_PERMISSION_ITEMS,
emptySectionDataAccess,
} from '@mento/shared';
import { api } from '@/lib/api';
import DataAccessEditor, { type IdOption } from '@/components/DataAccessEditor.vue';
const props = defineProps<{
modelValue: DataAccessPolicy;
/** فقط بخش‌هایی که این مجوزها را دارند نشان بده؛ خالی = همه */
enabledPermissions?: RolePermissionKey[];
disabled?: boolean;
/** تب‌های عمق داده قفل شوند؛ تب مدیریت مالی جدا می‌ماند */
dataLocked?: boolean;
showTreasury?: boolean;
treasuryAccess?: UserTreasuryAccess | null;
compact?: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [DataAccessPolicy];
'update:treasuryAccess': [UserTreasuryAccess | null];
}>();
/** بخش‌هایی که به 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 treasurySections = ROLE_PERMISSION_ITEMS.filter((item) => item.key.startsWith('treasury_'));
const active = ref<DataAccessSection | 'treasury'>('customers');
watch(
visibleSections,
(list) => {
if (active.value !== 'treasury' && !list.includes(active.value) && list[0]) active.value = list[0];
},
{ immediate: true },
);
/** بدون parse سخت‌گیرانه تا فیلتر در حال ویرایش پاک نشود */
function sectionValue(key: DataAccessSection): SectionDataAccess {
const raw = props.modelValue?.[key];
if (raw && typeof raw === 'object') {
return {
mode: raw.mode === 'rules' ? 'rules' : 'all',
rules: Array.isArray(raw.rules) ? raw.rules.map((r) => ({ ...r })) : [],
};
}
return emptySectionDataAccess();
}
function setSection(key: DataAccessSection, next: SectionDataAccess) {
emit('update:modelValue', {
...props.modelValue,
[key]: next,
});
}
const importSources = ref<Array<{ value: string; label: string }>>([]);
const importSourcesReady = ref(false);
const importSourcesFailed = ref(false);
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[]>([]);
const treasuryAccounts = ref<FinancialAccountDto[]>([]);
const treasuryAccountQuery = ref('');
const filteredTreasuryAccounts = computed(() => {
const query = treasuryAccountQuery.value.trim();
if (!query) return treasuryAccounts.value;
return treasuryAccounts.value.filter((account) => account.name.includes(query));
});
function emptyTreasuryAccess(): UserTreasuryAccess {
return { enabled: true, sections: [], ownAccounts: false, accountIds: [] };
}
function patchTreasury(next: UserTreasuryAccess | null) {
emit('update:treasuryAccess', next);
}
function toggleTreasurySection(key: RolePermissionKey) {
const current = props.treasuryAccess ?? emptyTreasuryAccess();
const sections = current.sections.includes(key)
? current.sections.filter((item) => item !== key)
: [...current.sections, key];
patchTreasury({ ...current, enabled: true, sections });
}
function toggleTreasuryAccount(id: string) {
const current = props.treasuryAccess ?? emptyTreasuryAccess();
const accountIds = current.accountIds.includes(id)
? current.accountIds.filter((item) => item !== id)
: [...current.accountIds, id];
patchTreasury({ ...current, enabled: true, accountIds });
}
async function loadTreasuryAccounts() {
if (!props.showTreasury) return;
try {
const { data } = await api.get<FinancialAccountDto[]>('/treasury/accounts');
treasuryAccounts.value = data;
} catch {
treasuryAccounts.value = [];
}
}
async function loadOptions() {
try {
const { data } = await api.get<PersonImportSourceStatDto[]>('/people/source-stats');
importSources.value = data.map((row) => ({ value: row.source, label: row.label }));
importSourcesReady.value = true;
} catch {
importSourcesFailed.value = true;
}
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();
void loadTreasuryAccounts();
});
const editorSection = computed<DataAccessSection>(() =>
active.value === 'treasury' ? 'customers' : active.value,
);
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>
<button
v-if="showTreasury"
type="button"
class="tab"
:class="{ on: active === 'treasury' }"
role="tab"
:aria-selected="active === 'treasury'"
@click="active = 'treasury'"
>
مدیریت مالی
</button>
</div>
<div v-if="active === 'treasury'" class="treasury">
<p class="muted">
این برگه فقط برای همین کاربر است. «مثل نقش» یعنی همان دسترسی مالی نقش بماند.
در حالت سفارشی، اگر هیچ بخش ریزی تیک نخورد همه بخش‌ها به‌جز حقوق باز می‌مانند و حقوق جداست.
اگر هیچ حسابی انتخاب نشود همه حساب‌ها باز است.
</p>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess == null"
:disabled="disabled"
@change="patchTreasury(null)"
/>
مثل نقش
</label>
<label class="choice">
<input
type="radio"
name="user-treasury"
:checked="treasuryAccess != null"
:disabled="disabled"
@change="patchTreasury(treasuryAccess ?? emptyTreasuryAccess())"
/>
سفارشی برای این کاربر
</label>
<template v-if="treasuryAccess">
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.enabled"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, enabled: !treasuryAccess.enabled })"
/>
دسترسی به مدیریت مالی
</label>
<template v-if="treasuryAccess.enabled">
<label v-for="perm in treasurySections" :key="perm.key" class="choice sub">
<input
type="checkbox"
:checked="treasuryAccess.sections.includes(perm.key)"
:disabled="disabled"
@change="toggleTreasurySection(perm.key)"
/>
{{ perm.label }}
</label>
<label class="choice">
<input
type="checkbox"
:checked="treasuryAccess.ownAccounts"
:disabled="disabled"
@change="patchTreasury({ ...treasuryAccess, ownAccounts: !treasuryAccess.ownAccounts })"
/>
حساب‌هایی که خودش مسئول آن‌هاست
</label>
<p class="muted">حساب‌های دیگر</p>
<input v-model="treasuryAccountQuery" placeholder="جستجوی حساب" :disabled="disabled" />
<label v-for="account in filteredTreasuryAccounts" :key="account.id" class="choice">
<input
type="checkbox"
:checked="treasuryAccess.accountIds.includes(account.id)"
:disabled="disabled"
@change="toggleTreasuryAccount(account.id)"
/>
{{ account.name }}
<template v-if="account.responsibleName"> — مسئول: {{ account.responsibleName }}</template>
</label>
<p v-if="!treasuryAccounts.length" class="muted">حسابی برای انتخاب نیست.</p>
</template>
</template>
</div>
<DataAccessEditor
v-else
:key="active"
:section="editorSection"
:model-value="sectionValue(editorSection)"
:stages="stagesForActive"
:pipelines="pipelines"
:boards="boardsForActive"
:activity-types="typesForActive"
:import-sources="importSources"
:import-sources-ready="importSourcesReady"
:import-sources-failed="importSourcesFailed"
:disabled="disabled || dataLocked"
:compact="compact"
@update:model-value="setSection(editorSection, $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;
}
.treasury {
display: grid;
gap: 0.45rem;
}
.choice {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4rem;
align-items: center;
}
.choice.sub {
padding-inline-start: 1.2rem;
}
</style>