From d7cb92bf81a49d3444c99cb693b4b97a991a6aaf Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 11 Nov 2026 13:55:43 +0330 Subject: [PATCH] feat(web): add stage path strip with Didar-style move confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users jump stages from activity detail and deal form only after confirming انجام بده / انجام نده. Co-authored-by: Cursor --- apps/web/src/components/StagePathStrip.vue | 85 ++++++++++++++++++++++ apps/web/src/views/ActivityDetailView.vue | 74 ++++++++++++++++++- apps/web/src/views/DealFormView.vue | 54 ++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/StagePathStrip.vue diff --git a/apps/web/src/components/StagePathStrip.vue b/apps/web/src/components/StagePathStrip.vue new file mode 100644 index 0000000..413616c --- /dev/null +++ b/apps/web/src/components/StagePathStrip.vue @@ -0,0 +1,85 @@ + + + diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 43ac172..83ff6a4 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -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(null); +const boardStages = ref([]); +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('/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(`/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(() => { +
+ +
+
@@ -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; diff --git a/apps/web/src/views/DealFormView.vue b/apps/web/src/views/DealFormView.vue index 949067d..fde84b4 100644 --- a/apps/web/src/views/DealFormView.vue +++ b/apps/web/src/views/DealFormView.vue @@ -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(['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); +
+ +
+