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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user