Files
crm/apps/web/src/views/ProjectBoardView.vue
T
MahdiandCursor c2bfa7749d Add project board, loyalty club, and if/then automations.
Separate modules for post-sales kanban, person points/tiers, and deal-won/ticket-resolved rules without overlapping the sales pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 09:42:07 +03:30

392 lines
9.6 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, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import type {
ProjectBoardBoardDto,
ProjectBoardDto,
ProjectCardDto,
} from '@mento/shared';
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 board = ref<ProjectBoardBoardDto | null>(null);
const boards = ref<ProjectBoardDto[]>([]);
const draggingId = ref<string | null>(null);
const dropStageId = ref<string | null>(null);
const showCreate = ref(false);
const newName = ref('');
const selectedBoardId = computed(() => {
const q = route.query.boardId;
if (typeof q === 'string' && q) return q;
return board.value?.board.id ?? '';
});
const cardCount = computed(
() => board.value?.stages.reduce((n, s) => n + s.cards.length, 0) ?? 0,
);
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,
});
board.value = data;
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;
}
}
function onSelectBoard(event: Event) {
const id = (event.target as HTMLSelectElement).value;
void router.replace({ name: 'projects', query: { boardId: id } });
}
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 onDragStart(card: ProjectCardDto, event: DragEvent) {
draggingId.value = card.id;
event.dataTransfer?.setData('text/plain', card.id);
event.dataTransfer!.effectAllowed = 'move';
}
function onDragEnd() {
draggingId.value = null;
dropStageId.value = null;
}
function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault();
dropStageId.value = stageId;
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
}
async function onDrop(stageId: string, event: DragEvent) {
event.preventDefault();
const cardId = event.dataTransfer?.getData('text/plain') || draggingId.value;
dropStageId.value = null;
draggingId.value = null;
if (!cardId || !board.value) return;
const current = board.value.stages.find((s) =>
s.cards.some((c) => c.id === cardId),
);
if (current?.id === stageId) return;
try {
await api.patch(`/project-cards/${cardId}/move`, { stageId });
await load();
} catch {
error.value = 'جابه‌جایی کارت ناموفق بود.';
}
}
function openCard(id: string) {
void router.push({ name: 'project-card-detail', params: { id } });
}
watch(
() => route.query.boardId,
(id, prev) => {
if (id === prev) return;
void load();
},
);
onMounted(load);
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<p class="eyebrow">پس‌ازفروش</p>
<h1>{{ board?.board.name || 'کاریز پروژه' }}</h1>
<p class="lead">
کارهای داخلی و تحویل را روی برد جابه‌جا کنید.
<span v-if="board">{{ cardCount }} کارت</span>
</p>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
<RouterLink
class="btn"
:to="{
name: 'project-card-new',
query: selectedBoardId ? { boardId: selectedBoardId } : undefined,
}"
>
کارت جدید
</RouterLink>
</div>
</header>
<div class="pipeline-bar">
<label class="picker">
<span>برد</span>
<select
:value="selectedBoardId"
:disabled="loading || !boards.length"
@change="onSelectBoard"
>
<option v-for="b in boards" :key="b.id" :value="b.id">
{{ b.name }}{{ b.isDefault ? ' (پیش‌فرض)' : '' }}
</option>
</select>
</label>
<button
v-if="canCreateBoard"
class="btn btn-ghost"
type="button"
@click="showCreate = !showCreate"
>
برد جدید
</button>
</div>
<form
v-if="showCreate && canCreateBoard"
class="create-box"
@submit.prevent="createBoard"
>
<input
v-model="newName"
type="text"
placeholder="نام برد جدید…"
maxlength="120"
required
minlength="2"
/>
<button class="btn" type="submit" :disabled="saving || newName.trim().length < 2">
{{ saving ? 'در حال ساخت…' : 'ساخت' }}
</button>
<button class="btn btn-ghost" type="button" @click="showCreate = false">انصراف</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading && !board" class="muted">در حال بارگذاری…</p>
<div v-if="board" class="board">
<div
v-for="stage in board.stages"
:key="stage.id"
class="column"
:class="{ drop: dropStageId === stage.id }"
@dragover="onDragOver(stage.id, $event)"
@dragleave="dropStageId = null"
@drop="onDrop(stage.id, $event)"
>
<div class="column-head">
<h2>{{ stage.name }}</h2>
<span>{{ stage.cards.length }}</span>
</div>
<div class="cards">
<article
v-for="card in stage.cards"
:key="card.id"
class="card"
:class="{ dragging: draggingId === card.id }"
draggable="true"
@dragstart="onDragStart(card, $event)"
@dragend="onDragEnd"
@dblclick="openCard(card.id)"
>
<h3 @click="openCard(card.id)">{{ card.title }}</h3>
<p v-if="card.person || card.company" class="meta">
<span v-if="card.person">{{ card.person.fullName }}</span>
<span v-if="card.person && card.company"> · </span>
<span v-if="card.company">{{ card.company.name }}</span>
</p>
<p v-if="card.deal" class="meta">معامله: {{ card.deal.title }}</p>
<p v-if="card.owner" class="meta">مسئول: {{ card.owner.fullName }}</p>
</article>
<p v-if="!stage.cards.length" class="empty">خالی</p>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
font-size: 1.85rem;
}
.lead,
.muted {
color: var(--muted);
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.pipeline-bar {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
}
.picker {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 200px;
min-width: 0;
color: var(--muted);
font-size: 0.9rem;
}
.picker select {
flex: 1;
min-width: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
background: #fff;
font: inherit;
}
.create-box {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.create-box input {
flex: 1 1 180px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.85rem;
font: inherit;
}
.error {
color: #b42318;
}
.board {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(240px, 280px);
gap: 0.85rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.column {
background: #f6f7f9;
border: 1px solid var(--line);
border-radius: 16px;
padding: 0.75rem;
min-height: 320px;
}
.column.drop {
outline: 2px dashed var(--accent);
}
.column-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.65rem;
}
.column-head h2 {
margin: 0;
font-size: 1rem;
}
.column-head span {
color: var(--muted);
font-size: 0.85rem;
}
.cards {
display: grid;
gap: 0.55rem;
}
.card {
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.8rem;
cursor: grab;
}
.card.dragging {
opacity: 0.55;
}
.card h3 {
margin: 0 0 0.35rem;
font-size: 0.98rem;
cursor: pointer;
}
.meta {
margin: 0.15rem 0 0;
color: var(--muted);
font-size: 0.82rem;
}
.empty {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
</style>