2026-08-30 09:42:07 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-09-15 19:13:30 +03:30
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router';
|
2026-08-30 09:42:07 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
2026-09-15 19:13:30 +03:30
|
|
|
|
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';
|
2026-09-19 15:30:31 +03:30
|
|
|
|
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
2026-09-15 19:13:30 +03:30
|
|
|
|
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';
|
2026-08-30 09:42:07 +03:30
|
|
|
|
import type {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
ImportContactsResult,
|
2026-08-30 09:42:07 +03:30
|
|
|
|
ProjectBoardBoardDto,
|
|
|
|
|
|
ProjectBoardDto,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-09-15 17:33:05 +03:30
|
|
|
|
import { brand } from '@mento/shared';
|
2026-09-15 19:13:30 +03:30
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
2026-08-30 09:42:07 +03:30
|
|
|
|
import { canManageSettings } from '@/lib/roles';
|
|
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const canCreateBoard = computed(() => canManageSettings(auth.user?.role));
|
2026-09-15 19:13:30 +03:30
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const error = ref('');
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const stages = ref<PipelineStage[]>([]);
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const boards = ref<ProjectBoardDto[]>([]);
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const currentBoardId = ref('');
|
|
|
|
|
|
const viewMode = ref<PipelineViewMode>('kanban');
|
|
|
|
|
|
const search = ref('');
|
2026-09-28 12:28:14 +03:30
|
|
|
|
const mineOnly = ref(route.query.mine === '1');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const showCreate = ref(false);
|
|
|
|
|
|
const newName = ref('');
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const cardModal = ref<{ cardId?: string; stageId?: string } | null>(null);
|
|
|
|
|
|
const deleteStageId = ref<string | null>(null);
|
|
|
|
|
|
const stageBusy = ref(false);
|
2026-09-15 17:33:05 +03:30
|
|
|
|
const busy = ref(false);
|
|
|
|
|
|
const importMsg = ref('');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
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 } });
|
|
|
|
|
|
},
|
2026-08-30 09:42:07 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const visibleStages = computed(() =>
|
2026-09-28 12:28:14 +03:30
|
|
|
|
filterStages(stages.value, {
|
|
|
|
|
|
query: search.value,
|
|
|
|
|
|
mineUserId: mineOnly.value ? auth.user?.id ?? null : null,
|
|
|
|
|
|
}),
|
2026-08-30 09:42:07 +03:30
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-28 12:28:14 +03:30
|
|
|
|
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 });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-09-15 19:13:30 +03:30
|
|
|
|
currentBoardId.value = data.board.id;
|
|
|
|
|
|
stages.value = mapProjectBoardToStages(data, formatCardDate);
|
2026-08-30 09:42:07 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
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);
|
2026-08-30 09:42:07 +03:30
|
|
|
|
event.dataTransfer!.effectAllowed = 'move';
|
2026-09-15 19:13:30 +03:30
|
|
|
|
if (event.dataTransfer) {
|
|
|
|
|
|
const img = new Image();
|
|
|
|
|
|
img.src =
|
|
|
|
|
|
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
|
|
|
|
event.dataTransfer.setDragImage(img, 0, 0);
|
|
|
|
|
|
}
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDragEnd() {
|
|
|
|
|
|
draggingId.value = null;
|
|
|
|
|
|
dropStageId.value = null;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
overlayDeal.value = null;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onDragOver(stageId: string, event: DragEvent) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
dropStageId.value = stageId;
|
|
|
|
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
2026-09-15 19:13:30 +03:30
|
|
|
|
if (event.clientX || event.clientY) {
|
|
|
|
|
|
overlayPos.value = { x: event.clientX + 12, y: event.clientY + 12 };
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
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 } });
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
async function exportExcel() {
|
|
|
|
|
|
busy.value = true;
|
|
|
|
|
|
importMsg.value = '';
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
const { data } = await api.get<Blob>('/project-cards/export', {
|
|
|
|
|
|
responseType: 'blob',
|
|
|
|
|
|
});
|
2026-09-15 17:33:05 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:30:31 +03:30
|
|
|
|
function onImportStart() {
|
2026-09-15 17:33:05 +03:30
|
|
|
|
busy.value = true;
|
|
|
|
|
|
importMsg.value = '';
|
|
|
|
|
|
error.value = '';
|
2026-09-19 15:30:31 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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(' | ');
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
2026-09-19 15:30:31 +03:30
|
|
|
|
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;
|
2026-09-15 17:33:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
function onMenuCard(cardId: string) {
|
|
|
|
|
|
menuOpenId.value = menuOpenId.value === cardId ? null : cardId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
|
watch(
|
|
|
|
|
|
() => route.query.boardId,
|
|
|
|
|
|
(id, prev) => {
|
|
|
|
|
|
if (id === prev) return;
|
|
|
|
|
|
void load();
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
window.addEventListener('drag', onPointerMove);
|
|
|
|
|
|
window.addEventListener('dragover', onPointerMove);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
window.removeEventListener('drag', onPointerMove);
|
|
|
|
|
|
window.removeEventListener('dragover', onPointerMove);
|
|
|
|
|
|
});
|
2026-08-30 09:42:07 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
<section class="page" dir="rtl">
|
2026-09-15 19:13:30 +03:30
|
|
|
|
<PipelineToolbar
|
2026-09-28 12:28:14 +03:30
|
|
|
|
pipeline-name="کاریز پروژه — کارهای تیمی"
|
2026-09-15 19:13:30 +03:30
|
|
|
|
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>
|
2026-09-28 12:28:14 +03:30
|
|
|
|
<label class="mine-toggle">
|
|
|
|
|
|
<input v-model="mineOnly" type="checkbox" />
|
|
|
|
|
|
<span>فقط کارهای من</span>
|
|
|
|
|
|
</label>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<button class="btn-ghost-sm" type="button" :disabled="loading || busy" @click="exportExcel">
|
|
|
|
|
|
خروجی اکسل
|
|
|
|
|
|
</button>
|
2026-09-19 15:30:31 +03:30
|
|
|
|
<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>
|
2026-09-15 19:13:30 +03:30
|
|
|
|
</template>
|
|
|
|
|
|
</PipelineToolbar>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
|
|
|
|
|
<p v-if="error" class="error">{{ error }}</p>
|
2026-09-15 17:33:05 +03:30
|
|
|
|
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
|
2026-09-15 19:13:30 +03:30
|
|
|
|
<p v-else-if="loading && !stages.length" class="muted">در حال بارگذاری برد…</p>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
<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"
|
2026-08-30 09:42:07 +03:30
|
|
|
|
>
|
2026-09-15 19:13:30 +03:30
|
|
|
|
ویرایش
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" role="menuitem" @click="menuOpenId = null">بستن</button>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
</div>
|
2026-09-15 19:13:30 +03:30
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.page {
|
|
|
|
|
|
display: grid;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
gap: 0.15rem;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
min-width: 0;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
max-width: 100%;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
direction: rtl;
|
|
|
|
|
|
text-align: right;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
container-type: inline-size;
|
|
|
|
|
|
container-name: pipeline;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 12:28:14 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 02:29:41 +03:30
|
|
|
|
.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;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
font: inherit;
|
|
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
.create-form .field {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.35rem;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
.create-form .field span {
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.muted {
|
|
|
|
|
|
color: var(--muted);
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
|
.error {
|
2026-09-15 19:13:30 +03:30
|
|
|
|
color: var(--danger);
|
|
|
|
|
|
font-weight: 600;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-15 17:33:05 +03:30
|
|
|
|
.ok {
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
.delete-copy {
|
2026-08-30 09:42:07 +03:30
|
|
|
|
margin: 0;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
color: var(--ink);
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
.menu-pop {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset-inline-start: 1rem;
|
2026-09-17 17:36:21 +03:30
|
|
|
|
bottom: calc(var(--mobile-tabbar-h) + 1.75rem);
|
2026-09-15 19:13:30 +03:30
|
|
|
|
z-index: 40;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
background: #fff;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
box-shadow: var(--shadow);
|
|
|
|
|
|
padding: 0.35rem;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.15rem;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-09-15 19:13:30 +03:30
|
|
|
|
.menu-pop button {
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
padding: 0.45rem 0.85rem;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
cursor: pointer;
|
2026-09-15 19:13:30 +03:30
|
|
|
|
font: inherit;
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
text-align: right;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
</style>
|