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

695 lines
19 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 {
PROJECT_BOARD_COPY,
filterStages,
mapProjectBoardToStages,
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 ProjectCardFormView from '@/views/ProjectCardFormView.vue';
import type {
ImportContactsResult,
ProjectBoardBoardDto,
ProjectBoardDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const canCreateBoard = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const stages = ref<PipelineStage[]>([]);
const boards = ref<ProjectBoardDto[]>([]);
const currentBoardId = ref('');
const viewMode = ref<PipelineViewMode>('kanban');
const search = ref('');
const showCreate = ref(false);
const newName = ref('');
const cardModal = ref<{ cardId?: string; stageId?: string } | null>(null);
const deleteStageId = ref<string | null>(null);
const stageBusy = ref(false);
const busy = ref(false);
const importMsg = ref('');
const fileInput = ref<HTMLInputElement | null>(null);
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 boardOptions = computed(() =>
boards.value.map((b) => ({
id: b.id,
name: b.name,
})),
);
const selectedBoardId = computed({
get: () => {
const q = route.query.boardId;
if (typeof q === 'string' && q) return q;
return currentBoardId.value;
},
set: (id: string) => {
void router.replace({ name: 'projects', query: { boardId: id } });
},
});
const visibleStages = computed(() =>
filterStages(stages.value, { query: search.value }),
);
function formatCardDate(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliNumeric(d);
}
function findAccent(cardId: string): PipelineStage['color'] {
for (const stage of stages.value) {
if (stage.deals.some((d) => d.id === cardId)) return stage.color;
}
return 'green';
}
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;
}
async function loadBoards() {
const { data } = await api.get<ProjectBoardDto[]>('/project-boards');
boards.value = data;
}
async function load() {
loading.value = true;
error.value = '';
try {
await loadBoards();
const boardId =
typeof route.query.boardId === 'string' ? route.query.boardId : undefined;
const { data } = await api.get<ProjectBoardBoardDto>('/project-boards/board', {
params: boardId ? { boardId } : undefined,
});
currentBoardId.value = data.board.id;
stages.value = mapProjectBoardToStages(data, formatCardDate);
if (!boardId || boardId !== data.board.id) {
await router.replace({
name: 'projects',
query: { boardId: data.board.id },
});
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری برد پروژه ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createBoard() {
const name = newName.value.trim();
if (name.length < 2 || saving.value) return;
saving.value = true;
error.value = '';
try {
const { data } = await api.post<ProjectBoardDto>('/project-boards', { name });
newName.value = '';
showCreate.value = false;
await router.replace({ name: 'projects', query: { boardId: 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 applyUpdateCardStage(
cardId: string,
stageId: string,
toIndex?: number,
) {
const prev = stages.value;
const next = updateDealStage(prev, cardId, stageId, toIndex);
const target = next.find((s) => s.id === stageId);
const position = target?.deals.findIndex((d) => d.id === cardId) ?? toIndex;
stages.value = next;
moving.value = true;
error.value = '';
try {
await api.patch(`/project-cards/${cardId}/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 cardId = raw;
if (!cardId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
await applyUpdateCardStage(cardId, stageId, index);
}
function onAddCard(stageId: string) {
cardModal.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 (!currentBoardId.value || !canCreateBoard.value) return;
stages.value = next;
stageBusy.value = true;
error.value = '';
try {
await api.patch(`/project-boards/${currentBoardId.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 (!currentBoardId.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(`/project-boards/${currentBoardId.value}/stages/${stageId}`, {
name: title,
});
} catch (e) {
stages.value = prev;
error.value = apiMessage(e, 'تغییر نام ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
async function onAddStage(name: string) {
if (!currentBoardId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.post(`/project-boards/${currentBoardId.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 || !currentBoardId.value || stageBusy.value) return;
stageBusy.value = true;
error.value = '';
try {
await api.delete(`/project-boards/${currentBoardId.value}/stages/${stageId}`);
deleteStageId.value = null;
await load();
} catch (e) {
error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
} finally {
stageBusy.value = false;
}
}
async function onCardSaved() {
cardModal.value = null;
await load();
}
function onOpenCard(cardId: string) {
menuOpenId.value = null;
void router.push({ name: 'project-card-detail', params: { id: cardId } });
}
async function exportExcel() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const { data } = await api.get<Blob>('/project-cards/export', {
responseType: 'blob',
});
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-project-cards.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
busy.value = false;
}
}
function pickImportFile() {
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ImportContactsResult>(
'/project-cards/import',
form,
{ headers: { 'Content-Type': undefined } },
);
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 !== selectedBoardId.value) {
await router.replace({ name: 'projects', query: { boardId: data.boardId } });
} else {
await load();
}
} catch (e) {
error.value = apiMessage(e, 'ایمپورت ناموفق بود');
} finally {
busy.value = false;
}
}
function onMenuCard(cardId: string) {
menuOpenId.value = menuOpenId.value === cardId ? null : cardId;
}
watch(
() => route.query.boardId,
(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);
});
</script>
<template>
<section class="page" dir="rtl">
<PipelineToolbar
pipeline-name="کاریز پروژه"
icon="grid"
create-option-label="برد جدید…"
:pipeline-options="boardOptions"
v-model:selected-pipeline-id="selectedBoardId"
v-model:view-mode="viewMode"
v-model:search="search"
:show-amount-filters="false"
:can-create="canCreateBoard"
@create="showCreate = true"
>
<template #excel>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="pickImportFile">
ورود از اکسل
</button>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
</template>
</PipelineToolbar>
<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"
:copy="PROJECT_BOARD_COPY"
@open-deal="onOpenCard"
@menu-deal="onMenuCard"
/>
<PipelineBoard
v-else-if="viewMode === 'kanban' && stages.length"
:stages="visibleStages"
:dragging-id="draggingId"
:drop-stage-id="dropStageId"
:can-manage="canCreateBoard"
:copy="PROJECT_BOARD_COPY"
@drag-start="onDragStart"
@drag-end="onDragEnd"
@drag-over="onDragOver"
@drop="onDrop"
@add-deal="onAddCard"
@open-deal="onOpenCard"
@menu-deal="onMenuCard"
@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"
:copy="PROJECT_BOARD_COPY"
:x="overlayPos.x"
:y="overlayPos.y"
/>
</Teleport>
<div v-if="menuOpenId" class="menu-pop" role="menu">
<button type="button" role="menuitem" @click="onOpenCard(menuOpenId)">
مشاهده کارت
</button>
<button
type="button"
role="menuitem"
@click="cardModal = { cardId: menuOpenId ?? undefined }; menuOpenId = null"
>
ویرایش
</button>
<button type="button" role="menuitem" @click="menuOpenId = null">بستن</button>
</div>
<UiModal
:open="showCreate && canCreateBoard"
title="برد جدید"
@close="showCreate = false"
>
<form class="create-form" @submit.prevent="createBoard">
<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="createBoard"
>
{{ 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(cardModal)"
:title="cardModal?.cardId ? 'ویرایش کارت' : 'کارت جدید'"
@close="cardModal = null"
>
<ProjectCardFormView
v-if="cardModal"
embedded
:card-id="cardModal.cardId"
:initial-board-id="selectedBoardId || undefined"
:initial-stage-id="cardModal.stageId"
@saved="onCardSaved"
@cancel="cardModal = 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;
}
.btn-ghost-sm {
border: 1px solid #e2e8f0;
background: #fff;
color: #0f766e;
border-radius: 10px;
padding: 0.45rem 0.75rem;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
font: inherit;
}
.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: 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>