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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user