feat(web): add stage path strip with Didar-style move confirm

Let users jump stages from activity detail and deal form only after confirming انجام بده / انجام نده.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-11 13:55:43 +03:30
co-authored by Cursor
parent 871d7bba1a
commit d7cb92bf81
3 changed files with 212 additions and 1 deletions
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Button as AButton, Flex as AFlex, theme } from 'ant-design-vue';
import { LeftOutlined } from '@ant-design/icons-vue';
export type StagePathItem = {
id: string;
name: string;
};
const props = withDefaults(
defineProps<{
stages: StagePathItem[];
value?: string | null;
loading?: boolean;
disabled?: boolean;
ariaLabel?: string;
}>(),
{
value: null,
loading: false,
disabled: false,
ariaLabel: 'مراحل',
},
);
const emit = defineEmits<{
'update:value': [id: string];
select: [id: string];
}>();
const { token } = theme.useToken();
const currentIndex = computed(() =>
props.stages.findIndex((stage) => stage.id === props.value),
);
const stripStyle = computed(() => ({
width: '100%',
overflowX: 'auto' as const,
paddingBottom: '2px',
}));
const sepStyle = computed(() => ({
color: token.value.colorTextQuaternary,
fontSize: '10px',
flexShrink: 0,
}));
function onPick(id: string) {
if (props.disabled || props.loading || id === props.value) return;
emit('update:value', id);
emit('select', id);
}
</script>
<template>
<div
role="listbox"
:aria-label="ariaLabel"
:aria-busy="loading || undefined"
:style="stripStyle"
>
<a-flex align="center" :gap="6" :wrap="false" :style="{ width: 'max-content', minWidth: '100%' }">
<template v-for="(stage, index) in stages" :key="stage.id">
<LeftOutlined v-if="index > 0" :style="sepStyle" aria-hidden="true" />
<a-button
role="option"
:aria-selected="stage.id === value"
:type="stage.id === value ? 'primary' : 'default'"
:disabled="disabled || loading"
:style="{
borderRadius: '999px',
flexShrink: 0,
fontWeight: stage.id === value || index <= currentIndex ? 600 : 500,
opacity: index < currentIndex ? 0.85 : 1,
}"
@click="onPick(stage.id)"
>
{{ stage.name }}
</a-button>
</template>
</a-flex>
</div>
</template>
+73 -1
View File
@@ -17,11 +17,14 @@ import JalaliDateInput from '@/components/JalaliDateInput.vue';
import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
import StagePathStrip from '@/components/StagePathStrip.vue';
import { message, Modal } from 'ant-design-vue';
import type {
ActivityChecklistItem,
ActivityDto,
ActivityImageDto,
ActivityPriority,
ActivityStageDto,
OrgUserDto,
TeamDto,
} from '@mento/shared';
@@ -79,6 +82,8 @@ const loading = ref(true);
const saving = ref(false);
const error = ref('');
const activity = ref<ActivityDto | null>(null);
const boardStages = ref<ActivityStageDto[]>([]);
const stageMoving = ref(false);
type NoteMedia = {
id: string;
name: string;
@@ -687,7 +692,7 @@ async function load() {
activity.value = data;
hydrateDraft(data);
shareOpen.value = {};
await loadActivityImages(data);
await Promise.all([loadActivityImages(data), loadBoardStages(data)]);
assignUserIds.value = data.assignees?.length
? data.assignees.map((u) => u.id)
: data.assignee?.id
@@ -696,11 +701,61 @@ async function load() {
} catch {
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
activity.value = null;
boardStages.value = [];
} finally {
loading.value = false;
}
}
async function loadBoardStages(row: ActivityDto) {
const boardId =
row.boardId ||
(typeof route.query.board === 'string' && route.query.board ? route.query.board : undefined);
try {
const { data } = await api.get<ActivityStageDto[]>('/activities/stages', {
params: boardId ? { boardId } : undefined,
});
boardStages.value = Array.isArray(data) ? data : [];
} catch {
boardStages.value = [];
}
}
function moveToStage(stageId: string) {
if (!id.value || !activity.value || stageMoving.value || saving.value) return;
if (activity.value.stageId === stageId) return;
const target = boardStages.value.find((stage) => stage.id === stageId);
if (!target) return;
Modal.confirm({
title: 'جابه‌جایی مرحله',
content: `به ستون «${target.name}» منتقل می‌شود. انجام شود؟`,
okText: 'انجام بده',
cancelText: 'انجام نده',
centered: true,
async onOk() {
stageMoving.value = true;
error.value = '';
try {
const { data } = await api.patch<ActivityDto>(`/activities/${id.value}/stage`, { stageId });
if (data.status !== target.kind) {
message.info(
'سهم شما ثبت شد. فعالیت وقتی انجام‌شده می‌شود که همهٔ مسئول‌ها سهم خود را بزنند.',
);
await load();
return;
}
activity.value = data;
hydrateDraft(data);
} catch {
error.value = 'جابه‌جایی مرحله ناموفق بود.';
throw new Error('stage-move-failed');
} finally {
stageMoving.value = false;
}
},
});
}
function clearImageUrls() {
for (const image of imageUrls.value) URL.revokeObjectURL(image.url);
imageUrls.value = [];
@@ -1024,6 +1079,17 @@ onUnmounted(() => {
</span>
</header>
<div v-if="boardStages.length" class="stage-path">
<StagePathStrip
:stages="boardStages"
:value="activity.stageId"
:loading="stageMoving"
:disabled="saving"
aria-label="ستون‌های بورد فعالیت"
@select="moveToStage"
/>
</div>
<dl class="meta">
<div>
<dt>
@@ -1718,6 +1784,12 @@ onUnmounted(() => {
min-width: 0;
}
.stage-path {
margin-top: 0.35rem;
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
.hero h2 {
margin: 0;
font-size: 1.05rem;
+54
View File
@@ -18,6 +18,7 @@ import {
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Modal,
Modal as AModal,
Row as ARow,
Segmented as ASegmented,
@@ -47,6 +48,7 @@ import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import StagePathStrip from '@/components/StagePathStrip.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import type {
@@ -104,6 +106,7 @@ const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدی
const loading = ref(false);
const saving = ref(false);
const stageMoving = ref(false);
const error = ref('');
const showOthers = ref(true);
const othersKeys = ref<string[]>(['others']);
@@ -197,6 +200,43 @@ const pipelineSelectOptions = computed(() =>
const stageSelectOptions = computed(() =>
stages.value.map((s) => ({ value: s.id, label: s.name })),
);
const stagePathItems = computed(() => stages.value.map((s) => ({ id: s.id, name: s.name })));
function onStageSelect(nextId: string) {
if (!nextId || nextId === stageId.value || stageMoving.value || saving.value) return;
const target = stages.value.find((stage) => stage.id === nextId);
if (!target) return;
const prev = stageId.value;
Modal.confirm({
title: 'جابه‌جایی مرحله',
content: `به مرحله «${target.name}» منتقل می‌شود. انجام شود؟`,
okText: 'انجام بده',
cancelText: 'انجام نده',
centered: true,
async onOk() {
stageId.value = nextId;
if (isNew.value || !id.value) return;
stageMoving.value = true;
error.value = '';
try {
await api.patch(`/deals/${id.value}/move`, { stageId: nextId });
} catch (e) {
stageId.value = prev;
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'جابه‌جایی مرحله ناموفق بود.');
} else {
error.value = 'جابه‌جایی مرحله ناموفق بود.';
}
throw e;
} finally {
stageMoving.value = false;
}
},
});
}
const productSelectOptions = computed(() =>
products.value.map((p) => ({ value: p.id, label: p.name })),
);
@@ -665,6 +705,20 @@ onMounted(load);
</a-space>
</a-flex>
<div
v-if="stagePathItems.length"
:style="{ marginBottom: '16px' }"
>
<StagePathStrip
:stages="stagePathItems"
:value="stageId"
:loading="stageMoving"
:disabled="saving || loading"
aria-label="مراحل مسیر فروش"
@select="onStageSelect"
/>
</div>
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '16px' }" />
<a-form layout="vertical" @finish="submit">