feat(web): pipeline merge wizard

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-12 20:55:14 +03:30
co-authored by Cursor
parent 9b42fec0a8
commit fd6e79c15d
2 changed files with 486 additions and 17 deletions
@@ -0,0 +1,471 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import type {
MergePipelineRequest,
MergePipelineResultDto,
PipelineDto,
PipelineMergePreviewDto,
PipelineMergePreviewStageDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiField, UiSelect } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
type Step = 'target' | 'map' | 'review';
const props = defineProps<{
open: boolean;
sourceId: string | null;
pipelines: PipelineDto[];
}>();
const emit = defineEmits<{
close: [];
done: [];
}>();
const step = ref<Step>('target');
const targetId = ref('');
const preview = ref<PipelineMergePreviewDto | null>(null);
const stageMap = ref<Record<string, string>>({});
const busy = ref(false);
const error = ref('');
const sourceName = computed(() => {
if (preview.value) return preview.value.source.name;
return props.pipelines.find((pipeline) => pipeline.id === props.sourceId)?.name ?? '';
});
const selectedTarget = computed(
() => preview.value?.targets.find((target) => target.id === targetId.value) ?? null,
);
const canContinueTarget = computed(() => Boolean(targetId.value && selectedTarget.value));
const canContinueMap = computed(() => {
const current = preview.value;
if (!current) return false;
return current.stages.every((stage) => Boolean(stageMap.value[stage.id]));
});
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (Array.isArray(msg)) return msg.join('، ');
if (msg) return msg;
if (!e.response) return 'اتصال به API برقرار نشد.';
}
return fallback;
}
function resetState() {
step.value = 'target';
targetId.value = '';
preview.value = null;
stageMap.value = {};
busy.value = false;
error.value = '';
}
function compatibleTargets(src: PipelineMergePreviewStageDto) {
const target = selectedTarget.value;
if (!target) return [];
return target.stages.filter((stage) => {
if (src.isWon) return stage.isWon;
if (src.isLost) return stage.isLost;
return !stage.isWon && !stage.isLost;
});
}
function statusLabel(stage: Pick<PipelineMergePreviewStageDto, 'isWon' | 'isLost'>) {
if (stage.isWon) return 'برد';
if (stage.isLost) return 'باخت';
return 'باز';
}
function syncStageMap() {
const current = preview.value;
if (!current || !targetId.value) {
stageMap.value = {};
return;
}
const next: Record<string, string> = {};
for (const sourceStage of current.stages) {
const compatible = compatibleTargets(sourceStage);
const currentTargetId = stageMap.value[sourceStage.id];
if (compatible.some((stage) => stage.id === currentTargetId)) {
next[sourceStage.id] = currentTargetId;
} else if (compatible.length === 1) {
next[sourceStage.id] = compatible[0].id;
} else {
next[sourceStage.id] = '';
}
}
stageMap.value = next;
}
async function loadPreview(id: string) {
busy.value = true;
error.value = '';
try {
const { data } = await api.get<PipelineMergePreviewDto>(`/pipelines/${id}/merge-preview`);
preview.value = data;
} catch (e) {
error.value = apiError(e, 'پیش‌نمایش ادغام ناموفق بود.');
} finally {
busy.value = false;
}
}
function nextFromTarget() {
if (!canContinueTarget.value) {
error.value = 'کاریز مقصد را انتخاب کنید.';
return;
}
error.value = '';
syncStageMap();
step.value = 'map';
}
function nextFromMap() {
if (!canContinueMap.value) {
error.value = 'برای همه مرحله‌ها مقصد سازگار انتخاب کنید.';
return;
}
error.value = '';
step.value = 'review';
}
async function submitMerge() {
if (!props.sourceId || !preview.value || !canContinueMap.value || busy.value) return;
busy.value = true;
error.value = '';
try {
const body: MergePipelineRequest = {
targetPipelineId: targetId.value,
stageMap: stageMap.value,
leadFormMode: 'retarget_all',
};
await api.post<MergePipelineResultDto>(`/pipelines/${props.sourceId}/merge`, body);
emit('done');
} catch (e) {
error.value = apiError(e, 'ادغام کاریز ناموفق بود.');
} finally {
busy.value = false;
}
}
watch(
() => [props.open, props.sourceId] as const,
async ([open, id]) => {
resetState();
if (!open || !id) return;
await loadPreview(id);
},
);
watch(targetId, () => {
error.value = '';
syncStageMap();
});
</script>
<template>
<div class="merge-wizard">
<ol class="steps" aria-label="مراحل ادغام کاریز">
<li :class="{ active: step === 'target', done: step !== 'target' }">۱. مقصد</li>
<li :class="{ active: step === 'map', done: step === 'review' }">۲. نگاشت</li>
<li :class="{ active: step === 'review' }">۳. بازبینی</li>
</ol>
<p v-if="busy && !preview" class="muted">در حال آماده‌سازی پیش‌نمایش…</p>
<template v-else-if="preview">
<section v-if="step === 'target'" class="step-panel">
<div class="intro">
<UiIcon name="layers" :size="20" />
<div>
<h3>کاریز مقصد را انتخاب کنید</h3>
<p>
معاملات و فرم‌های سرنخ از «{{ preview.source.name }}» به کاریز مقصد منتقل می‌شوند.
</p>
</div>
</div>
<UiField label="کاریز مقصد" required>
<UiSelect v-model="targetId" required>
<option value="" disabled>انتخاب کنید</option>
<option v-for="target in preview.targets" :key="target.id" :value="target.id">
{{ target.name }}
</option>
</UiSelect>
</UiField>
<div class="summary-grid">
<article>
<strong>{{ toFaDigits(preview.dealCount) }}</strong>
<span>معامله منتقل می‌شود</span>
</article>
<article>
<strong>{{ toFaDigits(preview.leadForms.length) }}</strong>
<span>فرم سرنخ به مقصد وصل می‌شود</span>
</article>
<article v-if="preview.source.isDefault" class="warn">
<strong>هشدار</strong>
<span>کاریز مبدا پیش‌فرض است و بعد از ادغام حذف می‌شود.</span>
</article>
</div>
<p v-if="selectedTarget?.isDefault" class="note">مقصد انتخاب‌شده کاریز پیش‌فرض است.</p>
</section>
<section v-else-if="step === 'map'" class="step-panel">
<div class="intro">
<UiIcon name="target" :size="20" />
<div>
<h3>مرحله‌های مبدا را نگاشت کنید</h3>
<p>هر مرحله فقط به مرحله‌ای با وضعیت سازگار در مقصد وصل می‌شود.</p>
</div>
</div>
<div class="map-list">
<article v-for="sourceStage in preview.stages" :key="sourceStage.id" class="map-row">
<div class="stage-info">
<strong>{{ sourceStage.name }}</strong>
<span>
{{ statusLabel(sourceStage) }} · {{ toFaDigits(sourceStage.dealCount) }} معامله
</span>
</div>
<UiSelect v-model="stageMap[sourceStage.id]" :error="!stageMap[sourceStage.id]">
<option value="" disabled>مرحله مقصد</option>
<option
v-for="targetStage in compatibleTargets(sourceStage)"
:key="targetStage.id"
:value="targetStage.id"
>
{{ targetStage.name }}
</option>
</UiSelect>
</article>
</div>
</section>
<section v-else class="step-panel">
<div class="intro">
<UiIcon name="check" :size="20" />
<div>
<h3>بازبینی نهایی</h3>
<p>
«{{ toFaDigits(preview.dealCount) }}» معامله منتقل می‌شود.
{{ toFaDigits(preview.leadForms.length) }} فرم سرنخ به مقصد وصل می‌شود.
کاریز «{{ preview.source.name }}» حذف می‌شود.
</p>
</div>
</div>
<dl class="review-list">
<div>
<dt>مبدا</dt>
<dd>{{ preview.source.name }}</dd>
</div>
<div>
<dt>مقصد</dt>
<dd>{{ selectedTarget?.name }}</dd>
</div>
<div>
<dt>روش فرم‌ها</dt>
<dd>همه فرم‌های سرنخ به مقصد وصل می‌شوند.</dd>
</div>
</dl>
</section>
</template>
<p v-else-if="!error" class="muted">
برای شروع ادغام، کاریز مبدا را انتخاب کنید.
<template v-if="sourceName">مبدا فعلی: {{ sourceName }}</template>
</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<UiButton variant="ghost" :disabled="busy" @click="emit('close')">بستن</UiButton>
<UiButton v-if="step === 'map'" variant="ghost" :disabled="busy" @click="step = 'target'">
قبلی
</UiButton>
<UiButton v-else-if="step === 'review'" variant="ghost" :disabled="busy" @click="step = 'map'">
قبلی
</UiButton>
<UiButton v-if="step === 'target'" :disabled="!preview || !canContinueTarget" @click="nextFromTarget">
ادامه
</UiButton>
<UiButton v-else-if="step === 'map'" :disabled="!canContinueMap" @click="nextFromMap">
بازبینی
</UiButton>
<UiButton v-else variant="danger" :loading="busy" :disabled="!canContinueMap" @click="submitMerge">
ادغام و حذف مبدا
</UiButton>
</div>
</div>
</template>
<style scoped>
.merge-wizard {
display: grid;
gap: 1rem;
}
.steps {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
padding: 0;
margin: 0;
list-style: none;
}
.steps li {
padding: 0.3rem 0.65rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-size: 0.85rem;
}
.steps li.active {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.steps li.done {
color: var(--ink);
}
.step-panel {
display: grid;
gap: 1rem;
}
.intro {
display: flex;
gap: 0.65rem;
align-items: flex-start;
}
.intro h3,
.intro p {
margin: 0;
}
.intro h3 {
font-size: 1rem;
}
.intro p {
margin-top: 0.25rem;
color: var(--muted);
line-height: 1.8;
}
.summary-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.summary-grid article {
display: grid;
gap: 0.2rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-subtle);
}
.summary-grid strong {
color: var(--ink);
}
.summary-grid span,
.muted,
.note {
color: var(--muted);
font-size: 0.9rem;
}
.summary-grid .warn {
border-color: var(--danger);
background: var(--danger-soft);
}
.map-list {
display: grid;
gap: 0.65rem;
}
.map-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 18rem);
gap: 0.75rem;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.stage-info {
display: grid;
gap: 0.25rem;
}
.stage-info span {
color: var(--muted);
font-size: 0.85rem;
}
.review-list {
display: grid;
gap: 0.65rem;
margin: 0;
}
.review-list div {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--line);
}
.review-list dt,
.review-list dd {
margin: 0;
}
.review-list dt {
color: var(--muted);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-start;
padding-top: 0.75rem;
border-top: 1px solid var(--line);
}
.error {
color: #b91c1c;
margin: 0;
}
@media (max-width: 720px) {
.map-row {
grid-template-columns: 1fr;
}
}
</style>
+15 -17
View File
@@ -5,6 +5,7 @@ import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { UiButton, UiCard, UiEmptyState, UiField, UiInput, UiModal, UiPageHeader } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import PipelineMergeWizard from '@/components/pipeline/PipelineMergeWizard.vue';
import type { PipelineDto } from '@mento/shared';
type PipelineListItem = PipelineDto & {
@@ -21,9 +22,6 @@ const mergeOpen = ref(false);
const mergeSourceId = ref<string | null>(null);
const canMerge = computed(() => pipelines.value.length >= 2);
const mergeSource = computed(() =>
pipelines.value.find((pipeline) => pipeline.id === mergeSourceId.value) ?? null,
);
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
@@ -77,6 +75,13 @@ function openMerge(pipeline: PipelineListItem) {
function closeMerge() {
mergeOpen.value = false;
mergeSourceId.value = null;
}
async function onMergeDone() {
okMsg.value = 'کاریز با موفقیت ادغام شد.';
await load();
closeMerge();
}
onMounted(load);
@@ -149,16 +154,13 @@ onMounted(load);
/>
<UiModal :open="mergeOpen" title="ادغام کاریز" @close="closeMerge">
<!-- TODO(Task 4): mount PipelineMergeWizard here with mergeSourceId and close events. -->
<p class="modal-copy">
ویزارد ادغام در مرحله بعد اضافه می‌شود.
</p>
<p v-if="mergeSource" class="muted">
کاریز مبدا: {{ mergeSource.name }}
</p>
<template #footer>
<UiButton variant="ghost" @click="closeMerge">بستن</UiButton>
</template>
<PipelineMergeWizard
:open="mergeOpen"
:source-id="mergeSourceId"
:pipelines="pipelines"
@close="closeMerge"
@done="onMergeDone"
/>
</UiModal>
</section>
</template>
@@ -241,10 +243,6 @@ onMounted(load);
font-size: 0.85rem;
}
.modal-copy {
margin: 0 0 0.5rem;
}
.ok {
color: #0f766e;
margin: 0;