Files
CRM_Montzar/apps/web/src/views/ProjectBoardView.vue
T
Mahdi 32e27d45f7 Add desk-style task workflow on Activities and Project boards.
Managers can switch my desk vs team, reassign activities, and filter project cards to their work; assignees can see and move cards.
2026-09-28 12:28:14 +03:30

725 lines
20 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 ExcelImportFlow from '@/components/ExcelImportFlow.vue';
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 mineOnly = ref(route.query.mine === '1');
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 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,
mineUserId: mineOnly.value ? auth.user?.id ?? null : null,
}),
);
watch(mineOnly, (on) => {
const current = route.query.mine === '1';
if (on === current) return;
const query = { ...route.query } as Record<string, string | undefined>;
if (on) query.mine = '1';
else delete query.mine;
void router.replace({ name: 'projects', query });
});
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 onImportStart() {
busy.value = true;
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 !== selectedBoardId.value) {
await router.replace({ name: 'projects', query: { boardId: data.boardId } });
} else {
await load();
}
}
function onImportFail(message: string) {
error.value = message;
}
function onImportSettled() {
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>
<label class="mine-toggle">
<input v-model="mineOnly" type="checkbox" />
<span>فقط کارهای من</span>
</label>
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
خروجی اکسل
</button>
<ExcelImportFlow
preview-path="/project-cards/import/preview"
import-path="/project-cards/import"
: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>
<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;
}
.mine-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-inline-end: 0.35rem;
padding: 0.4rem 0.7rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
user-select: none;
}
.mine-toggle input {
accent-color: #0f766e;
}
.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: 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>