Add deal co-assignees and saved filters on people/pipeline lists.

Deals keep a primary owner plus shared assignees with matching visibility; Contacts and Pipeline can save and reuse filter presets.
This commit is contained in:
2026-09-27 14:25:32 +03:30
parent 00f8b91d78
commit c06c92b333
19 changed files with 553 additions and 20 deletions
+272
View File
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type { SavedViewDto, SavedViewScope } from '@mento/shared';
const props = defineProps<{
scope: SavedViewScope;
/** فیلتر فعلی صفحه برای ذخیره */
currentFilters: Record<string, unknown>;
}>();
const emit = defineEmits<{
apply: [filters: Record<string, unknown>];
}>();
const views = ref<SavedViewDto[]>([]);
const activeId = ref('');
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const saveOpen = ref(false);
const saveName = ref('');
const saveShared = ref(false);
const activeView = computed(
() => views.value.find((v) => v.id === activeId.value) ?? null,
);
async function loadViews() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<SavedViewDto[]>('/saved-views', {
params: { scope: props.scope },
});
views.value = data;
} catch {
views.value = [];
error.value = 'بارگذاری فیلترهای ذخیره‌شده ناموفق بود.';
} finally {
loading.value = false;
}
}
function onPick(id: string) {
activeId.value = id;
if (!id) return;
const view = views.value.find((v) => v.id === id);
if (view) emit('apply', view.filters ?? {});
}
function openSave() {
saveName.value = activeView.value?.name ?? '';
saveShared.value = activeView.value?.isShared ?? false;
saveOpen.value = true;
error.value = '';
}
async function saveView() {
const name = saveName.value.trim();
if (name.length < 2 || busy.value) return;
busy.value = true;
error.value = '';
try {
const body = {
name,
scope: props.scope,
filters: props.currentFilters,
isShared: saveShared.value,
};
if (activeId.value) {
const { data } = await api.patch<SavedViewDto>(
`/saved-views/${activeId.value}`,
body,
);
const idx = views.value.findIndex((v) => v.id === data.id);
if (idx >= 0) views.value[idx] = data;
else views.value.unshift(data);
activeId.value = data.id;
} else {
const { data } = await api.post<SavedViewDto>('/saved-views', body);
views.value.unshift(data);
activeId.value = data.id;
}
saveOpen.value = false;
} catch {
error.value = 'ذخیره فیلتر ناموفق بود.';
} finally {
busy.value = false;
}
}
async function removeView() {
if (!activeId.value || busy.value) return;
if (!confirm('این فیلتر ذخیره‌شده حذف شود؟')) return;
busy.value = true;
try {
await api.delete(`/saved-views/${activeId.value}`);
views.value = views.value.filter((v) => v.id !== activeId.value);
activeId.value = '';
} catch {
error.value = 'حذف فیلتر ناموفق بود.';
} finally {
busy.value = false;
}
}
watch(
() => props.scope,
() => {
activeId.value = '';
void loadViews();
},
);
onMounted(() => {
void loadViews();
});
</script>
<template>
<div class="saved-filters" dir="rtl">
<label class="pick">
<span class="lbl">فیلتر ذخیره‌شده</span>
<select
:value="activeId"
:disabled="loading"
@change="onPick(($event.target as HTMLSelectElement).value)"
>
<option value="">— انتخاب —</option>
<option v-for="v in views" :key="v.id" :value="v.id">
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }}
</option>
</select>
</label>
<button type="button" class="btn" title="ذخیره فیلتر فعلی" @click="openSave">
<UiIcon name="flag" :size="14" />
ذخیره
</button>
<button
v-if="activeId"
type="button"
class="btn danger"
title="حذف فیلتر"
:disabled="busy"
@click="removeView"
>
حذف
</button>
<p v-if="error" class="err">{{ error }}</p>
<div v-if="saveOpen" class="sheet">
<div class="sheet-backdrop" @click="saveOpen = false" />
<div class="sheet-panel">
<h3>{{ activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر' }}</h3>
<label>
نام
<input v-model="saveName" type="text" maxlength="120" placeholder="مثلاً سرنخ‌های این هفته" />
</label>
<label class="check">
<input v-model="saveShared" type="checkbox" />
اشتراک با سازمان
</label>
<div class="actions">
<button type="button" class="btn primary" :disabled="busy" @click="saveView">
{{ busy ? '…' : 'ذخیره' }}
</button>
<button type="button" class="btn" @click="saveOpen = false">لغو</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.saved-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.45rem;
margin-top: 0.55rem;
}
.pick {
display: grid;
gap: 0.2rem;
min-width: 11rem;
}
.lbl {
font-size: 0.72rem;
color: #64748b;
}
select,
input[type='text'] {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.4rem 0.55rem;
background: #fff;
font-size: 0.85rem;
color: #0f172a;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: 8px;
padding: 0.4rem 0.65rem;
font-size: 0.82rem;
cursor: pointer;
}
.btn.primary {
background: #0d9488;
border-color: #0d9488;
color: #fff;
}
.btn.danger {
color: #b91c1c;
}
.err {
width: 100%;
margin: 0;
font-size: 0.8rem;
color: #b91c1c;
}
.sheet {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
}
.sheet-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.4);
}
.sheet-panel {
position: relative;
z-index: 1;
width: min(100%, 360px);
background: #fff;
border-radius: 12px;
padding: 1rem;
display: grid;
gap: 0.75rem;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
}
.sheet-panel h3 {
margin: 0;
font-size: 1rem;
}
.sheet-panel label {
display: grid;
gap: 0.3rem;
font-size: 0.85rem;
color: #334155;
}
.check {
display: flex !important;
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
.actions {
display: flex;
gap: 0.45rem;
}
</style>
@@ -32,6 +32,12 @@ const money = computed(() =>
formatCurrencyParts(props.deal.amount ?? 0, props.deal.currencyUnit ?? 'تومان'),
);
const colors = computed(() => PIPELINE_ACCENT[props.accent]);
const assigneeTitle = computed(() =>
(props.deal.assigneeNames ?? []).filter(Boolean).join('، ') || 'مسئول',
);
const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
</script>
<template>
@@ -68,7 +74,11 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
alt=""
width="32"
height="32"
:title="assigneeTitle"
/>
<span v-if="extraAssignees > 0" class="more-assignees" :title="assigneeTitle">
+{{ extraAssignees }}
</span>
<span class="date">
{{ deal.date }}
<UiIcon name="calendar" :size="13" />
@@ -155,6 +165,25 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
border: 1px solid var(--line);
}
.more-assignees {
width: auto;
min-width: 1.15rem;
height: 1.15rem;
padding: 0 0.22rem;
margin-inline-start: -1.55rem;
margin-top: 1.1rem;
border-radius: 999px;
background: #0f766e;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
display: grid;
place-items: center;
z-index: 1;
justify-self: end;
align-self: start;
}
.titles {
min-width: 0;
text-align: right;
+11
View File
@@ -10,6 +10,8 @@ export type PipelineDeal = {
/** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */
stageId?: string;
currencyUnit?: string;
/** نام مسئولان برای نمایش روی کارت */
assigneeNames?: string[];
};
export type PipelineUiCopy = {
@@ -136,6 +138,8 @@ export function mapBoardToStages(
createdAt: string;
person: { fullName: string } | null;
company: { name: string } | null;
owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}>;
}>;
},
@@ -164,6 +168,12 @@ export function mapBoardToStages(
deal.person?.fullName ||
'';
const dateIso = deal.expectedCloseAt || deal.createdAt;
const assigneeNames = [
...(deal.owner?.fullName ? [deal.owner.fullName] : []),
...(deal.assignees ?? [])
.map((a) => a.fullName)
.filter((n) => n && n !== deal.owner?.fullName),
];
return {
id: deal.id,
title: deal.title,
@@ -175,6 +185,7 @@ export function mapBoardToStages(
),
stageId: deal.stageId,
currencyUnit: currencyUnitLabel(deal.currency),
assigneeNames,
};
}),
};
+32
View File
@@ -18,6 +18,7 @@ import {
import UiIcon from '@/components/UiIcon.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import CompanyFormView from '@/views/CompanyFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import type {
CompanyDto,
ImportContactsResult,
@@ -65,6 +66,30 @@ const total = computed(() =>
);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
const peopleSavedFilters = computed(() => ({
q: q.value.trim() || undefined,
sort: sort.value === 'name' ? undefined : sort.value,
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
}));
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
const nextQ = typeof filters.q === 'string' ? filters.q : '';
q.value = nextQ;
const nextSort = filters.sort === 'newest' ? 'newest' : 'name';
const nextMood =
typeof filters.mood === 'string' &&
PERSON_MOODS.includes(filters.mood as PersonMood)
? (filters.mood as PersonMood)
: 'all';
patchQuery({
q: nextQ.trim() || undefined,
sort: nextSort === 'name' ? undefined : nextSort,
mood: nextMood === 'all' ? undefined : nextMood,
page: undefined,
tab: undefined,
});
}
function moodOf(person: PersonDto): PersonMood {
return person.mood || 'newcomer';
}
@@ -360,6 +385,13 @@ onMounted(load);
</div>
</div>
<SavedFiltersBar
v-if="tab === 'people'"
scope="people"
:current-filters="peopleSavedFilters"
@apply="applyPeopleSavedFilters"
/>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
+32
View File
@@ -93,6 +93,7 @@ const companyId = ref('');
const stageId = ref('');
const pipelineId = ref('');
const ownerId = ref('');
const selectedAssigneeIds = ref<string[]>([]);
const lostReason = ref('');
const status = ref('');
const customFields = ref<Record<string, unknown>>({});
@@ -247,6 +248,7 @@ async function load() {
contactKind.value = data.person?.id || !data.company?.id ? 'person' : 'company';
stageId.value = data.stageId;
ownerId.value = data.owner?.id ?? auth.user?.id ?? '';
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
lostReason.value = data.lostReason ?? '';
status.value = data.status;
customFields.value = { ...(data.customFields ?? {}) };
@@ -264,6 +266,7 @@ async function load() {
}
} else {
ownerId.value = auth.user?.id ?? users.value[0]?.id ?? '';
selectedAssigneeIds.value = [];
if (typeof route.query.personId === 'string' && route.query.personId) {
personId.value = route.query.personId;
contactKind.value = 'person';
@@ -306,6 +309,7 @@ function payload(): CreateDealRequest {
personId: personId.value || null,
companyId: companyId.value || null,
ownerId: ownerId.value || null,
assigneeIds: selectedAssigneeIds.value,
pipelineId: pipelineId.value || undefined,
stageId: stageId.value || undefined,
customFields: customFields.value,
@@ -623,6 +627,20 @@ onMounted(load);
</li>
</ul>
</div>
<div class="field">
<label for="deal-assignees">مسئولان مشترک</label>
<select
id="deal-assignees"
v-model="selectedAssigneeIds"
multiple
class="multi"
>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
<small class="hint">علاوه بر مسئول اصلی؛ با Ctrl چند نفر را انتخاب کنید.</small>
</div>
</div>
</section>
@@ -1322,6 +1340,20 @@ h1 {
gap: 0.45rem;
}
select.multi {
min-height: 110px;
padding: 0.4rem;
width: 100%;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
}
.hint {
color: #64748b;
font-size: 0.8rem;
}
.note-card.off,
.timeline.hide {
display: none;
+35
View File
@@ -21,6 +21,7 @@ import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
import DealFormView from '@/views/DealFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
@@ -84,6 +85,34 @@ const visibleStages = computed(() =>
}),
);
const dealsSavedFilters = computed(() => ({
q: search.value.trim() || undefined,
amountMin: amountMin.value || undefined,
amountMax: amountMax.value || undefined,
pipelineId: selectedPipelineId.value || undefined,
viewMode: viewMode.value,
}));
function applyDealsSavedFilters(filters: Record<string, unknown>) {
search.value = typeof filters.q === 'string' ? filters.q : '';
amountMin.value =
filters.amountMin == null || filters.amountMin === ''
? ''
: String(filters.amountMin);
amountMax.value =
filters.amountMax == null || filters.amountMax === ''
? ''
: String(filters.amountMax);
if (filters.viewMode === 'list' || filters.viewMode === 'kanban') {
viewMode.value = filters.viewMode;
}
const nextPipeline =
typeof filters.pipelineId === 'string' ? filters.pipelineId : '';
if (nextPipeline && nextPipeline !== selectedPipelineId.value) {
selectedPipelineId.value = nextPipeline;
}
}
function formatDealDate(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
@@ -480,6 +509,12 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
</template>
</PipelineToolbar>
<SavedFiltersBar
scope="deals"
:current-filters="dealsSavedFilters"
@apply="applyDealsSavedFilters"
/>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری کاریز…</p>