2026-08-29 21:06:30 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-10-17 13:26:11 +03:30
|
|
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
2026-09-05 00:18:39 +03:30
|
|
|
|
import { useRoute, useRouter } from 'vue-router';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
2026-10-12 17:59:07 +03:30
|
|
|
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
2026-09-05 00:18:39 +03:30
|
|
|
|
import PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
|
|
|
|
|
|
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
|
|
|
|
|
|
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
2026-09-09 13:12:13 +03:30
|
|
|
|
import PipelineList from '@/components/pipeline/PipelineList.vue';
|
2026-09-05 00:18:39 +03:30
|
|
|
|
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
|
|
|
|
|
import type { PipelineDeal, PipelineStage } from '@/lib/pipeline-board';
|
|
|
|
|
|
import {
|
2026-09-10 01:22:24 +03:30
|
|
|
|
filterStages,
|
2026-09-05 00:18:39 +03:30
|
|
|
|
mapBoardToStages,
|
2026-09-10 01:22:24 +03:30
|
|
|
|
moveStageToIndex,
|
2026-09-05 00:18:39 +03:30
|
|
|
|
updateDealStage,
|
|
|
|
|
|
} from '@/lib/pipeline-board';
|
2026-09-19 15:30:31 +03:30
|
|
|
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
2026-09-10 01:22:24 +03:30
|
|
|
|
import DealFormView from '@/views/DealFormView.vue';
|
2026-09-27 14:25:32 +03:30
|
|
|
|
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
|
2026-10-18 19:39:59 +03:30
|
|
|
|
import {
|
|
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
|
UploadOutlined,
|
|
|
|
|
|
} from '@ant-design/icons-vue';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Alert as AAlert,
|
|
|
|
|
|
Button as AButton,
|
|
|
|
|
|
Flex as AFlex,
|
|
|
|
|
|
Form as AForm,
|
|
|
|
|
|
FormItem as AFormItem,
|
|
|
|
|
|
Input as AInput,
|
|
|
|
|
|
Menu as AMenu,
|
|
|
|
|
|
MenuItem as AMenuItem,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Modal as AModal,
|
|
|
|
|
|
Space as ASpace,
|
|
|
|
|
|
Spin as ASpin,
|
|
|
|
|
|
theme,
|
|
|
|
|
|
} from 'ant-design-vue';
|
2026-09-15 19:13:30 +03:30
|
|
|
|
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
|
2026-10-06 16:36:04 +03:30
|
|
|
|
import {
|
|
|
|
|
|
DEAL_EXCEL_ALIASES,
|
|
|
|
|
|
DEAL_EXCEL_COLUMNS,
|
|
|
|
|
|
EXCEL_CUSTOM_FIELD_PREFIX,
|
|
|
|
|
|
brand,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-10-18 19:39:59 +03:30
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
|
|
import { canManageSettings } from '@/lib/roles';
|
|
|
|
|
|
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
|
2026-10-06 16:36:04 +03:30
|
|
|
|
|
|
|
|
|
|
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
|
|
|
|
|
|
key: c.key,
|
|
|
|
|
|
label: c.header,
|
|
|
|
|
|
required: c.key === 'title',
|
|
|
|
|
|
}));
|
|
|
|
|
|
const dealClientReadOptions = {
|
|
|
|
|
|
rawValues: true,
|
|
|
|
|
|
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
|
|
|
|
|
|
};
|
2026-10-18 19:39:59 +03:30
|
|
|
|
|
|
|
|
|
|
const { token } = theme.useToken();
|
|
|
|
|
|
const GAP = 16;
|
|
|
|
|
|
|
|
|
|
|
|
/** پدینگ بیرونی فقط از `.content` شل = ۱۶px */
|
|
|
|
|
|
const pageStyle = {
|
|
|
|
|
|
boxSizing: 'border-box' as const,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column' as const,
|
|
|
|
|
|
gap: `${GAP}px`,
|
|
|
|
|
|
flex: '1 1 auto',
|
|
|
|
|
|
minHeight: 0,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
padding: 0,
|
|
|
|
|
|
margin: 0,
|
|
|
|
|
|
};
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const canCreatePipeline = computed(() => canManageSettings(auth.user?.role));
|
2026-09-05 00:18:39 +03:30
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const error = ref('');
|
2026-09-05 00:18:39 +03:30
|
|
|
|
const stages = ref<PipelineStage[]>([]);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const pipelines = ref<PipelineDto[]>([]);
|
2026-09-05 00:18:39 +03:30
|
|
|
|
const boardPipelineId = ref('');
|
|
|
|
|
|
const viewMode = ref<PipelineViewMode>('kanban');
|
|
|
|
|
|
const search = ref('');
|
2026-09-10 01:22:24 +03:30
|
|
|
|
const amountMin = ref('');
|
|
|
|
|
|
const amountMax = ref('');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const showCreate = ref(false);
|
|
|
|
|
|
const newName = ref('');
|
2026-09-10 01:22:24 +03:30
|
|
|
|
const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null);
|
|
|
|
|
|
const stageBusy = ref(false);
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const busy = ref(false);
|
|
|
|
|
|
const importMsg = ref('');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
const draggingId = ref<string | null>(null);
|
2026-09-10 01:22:24 +03:30
|
|
|
|
const draggingColumnId = ref<string | null>(null);
|
2026-09-05 00:18:39 +03:30
|
|
|
|
const dropStageId = ref<string | null>(null);
|
|
|
|
|
|
const overlayDeal = ref<PipelineDeal | null>(null);
|
|
|
|
|
|
const overlayAccent = ref<PipelineStage['color']>('green');
|
|
|
|
|
|
const overlayPos = ref({ x: 0, y: 0 });
|
|
|
|
|
|
const menuOpenId = ref<string | null>(null);
|
|
|
|
|
|
const moving = ref(false);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
const pipelineOptions = computed(() =>
|
|
|
|
|
|
pipelines.value.map((p) => ({
|
|
|
|
|
|
id: p.id,
|
2026-09-09 13:12:13 +03:30
|
|
|
|
name: p.name,
|
2026-09-05 00:18:39 +03:30
|
|
|
|
})),
|
2026-08-29 21:06:30 +03:30
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
const selectedPipelineId = computed({
|
|
|
|
|
|
get: () => {
|
|
|
|
|
|
const q = route.query.pipelineId;
|
|
|
|
|
|
if (typeof q === 'string' && q) return q;
|
|
|
|
|
|
return boardPipelineId.value;
|
|
|
|
|
|
},
|
|
|
|
|
|
set: (id: string) => {
|
|
|
|
|
|
void router.replace({ name: 'pipeline', query: { pipelineId: id } });
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const visibleStages = computed(() =>
|
2026-09-10 01:22:24 +03:30
|
|
|
|
filterStages(stages.value, {
|
|
|
|
|
|
query: search.value,
|
|
|
|
|
|
amountMin: amountMin.value === '' ? null : Number(amountMin.value),
|
|
|
|
|
|
amountMax: amountMax.value === '' ? null : Number(amountMax.value),
|
|
|
|
|
|
}),
|
2026-09-05 00:18:39 +03:30
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-27 14:25:32 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
function formatDealDate(iso: string) {
|
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
|
|
|
|
return formatJalaliNumeric(d);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function findAccent(dealId: string): PipelineStage['color'] {
|
|
|
|
|
|
for (const stage of stages.value) {
|
|
|
|
|
|
if (stage.deals.some((d) => d.id === dealId)) return stage.color;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 00:18:39 +03:30
|
|
|
|
return 'green';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadPipelines() {
|
|
|
|
|
|
const { data } = await api.get<PipelineDto[]>('/pipelines');
|
|
|
|
|
|
pipelines.value = data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadPipelines();
|
|
|
|
|
|
const pipelineId =
|
2026-09-05 00:18:39 +03:30
|
|
|
|
typeof route.query.pipelineId === 'string'
|
|
|
|
|
|
? route.query.pipelineId
|
|
|
|
|
|
: undefined;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const { data } = await api.get<PipelineBoardDto>('/pipelines/board', {
|
|
|
|
|
|
params: pipelineId ? { pipelineId } : undefined,
|
|
|
|
|
|
});
|
2026-09-05 00:18:39 +03:30
|
|
|
|
boardPipelineId.value = data.pipeline.id;
|
|
|
|
|
|
stages.value = mapBoardToStages(data, formatDealDate);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
if (!pipelineId || pipelineId !== data.pipeline.id) {
|
|
|
|
|
|
await router.replace({
|
|
|
|
|
|
name: 'pipeline',
|
|
|
|
|
|
query: { pipelineId: data.pipeline.id },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e) && !e.response) {
|
|
|
|
|
|
error.value = 'اتصال به API برقرار نشد.';
|
|
|
|
|
|
} else {
|
2026-10-06 20:51:50 +03:30
|
|
|
|
error.value = 'بارگذاری مسیر فروش ناموفق بود.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function createPipeline() {
|
|
|
|
|
|
const name = newName.value.trim();
|
|
|
|
|
|
if (name.length < 2 || saving.value) return;
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.post<PipelineDto>('/pipelines', { name });
|
|
|
|
|
|
newName.value = '';
|
|
|
|
|
|
showCreate.value = false;
|
|
|
|
|
|
await router.replace({ name: 'pipeline', query: { pipelineId: data.id } });
|
|
|
|
|
|
} catch {
|
2026-10-06 20:51:50 +03:30
|
|
|
|
error.value = 'ساخت مسیر فروش ناموفق بود.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
function onPointerMove(event: PointerEvent | MouseEvent) {
|
|
|
|
|
|
if (!draggingId.value) return;
|
|
|
|
|
|
overlayPos.value = { x: event.clientX + 12, y: event.clientY + 12 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDragStart(deal: PipelineDeal, event: DragEvent) {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
draggingId.value = deal.id;
|
2026-09-05 00:18:39 +03:30
|
|
|
|
overlayDeal.value = deal;
|
|
|
|
|
|
overlayAccent.value = findAccent(deal.id);
|
|
|
|
|
|
overlayPos.value = {
|
|
|
|
|
|
x: (event.clientX || 0) + 12,
|
|
|
|
|
|
y: (event.clientY || 0) + 12,
|
|
|
|
|
|
};
|
2026-08-29 21:06:30 +03:30
|
|
|
|
event.dataTransfer?.setData('text/plain', deal.id);
|
|
|
|
|
|
event.dataTransfer!.effectAllowed = 'move';
|
2026-09-05 00:18:39 +03:30
|
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
|
const img = new Image();
|
|
|
|
|
|
img.src =
|
|
|
|
|
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
|
|
|
|
event.dataTransfer.setDragImage(img, 0, 0);
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDragEnd() {
|
|
|
|
|
|
draggingId.value = null;
|
|
|
|
|
|
dropStageId.value = null;
|
2026-09-05 00:18:39 +03:30
|
|
|
|
overlayDeal.value = null;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDragOver(stageId: string, event: DragEvent) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
dropStageId.value = stageId;
|
|
|
|
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
2026-09-05 00:18:39 +03:30
|
|
|
|
if (event.clientX || event.clientY) {
|
|
|
|
|
|
overlayPos.value = { x: event.clientX + 12, y: event.clientY + 12 };
|
|
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
async function applyUpdateDealStage(
|
|
|
|
|
|
dealId: string,
|
|
|
|
|
|
stageId: string,
|
|
|
|
|
|
toIndex?: number,
|
|
|
|
|
|
) {
|
|
|
|
|
|
const prev = stages.value;
|
|
|
|
|
|
const next = updateDealStage(prev, dealId, stageId, toIndex);
|
|
|
|
|
|
const target = next.find((s) => s.id === stageId);
|
|
|
|
|
|
const position = target?.deals.findIndex((d) => d.id === dealId) ?? toIndex;
|
|
|
|
|
|
|
|
|
|
|
|
stages.value = next;
|
|
|
|
|
|
moving.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.patch(`/deals/${dealId}/move`, {
|
|
|
|
|
|
stageId,
|
|
|
|
|
|
position: position ?? undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
stages.value = prev;
|
|
|
|
|
|
error.value = 'جابهجایی معامله ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
moving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
event.preventDefault();
|
2026-09-10 01:22:24 +03:30
|
|
|
|
const raw =
|
|
|
|
|
|
event.dataTransfer?.getData('text/plain') ||
|
|
|
|
|
|
draggingColumnId.value ||
|
|
|
|
|
|
draggingId.value ||
|
|
|
|
|
|
'';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
dropStageId.value = null;
|
|
|
|
|
|
draggingId.value = null;
|
2026-09-05 00:18:39 +03:30
|
|
|
|
overlayDeal.value = null;
|
2026-09-10 01:22:24 +03:30
|
|
|
|
if (raw.startsWith('stage:') || draggingColumnId.value) {
|
|
|
|
|
|
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
|
|
|
|
|
|
draggingColumnId.value = null;
|
|
|
|
|
|
if (fromId) await reorderColumn(fromId, stageId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const dealId = raw;
|
2026-09-05 00:18:39 +03:30
|
|
|
|
if (!dealId || moving.value) return;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
const current = stages.value.find((s) => s.deals.some((d) => d.id === dealId));
|
|
|
|
|
|
const currentIndex = current?.deals.findIndex((d) => d.id === dealId) ?? -1;
|
|
|
|
|
|
if (current?.id === stageId && (index == null || index === currentIndex)) {
|
|
|
|
|
|
return;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-05 00:18:39 +03:30
|
|
|
|
|
|
|
|
|
|
await applyUpdateDealStage(dealId, stageId, index);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 01:22:24 +03:30
|
|
|
|
function apiMessage(e: unknown, fallback: string) {
|
|
|
|
|
|
if (axios.isAxiosError(e)) {
|
|
|
|
|
|
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
|
|
|
|
|
if (typeof msg === 'string' && msg) return msg;
|
|
|
|
|
|
if (Array.isArray(msg) && msg[0]) return String(msg[0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return fallback;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
function onAddDeal(stageId: string) {
|
2026-09-10 01:22:24 +03:30
|
|
|
|
dealModal.value = { stageId };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onColumnDragStart(stageId: string) {
|
|
|
|
|
|
draggingColumnId.value = stageId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onColumnDragEnd() {
|
|
|
|
|
|
draggingColumnId.value = null;
|
|
|
|
|
|
dropStageId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function persistStageOrder(next: PipelineStage[], prev: PipelineStage[]) {
|
|
|
|
|
|
if (!boardPipelineId.value || !canCreatePipeline.value) return;
|
|
|
|
|
|
stages.value = next;
|
|
|
|
|
|
stageBusy.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.patch(`/pipelines/${boardPipelineId.value}/stages/order`, {
|
|
|
|
|
|
stageIds: next.map((s) => s.id),
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
stages.value = prev;
|
|
|
|
|
|
error.value = apiMessage(e, 'جابهجایی ستون ناموفق بود.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
stageBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onMoveStage(stageId: string, dir: -1 | 1) {
|
|
|
|
|
|
if (stageBusy.value) return;
|
|
|
|
|
|
const from = stages.value.findIndex((s) => s.id === stageId);
|
|
|
|
|
|
const to = from + dir;
|
|
|
|
|
|
if (from < 0 || to < 0 || to >= stages.value.length) return;
|
|
|
|
|
|
await persistStageOrder(
|
|
|
|
|
|
moveStageToIndex(stages.value, stageId, to),
|
|
|
|
|
|
stages.value,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function reorderColumn(fromId: string, toId: string) {
|
|
|
|
|
|
if (stageBusy.value || fromId === toId) return;
|
|
|
|
|
|
const to = stages.value.findIndex((s) => s.id === toId);
|
|
|
|
|
|
if (to < 0) return;
|
|
|
|
|
|
await persistStageOrder(
|
|
|
|
|
|
moveStageToIndex(stages.value, fromId, to),
|
|
|
|
|
|
stages.value,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onRenameStage(stageId: string, title: string) {
|
|
|
|
|
|
if (!boardPipelineId.value || stageBusy.value) return;
|
|
|
|
|
|
const prev = stages.value;
|
|
|
|
|
|
stages.value = prev.map((s) =>
|
|
|
|
|
|
s.id === stageId ? { ...s, title } : s,
|
|
|
|
|
|
);
|
|
|
|
|
|
stageBusy.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.patch(`/pipelines/${boardPipelineId.value}/stages/${stageId}`, {
|
|
|
|
|
|
name: title,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
stages.value = prev;
|
|
|
|
|
|
error.value = apiMessage(e, 'تغییر نام ستون ناموفق بود.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
stageBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onAddStage(name: string) {
|
|
|
|
|
|
if (!boardPipelineId.value || stageBusy.value) return;
|
|
|
|
|
|
stageBusy.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post(`/pipelines/${boardPipelineId.value}/stages`, { name });
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiMessage(e, 'افزودن ستون ناموفق بود.');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
stageBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDeleteStage(stageId: string) {
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const title = stages.value.find((s) => s.id === stageId)?.title ?? '';
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: 'حذف ستون',
|
|
|
|
|
|
content: `ستون «${title}» حذف شود؟ اگر معاملهای داخل آن باشد باید اول آنها را جابهجا کنید.`,
|
|
|
|
|
|
okText: 'حذف',
|
|
|
|
|
|
cancelText: 'انصراف',
|
|
|
|
|
|
okButtonProps: { danger: true },
|
|
|
|
|
|
async onOk() {
|
|
|
|
|
|
if (!boardPipelineId.value || stageBusy.value) return;
|
|
|
|
|
|
stageBusy.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
|
|
|
|
|
|
throw e;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
stageBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-09-10 01:22:24 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 12:39:52 +03:30
|
|
|
|
const dealDeleting = ref(false);
|
2026-10-12 17:59:07 +03:30
|
|
|
|
const selectMode = ref(false);
|
|
|
|
|
|
const selectedIds = ref<Set<string>>(new Set());
|
|
|
|
|
|
|
|
|
|
|
|
const selectedCount = computed(() => selectedIds.value.size);
|
2026-10-12 12:39:52 +03:30
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
function dealTitleById(dealId: string) {
|
2026-10-12 12:39:52 +03:30
|
|
|
|
for (const s of stages.value) {
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const d = s.deals.find((x) => x.id === dealId);
|
2026-10-12 12:39:52 +03:30
|
|
|
|
if (d) return d.title;
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
2026-10-18 19:39:59 +03:30
|
|
|
|
}
|
2026-10-12 17:59:07 +03:30
|
|
|
|
|
2026-10-12 12:39:52 +03:30
|
|
|
|
function askDeleteDeal(dealId: string) {
|
|
|
|
|
|
menuOpenId.value = null;
|
2026-10-18 19:39:59 +03:30
|
|
|
|
confirmDeleteDeals([dealId]);
|
2026-10-12 17:59:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startSelect(dealId: string) {
|
|
|
|
|
|
menuOpenId.value = null;
|
|
|
|
|
|
selectMode.value = true;
|
|
|
|
|
|
selectedIds.value = new Set([dealId]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleSelect(dealId: string) {
|
|
|
|
|
|
const next = new Set(selectedIds.value);
|
|
|
|
|
|
if (next.has(dealId)) next.delete(dealId);
|
|
|
|
|
|
else next.add(dealId);
|
|
|
|
|
|
selectedIds.value = next;
|
|
|
|
|
|
if (!next.size) selectMode.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearSelect() {
|
|
|
|
|
|
selectMode.value = false;
|
|
|
|
|
|
selectedIds.value = new Set();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function askBulkDelete() {
|
|
|
|
|
|
if (!selectedIds.value.size) return;
|
|
|
|
|
|
menuOpenId.value = null;
|
2026-10-18 19:39:59 +03:30
|
|
|
|
confirmDeleteDeals([...selectedIds.value]);
|
2026-10-12 12:39:52 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
function confirmDeleteDeals(ids: string[]) {
|
2026-10-12 17:59:07 +03:30
|
|
|
|
if (!ids.length || dealDeleting.value) return;
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const copy =
|
|
|
|
|
|
ids.length === 1
|
|
|
|
|
|
? `معاملهٔ «${dealTitleById(ids[0]!)}» حذف شود؟ این کار قابل بازگشت نیست.`
|
|
|
|
|
|
: `${toFaDigits(ids.length)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: ids.length > 1 ? 'حذف معاملهها' : 'حذف معامله',
|
|
|
|
|
|
content: copy,
|
|
|
|
|
|
okText: 'حذف',
|
|
|
|
|
|
cancelText: 'انصراف',
|
|
|
|
|
|
okButtonProps: { danger: true },
|
|
|
|
|
|
async onOk() {
|
|
|
|
|
|
dealDeleting.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
|
|
|
|
|
|
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
|
|
|
|
clearSelect();
|
|
|
|
|
|
await load();
|
|
|
|
|
|
if (failed) {
|
|
|
|
|
|
error.value =
|
|
|
|
|
|
failed === ids.length
|
|
|
|
|
|
? 'حذف معاملهها ناموفق بود.'
|
|
|
|
|
|
: `${toFaDigits(failed)} مورد حذف نشد.`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
|
|
|
|
|
|
throw e;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
dealDeleting.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-10-12 12:39:52 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 01:22:24 +03:30
|
|
|
|
async function onDealSaved() {
|
|
|
|
|
|
dealModal.value = null;
|
|
|
|
|
|
await load();
|
2026-09-05 00:18:39 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onOpenDeal(dealId: string) {
|
|
|
|
|
|
menuOpenId.value = null;
|
|
|
|
|
|
void router.push({ name: 'deal-detail', params: { id: dealId } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 17:28:57 +03:30
|
|
|
|
function findDeal(dealId: string | null) {
|
|
|
|
|
|
if (!dealId) return null;
|
|
|
|
|
|
for (const stage of stages.value) {
|
|
|
|
|
|
const deal = stage.deals.find((d) => d.id === dealId);
|
|
|
|
|
|
if (deal) return deal;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function canPromotePrior(deal: PipelineDeal | null) {
|
|
|
|
|
|
const pa = auth.user?.priorArchive;
|
|
|
|
|
|
if (!deal || !pa || pa.lens !== 'archive' || !pa.cutoffAt || !deal.createdAt) return false;
|
|
|
|
|
|
if (deal.promotedFromPriorAt) return false;
|
|
|
|
|
|
return new Date(deal.createdAt).getTime() < new Date(pa.cutoffAt).getTime();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function promotePriorDeal(dealId: string) {
|
|
|
|
|
|
menuOpenId.value = null;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post(`/deals/${dealId}/promote-from-prior`);
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = apiMessage(e, 'انتقال معامله به کار جاری ناموفق بود.');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 12:29:11 +03:30
|
|
|
|
const pageBusy = ref(false);
|
|
|
|
|
|
const busyMessage = ref('لطفاً صبر کنید…');
|
|
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
async function exportExcel() {
|
|
|
|
|
|
busy.value = true;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = true;
|
|
|
|
|
|
busyMessage.value = 'در حال تهیه خروجی اکسل…';
|
2026-09-15 19:13:30 +03:30
|
|
|
|
importMsg.value = '';
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data } = await api.get<Blob>('/deals/export', { responseType: 'blob' });
|
|
|
|
|
|
const url = URL.createObjectURL(data);
|
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = `${brand.filePrefix}-deals.xlsx`;
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'خروجی اکسل ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
busy.value = false;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:30:31 +03:30
|
|
|
|
function onImportStart() {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
busy.value = true;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
importMsg.value = '';
|
|
|
|
|
|
error.value = '';
|
2026-09-19 15:30:31 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onImportDone(data: ImportContactsResult) {
|
|
|
|
|
|
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
|
|
|
|
|
|
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
|
2026-10-06 20:51:50 +03:30
|
|
|
|
}${data.boards?.length ? ` · مسیر فروش: ${data.boards.join('، ')}` : ''}`;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
if (data.errors.length) {
|
|
|
|
|
|
error.value = data.errors
|
|
|
|
|
|
.slice(0, 3)
|
|
|
|
|
|
.map((e) => `ردیف ${e.row}: ${e.message}`)
|
|
|
|
|
|
.join(' | ');
|
2026-09-15 19:13:30 +03:30
|
|
|
|
}
|
2026-09-19 15:30:31 +03:30
|
|
|
|
if (data.boardId && data.boardId !== selectedPipelineId.value) {
|
|
|
|
|
|
await router.replace({ name: 'pipeline', query: { pipelineId: data.boardId } });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await load();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onImportFail(message: string) {
|
|
|
|
|
|
error.value = message;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onImportSettled() {
|
|
|
|
|
|
busy.value = false;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
pageBusy.value = false;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 14:54:32 +03:30
|
|
|
|
const menuStyle = ref<Record<string, string> | null>(null);
|
|
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
function onMenuDeal(dealId: string) {
|
|
|
|
|
|
menuOpenId.value = menuOpenId.value === dealId ? null : dealId;
|
2026-10-17 13:26:11 +03:30
|
|
|
|
menuStyle.value = null;
|
|
|
|
|
|
if (!menuOpenId.value) return;
|
|
|
|
|
|
void nextTick(() => {
|
|
|
|
|
|
menuStyle.value = cardMenuStyle(dealId);
|
|
|
|
|
|
});
|
2026-09-05 00:18:39 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
function onDealMenuClick({ key }: { key: string | number }) {
|
|
|
|
|
|
const id = menuOpenId.value;
|
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
if (key === 'select') startSelect(id);
|
|
|
|
|
|
else if (key === 'view') onOpenDeal(id);
|
|
|
|
|
|
else if (key === 'edit') {
|
|
|
|
|
|
dealModal.value = { dealId: id };
|
|
|
|
|
|
menuOpenId.value = null;
|
|
|
|
|
|
} else if (key === 'promote') void promotePriorDeal(id);
|
|
|
|
|
|
else if (key === 'delete') askDeleteDeal(id);
|
|
|
|
|
|
else menuOpenId.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 14:54:32 +03:30
|
|
|
|
const unbindMenuDismiss = bindMenuDismiss(
|
|
|
|
|
|
() => menuOpenId.value != null,
|
|
|
|
|
|
() => (menuOpenId.value = null),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-17 12:47:07 +03:30
|
|
|
|
watch(viewMode, () => {
|
|
|
|
|
|
clearSelect();
|
|
|
|
|
|
menuOpenId.value = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
watch(
|
|
|
|
|
|
() => route.query.pipelineId,
|
|
|
|
|
|
(id, prev) => {
|
|
|
|
|
|
if (id === prev) return;
|
|
|
|
|
|
void load();
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
window.addEventListener('drag', onPointerMove);
|
|
|
|
|
|
window.addEventListener('dragover', onPointerMove);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
2026-10-12 14:54:32 +03:30
|
|
|
|
unbindMenuDismiss();
|
2026-09-05 00:18:39 +03:30
|
|
|
|
window.removeEventListener('drag', onPointerMove);
|
|
|
|
|
|
window.removeEventListener('dragover', onPointerMove);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
defineExpose({ updateDealStage: applyUpdateDealStage });
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<div class="pipeline-page" dir="rtl" :style="pageStyle">
|
|
|
|
|
|
<a-flex vertical :gap="GAP" :style="{ flexShrink: 0 }">
|
|
|
|
|
|
<PipelineToolbar
|
|
|
|
|
|
pipeline-name="مسیر فروش"
|
|
|
|
|
|
:pipeline-options="pipelineOptions"
|
|
|
|
|
|
v-model:selected-pipeline-id="selectedPipelineId"
|
|
|
|
|
|
v-model:view-mode="viewMode"
|
|
|
|
|
|
v-model:search="search"
|
|
|
|
|
|
v-model:amount-min="amountMin"
|
|
|
|
|
|
v-model:amount-max="amountMax"
|
|
|
|
|
|
:can-create="canCreatePipeline"
|
|
|
|
|
|
@create="showCreate = true"
|
2026-10-12 17:59:07 +03:30
|
|
|
|
>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<template #excel>
|
|
|
|
|
|
<a-button :disabled="loading || busy" @click="exportExcel">
|
|
|
|
|
|
<template #icon><DownloadOutlined /></template>
|
|
|
|
|
|
خروجی اکسل
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<ExcelImportFlow
|
|
|
|
|
|
preview-path="/deals/import/preview"
|
|
|
|
|
|
import-path="/deals/import"
|
|
|
|
|
|
rows-import-path="/deals/import/rows"
|
|
|
|
|
|
:client-aliases="DEAL_EXCEL_ALIASES"
|
|
|
|
|
|
:client-fields="dealExcelFields"
|
|
|
|
|
|
:client-read-options="dealClientReadOptions"
|
|
|
|
|
|
:disabled="loading || busy"
|
|
|
|
|
|
@start="onImportStart"
|
|
|
|
|
|
@done="onImportDone"
|
|
|
|
|
|
@fail="onImportFail"
|
|
|
|
|
|
@settled="onImportSettled"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-button :disabled="loading || busy">
|
|
|
|
|
|
<template #icon><UploadOutlined /></template>
|
|
|
|
|
|
ورود از اکسل
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</ExcelImportFlow>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #filters>
|
|
|
|
|
|
<SavedFiltersBar
|
|
|
|
|
|
compact
|
|
|
|
|
|
scope="deals"
|
|
|
|
|
|
:current-filters="dealsSavedFilters"
|
|
|
|
|
|
@apply="applyDealsSavedFilters"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</PipelineToolbar>
|
|
|
|
|
|
|
|
|
|
|
|
<a-alert v-if="error" type="error" show-icon :message="error" />
|
|
|
|
|
|
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
|
|
|
|
|
|
<a-spin v-if="loading && !stages.length" tip="در حال بارگذاری مسیر فروش…" />
|
|
|
|
|
|
|
|
|
|
|
|
<a-alert
|
|
|
|
|
|
v-if="selectMode"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
show-icon
|
|
|
|
|
|
:message="`${toFaDigits(selectedCount)} معامله انتخاب شده`"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #action>
|
|
|
|
|
|
<a-space>
|
|
|
|
|
|
<a-button
|
|
|
|
|
|
v-if="selectedCount"
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
danger
|
|
|
|
|
|
@click="askBulkDelete"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #icon><DeleteOutlined /></template>
|
|
|
|
|
|
حذف انتخابشدهها
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<a-button @click="clearSelect">انصراف از انتخاب</a-button>
|
|
|
|
|
|
</a-space>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-alert>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
flex: '1 1 auto',
|
|
|
|
|
|
minHeight: 0,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="pageBusy"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
inset: 0,
|
|
|
|
|
|
zIndex: 2,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
background: token.colorBgMask,
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-spin :tip="busyMessage" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<PipelineList
|
|
|
|
|
|
v-if="viewMode === 'list' && (!loading || stages.length)"
|
|
|
|
|
|
:stages="visibleStages"
|
|
|
|
|
|
:select-mode="selectMode"
|
|
|
|
|
|
:selected-ids="selectedIds"
|
|
|
|
|
|
@open-deal="onOpenDeal"
|
|
|
|
|
|
@menu-deal="onMenuDeal"
|
|
|
|
|
|
@toggle-select="toggleSelect"
|
|
|
|
|
|
@bulk-delete="askBulkDelete"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<PipelineBoard
|
|
|
|
|
|
v-else-if="viewMode === 'kanban' && stages.length"
|
|
|
|
|
|
:stages="visibleStages"
|
|
|
|
|
|
:dragging-id="draggingId"
|
|
|
|
|
|
:drop-stage-id="dropStageId"
|
|
|
|
|
|
:can-manage="canCreatePipeline"
|
|
|
|
|
|
:select-mode="selectMode"
|
|
|
|
|
|
:selected-ids="selectedIds"
|
|
|
|
|
|
@drag-start="onDragStart"
|
|
|
|
|
|
@drag-end="onDragEnd"
|
|
|
|
|
|
@drag-over="onDragOver"
|
|
|
|
|
|
@drop="onDrop"
|
|
|
|
|
|
@add-deal="onAddDeal"
|
|
|
|
|
|
@open-deal="onOpenDeal"
|
|
|
|
|
|
@menu-deal="onMenuDeal"
|
|
|
|
|
|
@toggle-select="toggleSelect"
|
|
|
|
|
|
@bulk-delete="askBulkDelete"
|
|
|
|
|
|
@rename-stage="onRenameStage"
|
|
|
|
|
|
@delete-stage="onDeleteStage"
|
|
|
|
|
|
@move-stage="onMoveStage"
|
|
|
|
|
|
@column-drag-start="onColumnDragStart"
|
|
|
|
|
|
@column-drag-end="onColumnDragEnd"
|
|
|
|
|
|
@add-stage="onAddStage"
|
|
|
|
|
|
/>
|
2026-10-12 17:59:07 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-05 00:18:39 +03:30
|
|
|
|
<Teleport to="body">
|
|
|
|
|
|
<DealCardOverlay
|
|
|
|
|
|
v-if="overlayDeal && draggingId"
|
|
|
|
|
|
:deal="overlayDeal"
|
|
|
|
|
|
:accent="overlayAccent"
|
|
|
|
|
|
:x="overlayPos.x"
|
|
|
|
|
|
:y="overlayPos.y"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Teleport>
|
|
|
|
|
|
|
2026-10-17 13:26:11 +03:30
|
|
|
|
<Teleport to="body">
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<div
|
|
|
|
|
|
v-if="menuOpenId"
|
2026-10-19 11:40:25 +03:30
|
|
|
|
class="menu-pop"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
:style="{
|
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
|
zIndex: 1080,
|
|
|
|
|
|
...(menuStyle ?? {}),
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-menu
|
|
|
|
|
|
:style="{ minWidth: '160px', boxShadow: token.boxShadowSecondary }"
|
|
|
|
|
|
@click="onDealMenuClick"
|
2026-10-17 13:26:11 +03:30
|
|
|
|
>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-menu-item key="select">انتخاب</a-menu-item>
|
|
|
|
|
|
<a-menu-item key="view">مشاهده معامله</a-menu-item>
|
|
|
|
|
|
<a-menu-item key="edit">ویرایش</a-menu-item>
|
|
|
|
|
|
<a-menu-item
|
|
|
|
|
|
v-if="canPromotePrior(findDeal(menuOpenId))"
|
|
|
|
|
|
key="promote"
|
|
|
|
|
|
>
|
|
|
|
|
|
انتقال به کار جاری
|
|
|
|
|
|
</a-menu-item>
|
|
|
|
|
|
<a-menu-item key="delete" danger>حذف</a-menu-item>
|
|
|
|
|
|
<a-menu-item key="close">بستن</a-menu-item>
|
|
|
|
|
|
</a-menu>
|
2026-10-17 13:26:11 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</Teleport>
|
2026-09-10 01:22:24 +03:30
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-modal
|
2026-09-10 01:22:24 +03:30
|
|
|
|
:open="showCreate && canCreatePipeline"
|
2026-10-06 20:51:50 +03:30
|
|
|
|
title="مسیر فروش جدید"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
:confirm-loading="saving"
|
|
|
|
|
|
ok-text="ساخت"
|
|
|
|
|
|
cancel-text="انصراف"
|
|
|
|
|
|
:ok-button-props="{ disabled: newName.trim().length < 2 }"
|
|
|
|
|
|
destroy-on-close
|
|
|
|
|
|
@ok="createPipeline"
|
|
|
|
|
|
@cancel="showCreate = false"
|
2026-09-10 01:22:24 +03:30
|
|
|
|
>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-form layout="vertical" @finish="createPipeline">
|
|
|
|
|
|
<a-form-item label="نام مسیر فروش" required>
|
|
|
|
|
|
<a-input
|
|
|
|
|
|
v-model:value="newName"
|
2026-10-06 20:51:50 +03:30
|
|
|
|
placeholder="نام مسیر فروش جدید…"
|
2026-09-10 01:22:24 +03:30
|
|
|
|
:maxlength="120"
|
|
|
|
|
|
/>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-form-item>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
</a-modal>
|
2026-09-10 01:22:24 +03:30
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-modal
|
2026-09-10 01:22:24 +03:30
|
|
|
|
:open="Boolean(dealModal)"
|
|
|
|
|
|
:title="dealModal?.dealId ? 'ویرایش معامله' : 'ایجاد معامله جدید'"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
:footer="null"
|
|
|
|
|
|
destroy-on-close
|
|
|
|
|
|
width="720px"
|
|
|
|
|
|
@cancel="dealModal = null"
|
2026-09-10 01:22:24 +03:30
|
|
|
|
>
|
|
|
|
|
|
<DealFormView
|
|
|
|
|
|
v-if="dealModal"
|
|
|
|
|
|
embedded
|
|
|
|
|
|
:deal-id="dealModal.dealId"
|
|
|
|
|
|
:initial-pipeline-id="selectedPipelineId || undefined"
|
|
|
|
|
|
:initial-stage-id="dealModal.stageId"
|
|
|
|
|
|
@saved="onDealSaved"
|
|
|
|
|
|
@cancel="dealModal = null"
|
|
|
|
|
|
/>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-modal>
|
|
|
|
|
|
</div>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
/* پدینگ بیرونی فقط از `.content` شل = ۱۶px */
|
|
|
|
|
|
.pipeline-page {
|
2026-09-10 01:22:24 +03:30
|
|
|
|
margin: 0;
|
2026-10-18 19:39:59 +03:30
|
|
|
|
width: 100%;
|
2026-10-12 12:39:52 +03:30
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</style>
|