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

420 lines
13 KiB
Vue
Raw Normal View History

<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>