Files
crm/apps/web/src/views/PipelineBoardView.vue
T

598 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric } from '@/lib/jalali';
import PipelineToolbar from '@/components/pipeline/PipelineToolbar.vue';
import type { PipelineViewMode } from '@/components/pipeline/PipelineToolbar.vue';
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
import PipelineList from '@/components/pipeline/PipelineList.vue';
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
import type { PipelineDeal, PipelineStage } from '@/lib/pipeline-board';
import {
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';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canCreatePipeline = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const stages = ref<PipelineStage[]>([]);
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');
const overlayPos = ref({ x: 0, y: 0 });
const menuOpenId = ref<string | null>(null);
const moving = ref(false);
const pipelineOptions = computed(() =>
pipelines.value.map((p) => ({
id: p.id,
name: p.name,
})),
);
const selectedPipelineId = computed({
get: () => {
const q = route.query.pipelineId;
if (typeof q === 'string' && q) return q;
return boardPipelineId.value;
},
set: (id: string) => {
void router.replace({ name: 'pipeline', query: { pipelineId: id } });
},
});
const visibleStages = computed(() =>
filterStages(stages.value, {
query: search.value,
amountMin: amountMin.value === '' ? null : Number(amountMin.value),
amountMax: amountMax.value === '' ? null : Number(amountMax.value),
}),
);
function formatDealDate(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliNumeric(d);
}
function findAccent(dealId: string): PipelineStage['color'] {
for (const stage of stages.value) {
if (stage.deals.some((d) => d.id === dealId)) return stage.color;
}
return 'green';
}
async function loadPipelines() {
const { data } = await api.get<PipelineDto[]>('/pipelines');
pipelines.value = data;
}
async function load() {
loading.value = true;
error.value = '';
try {
await loadPipelines();
const pipelineId =
typeof route.query.pipelineId === 'string'
? route.query.pipelineId
: undefined;
const { data } = await api.get<PipelineBoardDto>('/pipelines/board', {
params: pipelineId ? { pipelineId } : undefined,
});
boardPipelineId.value = data.pipeline.id;
stages.value = mapBoardToStages(data, formatDealDate);
if (!pipelineId || pipelineId !== data.pipeline.id) {
await router.replace({
name: 'pipeline',
query: { pipelineId: data.pipeline.id },
});
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری کاریز ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createPipeline() {
const name = newName.value.trim();
if (name.length < 2 || saving.value) return;
saving.value = true;
error.value = '';
try {
const { data } = await api.post<PipelineDto>('/pipelines', { name });
newName.value = '';
showCreate.value = false;
await router.replace({ name: 'pipeline', query: { pipelineId: data.id } });
} catch {
error.value = 'ساخت کاریز ناموفق بود.';
} finally {
saving.value = false;
}
}
function onPointerMove(event: PointerEvent | MouseEvent) {
if (!draggingId.value) return;
overlayPos.value = { x: event.clientX + 12, y: event.clientY + 12 };
}
function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingId.value = deal.id;
overlayDeal.value = deal;
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
event.dataTransfer?.setData('text/plain', deal.id);
event.dataTransfer!.effectAllowed = 'move';
if (event.dataTransfer) {
const img = new Image();
img.src =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0);
}
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
if (event.clientX || event.clientY) {
overlayPos.value = { x: event.clientX + 12, y: event.clientY + 12 };
}
}
async function applyUpdateDealStage(
dealId: string,
stageId: string,
toIndex?: number,
) {
const prev = stages.value;
const next = updateDealStage(prev, dealId, stageId, toIndex);
const target = next.find((s) => s.id === stageId);
const position = target?.deals.findIndex((d) => d.id === dealId) ?? toIndex;
stages.value = next;
moving.value = true;
error.value = '';
try {
await api.patch(`/deals/${dealId}/move`, {
stageId,
position: position ?? undefined,
});
} catch {
stages.value = prev;
error.value = 'جابه‌جایی معامله ناموفق بود.';
} finally {
moving.value = false;
}
}
async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault();
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));
const currentIndex = current?.deals.findIndex((d) => d.id === dealId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
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) {
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) {
menuOpenId.value = null;
void router.push({ name: 'deal-detail', params: { id: dealId } });
}
function onMenuDeal(dealId: string) {
menuOpenId.value = menuOpenId.value === dealId ? null : dealId;
}
watch(
() => route.query.pipelineId,
(id, prev) => {
if (id === prev) return;
void load();
},
);
onMounted(() => {
void load();
window.addEventListener('drag', onPointerMove);
window.addEventListener('dragover', onPointerMove);
});
onBeforeUnmount(() => {
window.removeEventListener('drag', onPointerMove);
window.removeEventListener('dragover', onPointerMove);
});
defineExpose({ updateDealStage: applyUpdateDealStage });
</script>
<template>
<section class="page" dir="rtl">
<PipelineToolbar
pipeline-name="کاریز فروش"
:pipeline-options="pipelineOptions"
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"
@create="showCreate = true"
/>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری کاریز…</p>
<PipelineList
v-if="viewMode === 'list' && (!loading || stages.length)"
:stages="visibleStages"
@open-deal="onOpenDeal"
@menu-deal="onMenuDeal"
/>
<PipelineBoard
v-else-if="viewMode === 'kanban' && stages.length"
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreatePipeline"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@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">
<DealCardOverlay
v-if="overlayDeal && draggingId"
:deal="overlayDeal"
:accent="overlayAccent"
:x="overlayPos.x"
:y="overlayPos.y"
/>
</Teleport>
<div v-if="menuOpenId" class="menu-pop" role="menu">
<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>
<style scoped>
.page {
display: grid;
gap: 0.15rem;
min-width: 0;
max-width: 100%;
direction: rtl;
text-align: right;
container-type: inline-size;
container-name: pipeline;
}
.create-form .field {
display: grid;
gap: 0.35rem;
}
.create-form .field span {
font-size: 0.85rem;
color: var(--muted);
}
.muted {
color: var(--muted);
}
.error {
color: var(--danger);
font-weight: 600;
}
.delete-copy {
margin: 0;
line-height: 1.7;
color: var(--ink);
}
.menu-pop {
position: fixed;
inset-inline-start: 1rem;
bottom: 5.5rem;
z-index: 40;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 0.35rem;
display: grid;
gap: 0.15rem;
}
.menu-pop button {
border: 0;
background: transparent;
padding: 0.45rem 0.85rem;
cursor: pointer;
font: inherit;
color: var(--ink);
text-align: right;
}
</style>