Unify the web UI on shared controls and let pipeline columns be managed on the board.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 01:22:24 +03:30
co-authored by Cursor
parent f1aba1b386
commit b8ad4037b6
40 changed files with 2334 additions and 788 deletions
+254 -81
View File
@@ -11,10 +11,15 @@ import PipelineList from '@/components/pipeline/PipelineList.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import type { PipelineDeal, PipelineStage } from '@/lib/pipeline-board';
import {
filterStagesByQuery,
filterStages,
mapBoardToStages,
moveStageToIndex,
updateDealStage,
} from '@/lib/pipeline-board';
import UiButton from '@/components/ui/UiButton.vue';
import UiInput from '@/components/ui/UiInput.vue';
import UiModal from '@/components/ui/UiModal.vue';
import DealFormView from '@/views/DealFormView.vue';
import type { PipelineBoardDto, PipelineDto } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
@@ -32,10 +37,16 @@ const pipelines = ref<PipelineDto[]>([]);
const boardPipelineId = ref('');
const viewMode = ref<PipelineViewMode>('kanban');
const search = ref('');
const amountMin = ref('');
const amountMax = ref('');
const showCreate = ref(false);
const newName = ref('');
const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null);
const deleteStageId = ref<string | null>(null);
const stageBusy = ref(false);
const draggingId = ref<string | null>(null);
const draggingColumnId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null);
const overlayAccent = ref<PipelineStage['color']>('green');
@@ -62,7 +73,11 @@ const selectedPipelineId = computed({
});
const visibleStages = computed(() =>
filterStagesByQuery(stages.value, search.value),
filterStages(stages.value, {
query: search.value,
amountMin: amountMin.value === '' ? null : Number(amountMin.value),
amountMax: amountMax.value === '' ? null : Number(amountMax.value),
}),
);
function formatDealDate(iso: string) {
@@ -197,10 +212,21 @@ async function applyUpdateDealStage(
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
const dealId = event.dataTransfer?.getData('text/plain') || draggingId.value;
const raw =
event.dataTransfer?.getData('text/plain') ||
draggingColumnId.value ||
draggingId.value ||
'';
dropStageId.value = null;
draggingId.value = null;
overlayDeal.value = null;
if (raw.startsWith('stage:') || draggingColumnId.value) {
const fromId = raw.replace(/^stage:/, '') || draggingColumnId.value;
draggingColumnId.value = null;
if (fromId) await reorderColumn(fromId, stageId);
return;
}
const dealId = raw;
if (!dealId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === dealId));
@@ -212,14 +238,128 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
await applyUpdateDealStage(dealId, stageId, index);
}
function apiMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
if (typeof msg === 'string' && msg) return msg;
if (Array.isArray(msg) && msg[0]) return String(msg[0]);
}
return fallback;
}
function onAddDeal(stageId: string) {
void router.push({
name: 'deal-new',
query: {
pipelineId: selectedPipelineId.value || undefined,
stageId,
},
});
dealModal.value = { stageId };
}
function onColumnDragStart(stageId: string) {
draggingColumnId.value = stageId;
}
function onColumnDragEnd() {
draggingColumnId.value = null;
dropStageId.value = null;
}
async function persistStageOrder(next: PipelineStage[], prev: PipelineStage[]) {
if (!boardPipelineId.value || !canCreatePipeline.value) return;
stages.value = next;
stageBusy.value = true;
error.value = '';
try {
await api.patch(`/pipelines/${boardPipelineId.value}/stages/order`, {
stageIds: next.map((s) => s.id),
});
} catch (e) {
stages.value = prev;
error.value = apiMessage(e, 'جابه‌جایی ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
async function onMoveStage(stageId: string, dir: -1 | 1) {
if (stageBusy.value) return;
const from = stages.value.findIndex((s) => s.id === stageId);
const to = from + dir;
if (from < 0 || to < 0 || to >= stages.value.length) return;
await persistStageOrder(
moveStageToIndex(stages.value, stageId, to),
stages.value,
);
}
async function reorderColumn(fromId: string, toId: string) {
if (stageBusy.value || fromId === toId) return;
const to = stages.value.findIndex((s) => s.id === toId);
if (to < 0) return;
await persistStageOrder(
moveStageToIndex(stages.value, fromId, to),
stages.value,
);
}
async function onRenameStage(stageId: string, title: string) {
if (!boardPipelineId.value || stageBusy.value) return;
const prev = stages.value;
stages.value = prev.map((s) =>
s.id === stageId ? { ...s, title } : s,
);
stageBusy.value = true;
error.value = '';
try {
await api.patch(`/pipelines/${boardPipelineId.value}/stages/${stageId}`, {
name: title,
});
} catch (e) {
stages.value = prev;
error.value = apiMessage(e, 'تغییر نام ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
async function onAddStage(name: string) {
if (!boardPipelineId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.post(`/pipelines/${boardPipelineId.value}/stages`, { name });
await load();
} catch (e) {
error.value = apiMessage(e, 'افزودن ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
function onDeleteStage(stageId: string) {
deleteStageId.value = stageId;
}
const deleteStageTitle = computed(() => {
const id = deleteStageId.value;
return stages.value.find((s) => s.id === id)?.title ?? '';
});
async function confirmDeleteStage() {
const stageId = deleteStageId.value;
if (!stageId || !boardPipelineId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
deleteStageId.value = null;
await load();
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
async function onDealSaved() {
dealModal.value = null;
await load();
}
function onOpenDeal(dealId: string) {
@@ -231,10 +371,6 @@ function onMenuDeal(dealId: string) {
menuOpenId.value = menuOpenId.value === dealId ? null : dealId;
}
function onFilter() {
// UI فیلتر — اتصال سیستم فیلتر پیشرفته بعداً
}
watch(
() => route.query.pipelineId,
(id, prev) => {
@@ -262,36 +398,15 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
<PipelineToolbar
pipeline-name="کاریز فروش"
:pipeline-options="pipelineOptions"
:selected-pipeline-id="selectedPipelineId"
:view-mode="viewMode"
:search="search"
v-model:selected-pipeline-id="selectedPipelineId"
v-model:view-mode="viewMode"
v-model:search="search"
v-model:amount-min="amountMin"
v-model:amount-max="amountMax"
:can-create="canCreatePipeline"
@update:view-mode="viewMode = $event"
@update:search="search = $event"
@update:selected-pipeline-id="selectedPipelineId = $event"
@filter="onFilter"
@create="showCreate = !showCreate"
@create="showCreate = true"
/>
<form
v-if="showCreate && canCreatePipeline"
class="create-box"
@submit.prevent="createPipeline"
>
<input
v-model="newName"
type="text"
placeholder="نام کاریز جدید…"
maxlength="120"
required
minlength="2"
/>
<button class="btn" type="submit" :disabled="saving || newName.trim().length < 2">
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</button>
<button class="btn-ghost-sm" type="button" @click="showCreate = false">انصراف</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری کاریز…</p>
@@ -307,6 +422,7 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@@ -314,6 +430,12 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
@add-deal="onAddDeal"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
@rename-stage="onRenameStage"
@delete-stage="onDeleteStage"
@move-stage="onMoveStage"
@column-drag-start="onColumnDragStart"
@column-drag-end="onColumnDragEnd"
@add-stage="onAddStage"
/>
<Teleport to="body">
@@ -330,8 +452,85 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
<button type="button" role="menuitem" @click="onOpenDeal(menuOpenId)">
مشاهده معامله
</button>
<button
type="button"
role="menuitem"
@click="dealModal = { dealId: menuOpenId ?? undefined }; menuOpenId = null"
>
ویرایش
</button>
<button type="button" role="menuitem" @click="menuOpenId = null">بستن</button>
</div>
<UiModal
:open="showCreate && canCreatePipeline"
title="کاریز جدید"
@close="showCreate = false"
>
<form class="create-form" @submit.prevent="createPipeline">
<label class="field">
<span>نام کاریز</span>
<UiInput
v-model="newName"
placeholder="نام کاریز جدید…"
:maxlength="120"
required
:minlength="2"
/>
</label>
</form>
<template #footer>
<UiButton
type="button"
:disabled="saving || newName.trim().length < 2"
@click="createPipeline"
>
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="showCreate = false">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal
:open="Boolean(deleteStageId)"
title="حذف ستون"
@close="deleteStageId = null"
>
<p class="delete-copy">
ستون «{{ deleteStageTitle }}» حذف شود؟ اگر معامله‌ای داخل آن باشد باید
اول آن‌ها را جابه‌جا کنید.
</p>
<template #footer>
<UiButton
type="button"
:disabled="stageBusy"
@click="confirmDeleteStage"
>
{{ stageBusy ? 'در حال حذف…' : 'حذف' }}
</UiButton>
<UiButton variant="ghost" type="button" @click="deleteStageId = null">
انصراف
</UiButton>
</template>
</UiModal>
<UiModal
:open="Boolean(dealModal)"
:title="dealModal?.dealId ? 'ویرایش معامله' : 'ایجاد معامله جدید'"
@close="dealModal = null"
>
<DealFormView
v-if="dealModal"
embedded
:deal-id="dealModal.dealId"
:initial-pipeline-id="selectedPipelineId || undefined"
:initial-stage-id="dealModal.stageId"
@saved="onDealSaved"
@cancel="dealModal = null"
/>
</UiModal>
</section>
</template>
@@ -347,46 +546,14 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
container-name: pipeline;
}
.btn-ghost-sm {
border: 1px solid var(--line);
background: #fff;
color: var(--accent);
border-radius: 10px;
padding: 0.4rem 0.75rem;
font-weight: 700;
.create-form .field {
display: grid;
gap: 0.35rem;
}
.create-form .field span {
font-size: 0.85rem;
cursor: pointer;
}
.create-box {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
min-width: 0;
}
.create-box input {
flex: 1 1 180px;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
background: #fff;
font: inherit;
}
@container pipeline (max-width: 560px) {
.create-box {
flex-direction: column;
align-items: stretch;
}
.create-box input,
.create-box .btn,
.create-box .btn-ghost-sm {
width: 100%;
}
color: var(--muted);
}
.muted {
@@ -398,6 +565,12 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
font-weight: 600;
}
.delete-copy {
margin: 0;
line-height: 1.7;
color: var(--ink);
}
.menu-pop {
position: fixed;
inset-inline-start: 1rem;
@@ -405,7 +578,7 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
z-index: 40;
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.35rem;
display: grid;