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 ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue'; import ActivityPostponeSheet from '@/components/activities/ActivityPostponeSheet.vue';
import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue'; import ActivityFieldsView from '@/components/activities/ActivityFieldsView.vue';
import StagePathStrip from '@/components/StagePathStrip.vue';
import { message, Modal } from 'ant-design-vue';
import type { import type {
ActivityChecklistItem, ActivityChecklistItem,
ActivityDto, ActivityDto,
ActivityImageDto, ActivityImageDto,
ActivityPriority, ActivityPriority,
ActivityStageDto,
OrgUserDto, OrgUserDto,
TeamDto, TeamDto,
} from '@mento/shared'; } from '@mento/shared';
@@ -79,6 +82,8 @@ const loading = ref(true);
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const activity = ref<ActivityDto | null>(null); const activity = ref<ActivityDto | null>(null);
const boardStages = ref<ActivityStageDto[]>([]);
const stageMoving = ref(false);
type NoteMedia = { type NoteMedia = {
id: string; id: string;
name: string; name: string;
@@ -687,7 +692,7 @@ async function load() {
activity.value = data; activity.value = data;
hydrateDraft(data); hydrateDraft(data);
shareOpen.value = {}; shareOpen.value = {};
await loadActivityImages(data); await Promise.all([loadActivityImages(data), loadBoardStages(data)]);
assignUserIds.value = data.assignees?.length assignUserIds.value = data.assignees?.length
? data.assignees.map((u) => u.id) ? data.assignees.map((u) => u.id)
: data.assignee?.id : data.assignee?.id
@@ -696,11 +701,61 @@ async function load() {
} catch { } catch {
error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.'; error.value = 'بارگذاری جزئیات فعالیت ناموفق بود.';
activity.value = null; activity.value = null;
boardStages.value = [];
} finally { } finally {
loading.value = false; 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() { function clearImageUrls() {
for (const image of imageUrls.value) URL.revokeObjectURL(image.url); for (const image of imageUrls.value) URL.revokeObjectURL(image.url);
imageUrls.value = []; imageUrls.value = [];
@@ -1024,6 +1079,17 @@ onUnmounted(() => {
</span> </span>
</header> </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"> <dl class="meta">
<div> <div>
<dt> <dt>
@@ -1718,6 +1784,12 @@ onUnmounted(() => {
min-width: 0; min-width: 0;
} }
.stage-path {
margin-top: 0.35rem;
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
.hero h2 { .hero h2 {
margin: 0; margin: 0;
font-size: 1.05rem; font-size: 1.05rem;
+54
View File
@@ -18,6 +18,7 @@ import {
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
InputNumber as AInputNumber, InputNumber as AInputNumber,
Modal,
Modal as AModal, Modal as AModal,
Row as ARow, Row as ARow,
Segmented as ASegmented, Segmented as ASegmented,
@@ -47,6 +48,7 @@ import EntityTimeline from '@/components/EntityTimeline.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue'; import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue'; import MoneyInput from '@/components/MoneyInput.vue';
import StagePathStrip from '@/components/StagePathStrip.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue'; import UserMultiSelect from '@/components/UserMultiSelect.vue';
import PersonFormView from '@/views/PersonFormView.vue'; import PersonFormView from '@/views/PersonFormView.vue';
import type { import type {
@@ -104,6 +106,7 @@ const pageTitle = computed(() => (isNew.value ? 'ایجاد معامله جدی
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const stageMoving = ref(false);
const error = ref(''); const error = ref('');
const showOthers = ref(true); const showOthers = ref(true);
const othersKeys = ref<string[]>(['others']); const othersKeys = ref<string[]>(['others']);
@@ -197,6 +200,43 @@ const pipelineSelectOptions = computed(() =>
const stageSelectOptions = computed(() => const stageSelectOptions = computed(() =>
stages.value.map((s) => ({ value: s.id, label: s.name })), 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(() => const productSelectOptions = computed(() =>
products.value.map((p) => ({ value: p.id, label: p.name })), products.value.map((p) => ({ value: p.id, label: p.name })),
); );
@@ -665,6 +705,20 @@ onMounted(load);
</a-space> </a-space>
</a-flex> </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-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '16px' }" />
<a-form layout="vertical" @finish="submit"> <a-form layout="vertical" @finish="submit">