420 lines
13 KiB
Vue
420 lines
13 KiB
Vue
<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>
|