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

741 lines
21 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 ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import UiBusyOverlay from '@/components/ui/UiBusyOverlay.vue';
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 SavedFiltersBar from '@/components/SavedFiltersBar.vue';
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
import {
DEAL_EXCEL_ALIASES,
DEAL_EXCEL_COLUMNS,
EXCEL_CUSTOM_FIELD_PREFIX,
brand,
} from '@mento/shared';
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
key: c.key,
label: c.header,
required: c.key === 'title',
}));
const dealClientReadOptions = {
rawValues: true,
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
};
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 busy = ref(false);
const importMsg = ref('');
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),
}),
);
const dealsSavedFilters = computed(() => ({
q: search.value.trim() || undefined,
amountMin: amountMin.value || undefined,
amountMax: amountMax.value || undefined,
pipelineId: selectedPipelineId.value || undefined,
viewMode: viewMode.value,
}));
function applyDealsSavedFilters(filters: Record<string, unknown>) {
search.value = typeof filters.q === 'string' ? filters.q : '';
amountMin.value =
filters.amountMin == null || filters.amountMin === ''
? ''
: String(filters.amountMin);
amountMax.value =
filters.amountMax == null || filters.amountMax === ''
? ''
: String(filters.amountMax);
if (filters.viewMode === 'list' || filters.viewMode === 'kanban') {
viewMode.value = filters.viewMode;
}
const nextPipeline =
typeof filters.pipelineId === 'string' ? filters.pipelineId : '';
if (nextPipeline && nextPipeline !== selectedPipelineId.value) {
selectedPipelineId.value = nextPipeline;
}
}
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 } });
}
const pageBusy = ref(false);
const busyMessage = ref('لطفاً صبر کنید…');
async function exportExcel() {
busy.value = true;
pageBusy.value = true;
busyMessage.value = 'در حال تهیه خروجی اکسل…';
importMsg.value = '';
error.value = '';
try {
const { data } = await api.get<Blob>('/deals/export', { responseType: 'blob' });
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-deals.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
busy.value = false;
pageBusy.value = false;
}
}
function onImportStart() {
busy.value = true;
pageBusy.value = false;
importMsg.value = '';
error.value = '';
}
async function onImportDone(data: ImportContactsResult) {
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}${data.boards?.length ? ` · مسیر فروش: ${data.boards.join('، ')}` : ''}`;
if (data.errors.length) {
error.value = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
}
if (data.boardId && data.boardId !== selectedPipelineId.value) {
await router.replace({ name: 'pipeline', query: { pipelineId: data.boardId } });
} else {
await load();
}
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
busy.value = false;
pageBusy.value = false;
}
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">
<UiBusyOverlay :open="pageBusy" :message="busyMessage" />
<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"
>
<template #excel>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<ExcelImportFlow
preview-path="/deals/import/preview"
import-path="/deals/import"
rows-import-path="/deals/import/rows"
:client-aliases="DEAL_EXCEL_ALIASES"
:client-fields="dealExcelFields"
:client-read-options="dealClientReadOptions"
:disabled="loading || busy"
@start="onImportStart"
@done="onImportDone"
@fail="onImportFail"
@settled="onImportSettled"
>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy">
ورود از اکسل
</button>
</ExcelImportFlow>
</template>
</PipelineToolbar>
<SavedFiltersBar
scope="deals"
:current-filters="dealsSavedFilters"
@apply="applyDealsSavedFilters"
/>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="importMsg" class="ok">{{ importMsg }}</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;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.delete-copy {
margin: 0;
line-height: 1.7;
color: var(--ink);
}
.menu-pop {
position: fixed;
inset-inline-start: 1rem;
bottom: calc(var(--mobile-tabbar-h) + 1.75rem);
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>