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

530 lines
16 KiB
Vue
Raw Normal View History

<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 boardsLegend = computed(() =>
props.section === 'projects' ? 'بورد پروژه' : 'بورد فعالیت',
);
/** چند قانون قدیمی → یک فیلتر (اتحاد گزینه‌ها) برای ویرایش ساده */
function collapseRules(rules: DataAccessRule[]): DataAccessRule {
if (!rules.length) return {};
if (rules.length === 1) return { ...rules[0]! };
const owners = new Set<DataAccessOwnerKind>();
const groupIds = new Set<string>();
const sources = new Set<string>();
const moods = new Set<PersonMood>();
const stageIds = new Set<string>();
const pipelineIds = new Set<string>();
const boardIds = new Set<string>();
const typeIds = new Set<string>();
for (const r of rules) {
for (const o of r.owners ?? []) owners.add(o);
for (const id of r.groupIds ?? []) groupIds.add(id);
for (const s of r.sources ?? []) sources.add(s);
for (const m of r.moods ?? []) moods.add(m);
for (const id of r.stageIds ?? []) stageIds.add(id);
for (const id of r.pipelineIds ?? []) pipelineIds.add(id);
for (const id of r.boardIds ?? []) boardIds.add(id);
for (const id of r.typeIds ?? []) typeIds.add(id);
}
const out: DataAccessRule = {};
if (owners.size) out.owners = [...owners];
if (groupIds.size) out.groupIds = [...groupIds];
if (sources.size) out.sources = [...sources];
if (moods.size) out.moods = [...moods];
if (stageIds.size) out.stageIds = [...stageIds];
if (pipelineIds.size) out.pipelineIds = [...pipelineIds];
if (boardIds.size) out.boardIds = [...boardIds];
if (typeIds.size) out.typeIds = [...typeIds];
return out;
}
function ruleHasAnyFilter(rule: DataAccessRule): boolean {
return Boolean(
rule.owners?.length ||
rule.groupIds?.length ||
rule.sources?.length ||
rule.moods?.length ||
rule.stageIds?.length ||
rule.pipelineIds?.length ||
rule.boardIds?.length ||
rule.typeIds?.length,
);
}
const hadMultipleRules = computed(
() => (props.modelValue?.rules?.length ?? 0) > 1,
);
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 collapsed = collapseRules(props.modelValue?.rules ?? []);
emit('update:modelValue', {
mode: 'rules',
rules: [ruleHasAnyFilter(collapsed) ? collapsed : {}],
});
},
});
const filter = computed(() => collapseRules(props.modelValue?.rules ?? []));
function emitFilter(next: DataAccessRule) {
const cleaned: DataAccessRule = {};
if (next.owners?.length) cleaned.owners = [...next.owners];
if (next.groupIds?.length) cleaned.groupIds = [...next.groupIds];
if (next.sources?.length) cleaned.sources = [...next.sources];
if (next.moods?.length) cleaned.moods = [...next.moods];
if (next.stageIds?.length) cleaned.stageIds = [...next.stageIds];
if (next.pipelineIds?.length) cleaned.pipelineIds = [...next.pipelineIds];
if (next.boardIds?.length) cleaned.boardIds = [...next.boardIds];
if (next.typeIds?.length) cleaned.typeIds = [...next.typeIds];
emit('update:modelValue', { mode: 'rules', rules: [cleaned] });
}
function patchFilter(patch: Partial<DataAccessRule>) {
emitFilter({ ...filter.value, ...patch });
}
function toggleOwner(kind: DataAccessOwnerKind) {
const current = new Set(filter.value.owners ?? []);
if (current.has(kind)) current.delete(kind);
else current.add(kind);
const owners = [...current] as DataAccessOwnerKind[];
patchFilter({ owners: owners.length ? owners : undefined });
}
type ListKey =
| 'groupIds'
| 'sources'
| 'moods'
| 'stageIds'
| 'pipelineIds'
| 'boardIds'
| 'typeIds';
function toggleInList(key: ListKey, value: string) {
const current = new Set((filter.value[key] as string[] | undefined) ?? []);
if (current.has(value)) current.delete(value);
else current.add(value);
const list = [...current];
patchFilter({ [key]: list.length ? list : undefined });
}
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() {
const v = customSource.value.trim();
if (!v) return;
toggleInList('sources', v);
customSource.value = '';
}
const filterSummary = computed(() => {
const rule = filter.value;
const parts: string[] = [];
if (rule.owners?.includes('self')) parts.push(ownerSelfLabel.value);
if (rule.owners?.includes('team')) parts.push('تیم‌های عضو');
if (rule.groupIds?.length) parts.push(`${rule.groupIds.length} گروه`);
if (rule.sources?.length) parts.push(`${rule.sources.length} مورد منبع/نوع`);
if (rule.moods?.length) parts.push(`${rule.moods.length} حال`);
if (rule.pipelineIds?.length) parts.push(`${rule.pipelineIds.length} پایپ‌لاین`);
if (rule.stageIds?.length) parts.push(`${rule.stageIds.length} مرحله`);
if (rule.boardIds?.length) parts.push(`${rule.boardIds.length} بورد`);
if (rule.typeIds?.length) parts.push(`${rule.typeIds.length} نوع/دسته`);
return parts.length ? parts.join(' · ') : 'هنوز فیلتری انتخاب نشده';
});
</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" />
محدود با فیلتر
</label>
</div>
<template v-if="mode === 'rules'">
<p class="hint">
فقط رکوردهایی دیده می‌شوند که با
<strong>همهٔ فیلترهای پرشده</strong>
جور باشند. در هر ردیف، چند تیک یعنی هر کدام کافی است.
</p>
<p v-if="hadMultipleRules" class="hint warn">
تنظیم قبلی چندتکه بود؛ اینجا در یک فیلتر ادغام شده. با ذخیره به همین شکل ساده ثبت می‌شود.
</p>
<p class="summary">{{ filterSummary }}</p>
<fieldset :disabled="disabled">
<legend>مالک / مسئول</legend>
<p class="muted">خالی = بدون محدودیت مالک</p>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('self')"
@change="toggleOwner('self')"
/>
{{ ownerSelfLabel }}
</label>
<label class="chip">
<input
type="checkbox"
:checked="filter.owners?.includes('team')"
@change="toggleOwner('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="filter.groupIds?.includes(g.id)"
@change="toggleInList('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="filter.sources?.includes(s.value)"
@change="toggleInList('sources', s.value)"
/>
{{ s.label }}
</label>
<div class="custom-source">
<input
v-model="customSource"
type="text"
placeholder="منبع سفارشی…"
:disabled="disabled"
@keydown.enter.prevent="addCustomSource"
/>
<UiButton
v-if="!disabled"
type="button"
variant="secondary"
@click.prevent="addCustomSource"
>
افزودن
</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"
/>
<UiButton
v-if="!disabled"
type="button"
variant="secondary"
@click.prevent="addCustomSource"
>
افزودن
</UiButton>
</div>
<label v-for="s in filter.sources ?? []" :key="s" class="chip">
<input type="checkbox" checked @change="toggleInList('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="filter.sources?.includes(t.value)"
@change="toggleInList('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="filter.sources?.includes(t.value)"
@change="toggleInList('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="filter.moods?.includes(m)"
@change="toggleInList('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="filter.pipelineIds?.includes(p.id)"
@change="toggleInList('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="filter.stageIds?.includes(s.id)"
@change="toggleInList('stageIds', s.id)"
/>
{{ s.name }}
</label>
</fieldset>
<fieldset v-if="showBoards" :disabled="disabled">
<legend>{{ boardsLegend }}</legend>
<p v-if="!boards.length" class="muted">بوردی نیست.</p>
<label v-for="b in boards" :key="b.id" class="chip">
<input
type="checkbox"
:checked="filter.boardIds?.includes(b.id)"
@change="toggleInList('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="filter.typeIds?.includes(t.id)"
@change="toggleInList('typeIds', t.id)"
/>
{{ t.name }}
</label>
</fieldset>
</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,
.summary {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.hint.warn {
color: #b45309;
}
.summary {
font-weight: 600;
color: #334155;
}
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 fieldset {
padding: 0.45rem 0.55rem;
}
</style>