feat(web): pipeline merge wizard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user