diff --git a/apps/api/src/leads/lead-stages.service.ts b/apps/api/src/leads/lead-stages.service.ts index 34ed70a..055830d 100644 --- a/apps/api/src/leads/lead-stages.service.ts +++ b/apps/api/src/leads/lead-stages.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import type { LeadStageDto } from '@mento/shared'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; const DEFAULT_STAGES: Array<{ @@ -74,20 +75,24 @@ export class LeadStagesService { where: { organizationId }, _max: { position: true }, }); - const row = await this.prisma.leadStage.create({ - data: { - organizationId, - name: dto.name.trim(), - color: dto.color?.trim() || 'teal', - showOnBoard: dto.showOnBoard !== false, - isConverted: Boolean(dto.isConverted), - isLost: Boolean(dto.isLost), - isUnqualified: Boolean(dto.isUnqualified), - position: (max._max.position ?? -1) + 1, - }, - include: { _count: { select: { leads: true } } }, - }); - return this.toDto(row); + try { + const row = await this.prisma.leadStage.create({ + data: { + organizationId, + name: this.cleanName(dto.name), + color: dto.color?.trim() || 'teal', + showOnBoard: dto.showOnBoard !== false, + isConverted: Boolean(dto.isConverted), + isLost: Boolean(dto.isLost), + isUnqualified: Boolean(dto.isUnqualified), + position: (max._max.position ?? -1) + 1, + }, + include: { _count: { select: { leads: true } } }, + }); + return this.toDto(row); + } catch (e) { + this.rethrow(e); + } } async update( @@ -103,10 +108,11 @@ export class LeadStagesService { position?: number; }, ): Promise { + try { await this.prisma.leadStage.updateMany({ where: { id, organizationId }, data: { - ...(dto.name != null ? { name: dto.name.trim() } : {}), + ...(dto.name != null ? { name: this.cleanName(dto.name) } : {}), ...(dto.color != null ? { color: dto.color } : {}), ...(dto.showOnBoard != null ? { showOnBoard: dto.showOnBoard } : {}), ...(dto.isConverted != null ? { isConverted: dto.isConverted } : {}), @@ -122,6 +128,9 @@ export class LeadStagesService { include: { _count: { select: { leads: true } } }, }); return this.toDto(row); + } catch (e) { + this.rethrow(e); + } } async reorder(organizationId: string, stageIds: string[]): Promise { @@ -143,11 +152,27 @@ export class LeadStagesService { }); if (!stage) return; if (stage._count.leads > 0) { - throw new Error('مرحله‌ای که لید دارد قابل حذف نیست'); + throw new BadRequestException( + 'این ستون هنوز لید دارد و حذف نمی‌شود. لیدهای فعال و بایگانی را اول به ستون دیگری ببرید.', + ); } await this.prisma.leadStage.delete({ where: { id } }); } + private cleanName(raw: string): string { + const name = raw?.trim() ?? ''; + if (!name) throw new BadRequestException('نام ستون لازم است'); + if (name.length > 80) throw new BadRequestException('نام ستون حداکثر ۸۰ کاراکتر است'); + return name; + } + + private rethrow(e: unknown): never { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new BadRequestException('ستونی با این نام وجود دارد'); + } + throw e; + } + private toDto(row: { id: string; name: string; diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index 26b3919..2c2f96d 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -107,6 +107,7 @@ export class LeadsController { await this.stages.remove(user.organizationId, id); return { ok: true }; } catch (e) { + if (e instanceof BadRequestException) throw e; throw new BadRequestException( e instanceof Error ? e.message : 'حذف مرحله ناموفق بود', ); diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index a069263..d75ed7d 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -979,8 +979,10 @@ export class LeadsService { const stages = await this.stages.list(actor.organizationId); const convertedIds = stages.filter((s) => s.isConverted).map((s) => s.id); const lostIds = stages.filter((s) => s.isLost).map((s) => s.id); - const interested = stages.find((s) => s.name.includes('علاقه‌مند')); - const newStage = stages.find((s) => s.name === 'جدید'); + // همان مقصد فرم و ورود از اکسل: اولین ستون باز، مستقل از اسم «جدید» + const newStage = stages.find( + (s) => !s.isConverted && !s.isLost && !s.isUnqualified, + ); const [ total, @@ -988,7 +990,6 @@ export class LeadsService { unassigned, converted, lost, - interestedCount, withFollowUp, callsLogged, byAssigneeRaw, @@ -1008,11 +1009,6 @@ export class LeadsService { where: { ...base, stageId: { in: lostIds } }, }) : Promise.resolve(0), - interested - ? this.prisma.lead.count({ - where: { ...base, stageId: interested.id }, - }) - : Promise.resolve(0), this.prisma.lead.count({ where: { ...base, nextFollowUpAt: { not: null } }, }), @@ -1055,7 +1051,7 @@ export class LeadsService { .sort((a, b) => b.count - a.count), callsLogged, withFollowUp, - interested: interestedCount, + interested: 0, converted, lost, conversionRate: Math.round((converted / denom) * 1000) / 10, diff --git a/apps/web/src/views/LeadBoardView.vue b/apps/web/src/views/LeadBoardView.vue index 5e9cff0..b02c80a 100644 --- a/apps/web/src/views/LeadBoardView.vue +++ b/apps/web/src/views/LeadBoardView.vue @@ -4,11 +4,16 @@ import { RouterLink, useRouter } from 'vue-router'; import axios from 'axios'; import { Alert as AAlert, - Avatar as AAvatar, Button as AButton, Card as ACard, + Checkbox as ACheckbox, + Dropdown as ADropdown, Empty as AEmpty, Flex as AFlex, + Input as AInput, + Menu as AMenu, + MenuItem as AMenuItem, + Modal as AModal, Space as ASpace, Spin as ASpin, Tag as ATag, @@ -18,25 +23,43 @@ import { import { CalendarOutlined, HolderOutlined, + MoreOutlined, PhoneOutlined, + PlusOutlined, ReloadOutlined, UnorderedListOutlined, } from '@ant-design/icons-vue'; import { api } from '@/lib/api'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; -import type { LeadBoardColumnDto, LeadDto } from '@mento/shared'; +import { canManageSettings } from '@/lib/roles'; +import { useAuthStore } from '@/stores/auth'; +import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared'; const { Title: ATypographyTitle, Text: ATypographyText } = Typography; const { token } = theme.useToken(); const router = useRouter(); +const auth = useAuthStore(); +const canManage = computed(() => canManageSettings(auth.user?.role)); const GAP = 16; const COL_WIDTH = 280; const loading = ref(false); const moving = ref(false); +const stageSaving = ref(false); const error = ref(''); +const stageError = ref(''); const columns = ref([]); +const stages = ref([]); +const renameTarget = ref(null); +const renameName = ref(''); +const addOpen = ref(false); +const addName = ref(''); +const addConverted = ref(false); +const addLost = ref(false); +const addUnqualified = ref(false); +const deleteTarget = ref(null); +const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null); const draggingId = ref(null); const dropStageId = ref(null); /** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */ @@ -137,14 +160,6 @@ const cardBodyStyle = computed(() => ({ margin: 0, })); -function leadInitials(lead: LeadDto) { - const raw = (lead.contactName || lead.title || '?').trim(); - const parts = raw.split(/\s+/).filter(Boolean); - if (!parts.length) return '؟'; - if (parts.length === 1) return parts[0]!.slice(0, 1); - return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`; -} - function leadSubtitle(lead: LeadDto) { return [lead.contactName, lead.city].filter(Boolean).join(' · ') || ''; } @@ -158,22 +173,288 @@ function formatDate(value: string | null) { } } +function apiMessage(e: unknown, fallback: string) { + if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message; + if (Array.isArray(msg)) return msg[0] ?? fallback; + if (typeof msg === 'string' && msg) return msg; + if (!e.response) return 'اتصال به API برقرار نشد.'; + } + return fallback; +} + +function isOpenStage(stage: Pick) { + return !stage.isConverted && !stage.isLost && !stage.isUnqualified; +} + +function entryStage(list: LeadStageDto[]) { + return list.find(isOpenStage) ?? null; +} + async function load() { loading.value = true; error.value = ''; try { - const { data } = await api.get('/leads/board'); - columns.value = data; + const [board, stageRows] = await Promise.all([ + api.get('/leads/board'), + api.get('/leads/stages'), + ]); + columns.value = board.data; + stages.value = stageRows.data; } catch (e) { - error.value = - axios.isAxiosError(e) && !e.response - ? 'اتصال به API برقرار نشد.' - : 'بارگذاری مسیر لیدها ناموفق بود.'; + error.value = apiMessage(e, 'بارگذاری مسیر لیدها ناموفق بود.'); } finally { loading.value = false; } } +function visibleIndex(stageId: string) { + return stages.value.filter((stage) => stage.showOnBoard).findIndex((stage) => stage.id === stageId); +} + +function plannedOrder(stageId: string, dir: -1 | 1) { + const visible = stages.value.filter((stage) => stage.showOnBoard); + const from = visible.findIndex((stage) => stage.id === stageId); + const to = from + dir; + if (from < 0 || to < 0 || to >= visible.length) return null; + const nextVisible = visible.slice(); + const [item] = nextVisible.splice(from, 1); + if (!item) return null; + nextVisible.splice(to, 0, item); + const queue = nextVisible.slice(); + return stages.value.map((stage) => (stage.showOnBoard ? queue.shift()!.id : stage.id)); +} + +function entryIn(ids: string[]) { + const byId = new Map(stages.value.map((stage) => [stage.id, stage])); + for (const id of ids) { + const stage = byId.get(id); + if (stage && isOpenStage(stage)) return stage; + } + return null; +} + +async function persistOrder(stageIds: string[]) { + stageSaving.value = true; + error.value = ''; + try { + await api.patch('/leads/stages/reorder', { stageIds }); + moveConfirm.value = null; + await load(); + } catch (e) { + error.value = apiMessage(e, 'جابه‌جایی ستون ناموفق بود.'); + } finally { + stageSaving.value = false; + } +} + +function requestMove(stageId: string, dir: -1 | 1) { + const next = plannedOrder(stageId, dir); + if (!next) return; + const before = entryStage(stages.value); + const after = entryIn(next); + if (before && after && before.id !== after.id) { + moveConfirm.value = { + stageIds: next, + message: `با این جابه‌جایی، لیدهای تازهٔ فرم و ورود از اکسل به‌جای «${before.name}» در ستون «${after.name}» ثبت می‌شوند. لیدهای فعلی سر جایشان می‌مانند.`, + }; + return; + } + void persistOrder(next); +} + +function nameTaken(name: string, exceptId?: string) { + const trimmed = name.trim(); + return stages.value.some((stage) => stage.id !== exceptId && stage.name === trimmed); +} + +function openRename(stage: LeadStageDto) { + stageError.value = ''; + renameName.value = stage.name; + renameTarget.value = stage; +} + +function openAdd() { + stageError.value = ''; + addName.value = ''; + addConverted.value = false; + addLost.value = false; + addUnqualified.value = false; + addOpen.value = true; +} + +function openDelete(stage: LeadStageDto) { + stageError.value = ''; + deleteTarget.value = stage; +} + +function onColumnMenu(stageId: string, key: string) { + const stage = stages.value.find((item) => item.id === stageId); + if (!stage) return; + if (key === 'rename') openRename(stage); + else if (key === 'right') requestMove(stageId, -1); + else if (key === 'left') requestMove(stageId, 1); + else if (key === 'delete') openDelete(stage); +} + +const renameNotes = computed(() => { + const stage = renameTarget.value; + if (!stage) return []; + const lines = [ + 'اسم جدید در فهرست لیدها، صفحهٔ جزئیات، فرم‌ساز و فیلتر پیامک هم دیده می‌شود. لیدهای این ستون سر جایشان می‌مانند.', + ]; + if (entryStage(stages.value)?.id === stage.id) { + lines.push('شمارش لیدهای تازه به اسم ستون وابسته نیست و با این تغییر نام به‌هم نمی‌ریزد.'); + } + if (stage.isConverted) lines.push('علامت تبدیل‌شده روی همین ستون می‌ماند.'); + if (stage.isLost) lines.push('علامت از دست‌رفته روی همین ستون می‌ماند و آمار همان را می‌شمارد.'); + if (stage.isUnqualified) lines.push('علامت نامرتبط روی همین ستون می‌ماند.'); + return lines; +}); + +const addNotes = computed(() => { + const lines = [ + 'ستون در انتهای مسیر اضافه می‌شود. لیدهای ستون‌های دیگر جابه‌جا نمی‌شوند.', + ]; + const flags = [addConverted.value, addLost.value, addUnqualified.value].filter(Boolean).length; + if (flags > 1) { + lines.push( + 'یک ستون را با یک علامت بسازید. اگر تبدیل‌شده، از دست‌رفته و نامرتبط با هم باشند، فرم و آمار هر کدام رفتار جدا دارند.', + ); + } else if (!flags) { + lines.push('بدون علامت، این ستون مرحلهٔ عادی است و مقصد تبدیل به مشتری یا آمار از دست‌رفته نمی‌شود.'); + } + if (addConverted.value && stages.value.some((stage) => stage.isConverted)) { + lines.push( + 'ستون تبدیل‌شدهٔ دیگری هم هست. تبدیل به مشتری به اولین ستون تبدیل‌شده در ترتیب مسیر می‌رود.', + ); + } + if (addLost.value && stages.value.some((stage) => stage.isLost)) { + lines.push('ستون از دست‌رفتهٔ دیگری هم هست. آمار، لیدهای هر دو ستون را می‌شمارد.'); + } + return lines; +}); + +const deleteInfo = computed(() => { + const stage = deleteTarget.value; + if (!stage) return { blocked: '', lines: [] as string[] }; + const total = columns.value.find((col) => col.stage.id === stage.id)?.total ?? stage.leadCount ?? 0; + if (total > 0) { + return { + blocked: `ستون «${stage.name}» ${toFaDigits(total)} لید دارد و حذف نمی‌شود. اول لیدها را به ستون دیگری ببرید.`, + lines: [] as string[], + }; + } + const others = stages.value.filter((item) => item.id !== stage.id); + const lines = [ + 'این ستون از فهرست لیدها، جزئیات، فرم‌ساز و فیلتر پیامک هم برداشته می‌شود.', + ]; + if (stage.isConverted && !others.some((item) => item.isConverted)) { + lines.push( + 'این تنها ستون تبدیل‌شده است. بعد از حذف، تبدیل به مشتری هنوز انجام می‌شود ولی لید به ستون تبدیل‌شده منتقل نمی‌شود.', + ); + } + if (stage.isLost && !others.some((item) => item.isLost)) { + lines.push('این تنها ستون از دست‌رفته است. بعد از حذف، آمار لیدهای از دست‌رفته دیگر ستونی برای شمردن ندارد.'); + } + if (stage.isUnqualified && !others.some((item) => item.isUnqualified)) { + lines.push('این تنها ستون نامرتبط است. بعد از حذف، مسیر دیگر ستون مشخصی برای لید نامرتبط ندارد.'); + } + if (entryStage(stages.value)?.id === stage.id) { + const next = others.find(isOpenStage); + lines.push( + next + ? `لیدهای تازهٔ فرم و ورود از اکسل به‌جای این ستون در «${next.name}» ثبت می‌شوند.` + : 'ستون باز دیگری نمی‌ماند. لید تازهٔ فرم در اولین ستون باقی‌مانده ثبت می‌شود.', + ); + } + lines.push('اگر فرمی این ستون را مرحلهٔ اولیه کرده باشد، لید تازه به اولین ستون باز می‌رود.'); + return { blocked: '', lines }; +}); + +async function saveRename() { + const stage = renameTarget.value; + const name = renameName.value.trim(); + if (!stage || stageSaving.value) return; + if (name.length < 1) { + stageError.value = 'نام ستون لازم است.'; + return; + } + if (name.length > 80) { + stageError.value = 'نام ستون حداکثر ۸۰ کاراکتر است.'; + return; + } + if (nameTaken(name, stage.id)) { + stageError.value = 'ستونی با این نام وجود دارد.'; + return; + } + if (name === stage.name) { + renameTarget.value = null; + return; + } + stageSaving.value = true; + stageError.value = ''; + try { + await api.patch(`/leads/stages/${stage.id}`, { name }); + renameTarget.value = null; + await load(); + } catch (e) { + stageError.value = apiMessage(e, 'تغییر نام ستون ناموفق بود.'); + } finally { + stageSaving.value = false; + } +} + +async function saveAdd() { + const name = addName.value.trim(); + if (stageSaving.value) return; + if (!name) { + stageError.value = 'نام ستون لازم است.'; + return; + } + if (name.length > 80) { + stageError.value = 'نام ستون حداکثر ۸۰ کاراکتر است.'; + return; + } + if (nameTaken(name)) { + stageError.value = 'ستونی با این نام وجود دارد.'; + return; + } + stageSaving.value = true; + stageError.value = ''; + try { + await api.post('/leads/stages', { + name, + showOnBoard: true, + isConverted: addConverted.value, + isLost: addLost.value, + isUnqualified: addUnqualified.value, + }); + addOpen.value = false; + await load(); + } catch (e) { + stageError.value = apiMessage(e, 'افزودن ستون ناموفق بود.'); + } finally { + stageSaving.value = false; + } +} + +async function confirmDelete() { + const stage = deleteTarget.value; + if (!stage || deleteInfo.value.blocked || stageSaving.value) return; + stageSaving.value = true; + stageError.value = ''; + try { + await api.delete(`/leads/stages/${stage.id}`); + deleteTarget.value = null; + await load(); + } catch (e) { + stageError.value = apiMessage(e, 'حذف ستون ناموفق بود.'); + } finally { + stageSaving.value = false; + } +} + function openLead(lead: LeadDto) { if (suppressCardClick || draggingId.value || moving.value) return; void router.push({ name: 'lead-detail', params: { id: lead.id } }); @@ -223,11 +504,16 @@ async function onDrop(stageId: string, event: DragEvent) { columns.value = columns.value.map((c) => { if (c.stage.id === fromCol.stage.id) { - return { ...c, leads: c.leads.filter((l) => l.id !== leadId) }; + return { + ...c, + total: Math.max(0, c.total - 1), + leads: c.leads.filter((l) => l.id !== leadId), + }; } if (c.stage.id === stageId) { return { ...c, + total: c.total + 1, leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads], }; } @@ -257,7 +543,7 @@ onMounted(load); مسیر لیدها - کشیدن و رها کردن برای تغییر وضعیت + کشیدن کارت برای تغییر وضعیت. از منوی هر ستون می‌توانید نام، ترتیب و حذف را عوض کنید. @@ -298,9 +584,14 @@ onMounted(load); + > + + + افزودن ستون + +
{{ col.stage.name }} - - {{ toFaDigits(col.leads.length) }} - + + + {{ toFaDigits(col.total) }} + + + + + + + +
@@ -376,16 +689,6 @@ onMounted(load); > - - {{ leadInitials(lead) }} -
+ + + + افزودن ستون + + + + + + + + + + + + + + + + + علامت تبدیل‌شده + علامت از دست‌رفته + علامت نامرتبط + + + + + + + + + + + + + +