From aa3fe49ce6ed33bfd9570f3bcfbeb19ab3afcb2d Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 5 Sep 2026 00:18:39 +0330 Subject: [PATCH] Redesign sales pipeline UI and reconnect it to live board APIs. Keep the employer kanban layout while loading pipelines/deals from the server, persisting drag-and-drop moves, and seeding a Waiting pipeline with demo deals. Co-authored-by: Cursor --- apps/api/prisma/seed.ts | 166 +++++ apps/web/src/components/UiIcon.vue | 16 +- .../src/components/pipeline/AddDealButton.vue | 59 ++ apps/web/src/components/pipeline/DealCard.vue | 227 +++++++ .../components/pipeline/DealCardOverlay.vue | 34 + .../src/components/pipeline/PipelineBoard.vue | 64 ++ .../components/pipeline/PipelineColumn.vue | 209 ++++++ .../components/pipeline/PipelineToolbar.vue | 261 ++++++++ apps/web/src/lib/pipeline-board.ts | 227 +++++++ apps/web/src/views/DealFormView.vue | 7 + apps/web/src/views/PipelineBoardView.vue | 614 +++++++----------- 11 files changed, 1519 insertions(+), 365 deletions(-) create mode 100644 apps/web/src/components/pipeline/AddDealButton.vue create mode 100644 apps/web/src/components/pipeline/DealCard.vue create mode 100644 apps/web/src/components/pipeline/DealCardOverlay.vue create mode 100644 apps/web/src/components/pipeline/PipelineBoard.vue create mode 100644 apps/web/src/components/pipeline/PipelineColumn.vue create mode 100644 apps/web/src/components/pipeline/PipelineToolbar.vue create mode 100644 apps/web/src/lib/pipeline-board.ts diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 9e624e4..0c07153 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -239,6 +239,172 @@ async function main() { console.log('Services pipeline already seeded'); } + // کاریز منتظر — مطابق طراحی UI (۳ مرحله + معاملات نمونه) + const WAITING_STAGES = [ + { name: 'پیگیری', isWon: false, isLost: false }, + { name: 'مذاکرات اولیه', isWon: false, isLost: false }, + { name: 'قیمت و نهایی کردن', isWon: false, isLost: false }, + ] as const; + + const WAITING_DEALS: Array<{ + stageName: (typeof WAITING_STAGES)[number]['name']; + title: string; + notes: string; + amount: number; + expectedCloseAt: string; + position: number; + }> = [ + { + stageName: 'پیگیری', + title: 'نرم‌افزار حسابان', + notes: 'نصب سیستم یکپارچه', + amount: 250_000_000, + expectedCloseAt: '2024-07-15T00:00:00.000Z', + position: 0, + }, + { + stageName: 'پیگیری', + title: 'همکاران سیستم', + notes: 'نسخه سازمانی', + amount: 350_000_000, + expectedCloseAt: '2024-07-08T00:00:00.000Z', + position: 1, + }, + { + stageName: 'پیگیری', + title: 'بازرگانی امید', + notes: 'کاربران سازمانی', + amount: 180_000_000, + expectedCloseAt: '2024-07-17T00:00:00.000Z', + position: 2, + }, + { + stageName: 'پیگیری', + title: 'کالای صنعتی پارس', + notes: 'ماژول انبارداری', + amount: 120_000_000, + expectedCloseAt: '2024-07-23T00:00:00.000Z', + position: 3, + }, + { + stageName: 'مذاکرات اولیه', + title: 'فروشگاه تک‌کالا', + notes: 'ماژول فروش', + amount: 280_000_000, + expectedCloseAt: '2024-07-13T00:00:00.000Z', + position: 0, + }, + { + stageName: 'مذاکرات اولیه', + title: 'شرکت داده‌پرداز', + notes: 'اتصال به حسابداری', + amount: 190_000_000, + expectedCloseAt: '2024-07-10T00:00:00.000Z', + position: 1, + }, + { + stageName: 'مذاکرات اولیه', + title: 'گروه فنی مهندسی آوا', + notes: 'ماژول پروژه‌ها', + amount: 315_000_000, + expectedCloseAt: '2024-07-05T00:00:00.000Z', + position: 2, + }, + { + stageName: 'مذاکرات اولیه', + title: 'رستوران زنجیره‌ای نیک', + notes: 'ماژول رستوران', + amount: 165_000_000, + expectedCloseAt: '2024-07-09T00:00:00.000Z', + position: 3, + }, + { + stageName: 'قیمت و نهایی کردن', + title: 'شرکت تجارت', + notes: 'یکپارچه‌سازی کامل', + amount: 640_000_000, + expectedCloseAt: '2024-06-30T00:00:00.000Z', + position: 0, + }, + { + stageName: 'قیمت و نهایی کردن', + title: 'فروشگاه اینترنتی دیجی‌خونه', + notes: 'فروشگاه اینترنتی', + amount: 420_000_000, + expectedCloseAt: '2024-06-28T00:00:00.000Z', + position: 1, + }, + { + stageName: 'قیمت و نهایی کردن', + title: 'شرکت داروسازی بهداشت', + notes: 'ماژول مالی و انبار', + amount: 780_000_000, + expectedCloseAt: '2024-07-02T00:00:00.000Z', + position: 2, + }, + ]; + + let waitingPipeline = await prisma.pipeline.findFirst({ + where: { organizationId: org.id, name: 'کاریز منتظر' }, + include: { stages: { orderBy: { position: 'asc' } } }, + }); + + if (!waitingPipeline) { + waitingPipeline = await prisma.pipeline.create({ + data: { + organizationId: org.id, + name: 'کاریز منتظر', + isDefault: false, + stages: { + create: WAITING_STAGES.map((s, index) => ({ + name: s.name, + position: index, + isWon: s.isWon, + isLost: s.isLost, + })), + }, + }, + include: { stages: { orderBy: { position: 'asc' } } }, + }); + console.log('Seeded waiting pipeline (کاریز منتظر)'); + } else { + console.log('Waiting pipeline already seeded'); + } + + const waitingDealCount = await prisma.deal.count({ + where: { organizationId: org.id, pipelineId: waitingPipeline.id }, + }); + + if (waitingDealCount === 0) { + const stageByName = new Map( + waitingPipeline.stages.map((s) => [s.name, s] as const), + ); + await prisma.deal.createMany({ + data: WAITING_DEALS.map((d) => { + const stage = stageByName.get(d.stageName); + if (!stage) { + throw new Error(`Missing stage ${d.stageName} on waiting pipeline`); + } + return { + organizationId: org.id, + pipelineId: waitingPipeline!.id, + stageId: stage.id, + title: d.title, + notes: d.notes, + amount: d.amount, + currency: 'IRR', + expectedCloseAt: new Date(d.expectedCloseAt), + ownerId: owner?.id, + position: d.position, + tags: ['دمو-کاریز'], + }; + }), + }); + console.log('Seeded waiting pipeline deals:', WAITING_DEALS.length); + } else { + console.log('Waiting pipeline deals already seeded'); + } + const dealsCount = await prisma.deal.count({ where: { organizationId: org.id }, }); diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index 07ff14e..fdad504 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -48,7 +48,9 @@ defineProps<{ | 'dollar' | 'layers' | 'target' - | 'pencil'; + | 'pencil' + | 'grid' + | 'list'; size?: number; }>(); @@ -275,6 +277,18 @@ defineProps<{ + + diff --git a/apps/web/src/components/pipeline/AddDealButton.vue b/apps/web/src/components/pipeline/AddDealButton.vue new file mode 100644 index 0000000..caa3504 --- /dev/null +++ b/apps/web/src/components/pipeline/AddDealButton.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue new file mode 100644 index 0000000..54cf394 --- /dev/null +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/apps/web/src/components/pipeline/DealCardOverlay.vue b/apps/web/src/components/pipeline/DealCardOverlay.vue new file mode 100644 index 0000000..cabac6a --- /dev/null +++ b/apps/web/src/components/pipeline/DealCardOverlay.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/apps/web/src/components/pipeline/PipelineBoard.vue b/apps/web/src/components/pipeline/PipelineBoard.vue new file mode 100644 index 0000000..60c1cd7 --- /dev/null +++ b/apps/web/src/components/pipeline/PipelineBoard.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/apps/web/src/components/pipeline/PipelineColumn.vue b/apps/web/src/components/pipeline/PipelineColumn.vue new file mode 100644 index 0000000..4cca496 --- /dev/null +++ b/apps/web/src/components/pipeline/PipelineColumn.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/apps/web/src/components/pipeline/PipelineToolbar.vue b/apps/web/src/components/pipeline/PipelineToolbar.vue new file mode 100644 index 0000000..b60d232 --- /dev/null +++ b/apps/web/src/components/pipeline/PipelineToolbar.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/apps/web/src/lib/pipeline-board.ts b/apps/web/src/lib/pipeline-board.ts new file mode 100644 index 0000000..b609ac4 --- /dev/null +++ b/apps/web/src/lib/pipeline-board.ts @@ -0,0 +1,227 @@ +export type PipelineAccent = 'green' | 'orange' | 'purple'; + +export type PipelineDeal = { + id: string; + title: string; + description: string; + amount: number; + date: string; + initials: string; + /** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */ + stageId?: string; + currencyUnit?: string; +}; + +export type PipelineStage = { + id: string; + title: string; + color: PipelineAccent; + deals: PipelineDeal[]; + isWon?: boolean; + isLost?: boolean; +}; + +export const PIPELINE_ACCENT: Record< + PipelineAccent, + { soft: string; solid: string; text: string; border: string } +> = { + green: { + soft: '#ecfdf5', + solid: '#10b981', + text: '#047857', + border: '#a7f3d0', + }, + orange: { + soft: '#fff7ed', + solid: '#f59e0b', + text: '#b45309', + border: '#fde68a', + }, + purple: { + soft: '#f5f3ff', + solid: '#8b5cf6', + text: '#6d28d9', + border: '#ddd6fe', + }, +}; + +const ACCENT_CYCLE: PipelineAccent[] = ['green', 'orange', 'purple']; + +/** خروجی: ۲۵۰,۰۰۰,۰۰۰ تومان */ +export function formatCurrency(amount: number, unit = 'تومان'): string { + try { + return `${new Intl.NumberFormat('fa-IR').format(amount)} ${unit}`; + } catch { + return `${amount} ${unit}`; + } +} + +export function formatCurrencyParts( + amount: number, + unit = 'تومان', +): { number: string; unit: string } { + try { + return { + number: new Intl.NumberFormat('fa-IR').format(amount), + unit, + }; + } catch { + return { number: String(amount), unit }; + } +} + +export function initialsFromTitle(title: string): string { + const parts = title + .trim() + .split(/\s+/) + .filter(Boolean); + if (!parts.length) return '؟'; + if (parts.length === 1) return parts[0]!.slice(0, 2); + return parts + .slice(0, 3) + .map((p) => p[0]!) + .join(''); +} + +export function currencyUnitLabel(currency: string): string { + if (currency === 'IRR' || currency === 'IRT') return 'تومان'; + return currency; +} + +/** نگاشت برد API به مدل UI کاریز */ +export function mapBoardToStages( + board: { + stages: Array<{ + id: string; + name: string; + isWon: boolean; + isLost: boolean; + deals: Array<{ + id: string; + title: string; + amount: number | null; + currency: string; + notes: string | null; + stageId: string; + expectedCloseAt: string | null; + createdAt: string; + person: { fullName: string } | null; + company: { name: string } | null; + }>; + }>; + }, + formatDate: (iso: string) => string, +): PipelineStage[] { + let openIndex = 0; + return board.stages.map((stage) => { + let color: PipelineAccent; + if (stage.isWon) color = 'green'; + else if (stage.isLost) color = 'orange'; + else { + color = ACCENT_CYCLE[openIndex % ACCENT_CYCLE.length]!; + openIndex += 1; + } + + return { + id: stage.id, + title: stage.name, + color, + isWon: stage.isWon, + isLost: stage.isLost, + deals: stage.deals.map((deal) => { + const description = + deal.notes?.trim() || + deal.company?.name || + deal.person?.fullName || + ''; + const dateIso = deal.expectedCloseAt || deal.createdAt; + return { + id: deal.id, + title: deal.title, + description, + amount: deal.amount ?? 0, + date: formatDate(dateIso), + initials: initialsFromTitle( + deal.company?.name || deal.title, + ), + stageId: deal.stageId, + currencyUnit: currencyUnitLabel(deal.currency), + }; + }), + }; + }); +} + + +/** + * جابه‌جایی معامله بین مراحل یا تغییر ترتیب داخل یک مرحله. + * فقط state محلی را تغییر می‌دهد — آماده اتصال به API. + */ +export function updateDealStage( + stages: PipelineStage[], + dealId: string, + stageId: string, + toIndex?: number, +): PipelineStage[] { + let moving: PipelineDeal | null = null; + let fromStageId = ''; + let fromIndex = -1; + + for (const stage of stages) { + const idx = stage.deals.findIndex((d) => d.id === dealId); + if (idx !== -1) { + moving = stage.deals[idx]!; + fromStageId = stage.id; + fromIndex = idx; + break; + } + } + if (!moving) return stages; + + const without = stages.map((stage) => { + if (stage.id !== fromStageId) return stage; + return { + ...stage, + deals: stage.deals.filter((d) => d.id !== dealId), + }; + }); + + return without.map((stage) => { + if (stage.id !== stageId) return stage; + const next = [...stage.deals]; + let insertAt = + toIndex == null || toIndex < 0 || toIndex > next.length + ? next.length + : toIndex; + if ( + fromStageId === stageId && + fromIndex >= 0 && + toIndex != null && + toIndex > fromIndex + ) { + insertAt = Math.max(0, insertAt - 1); + } + next.splice(insertAt, 0, { ...moving!, stageId }); + return { ...stage, deals: next }; + }); +} + +export function filterStagesByQuery( + stages: PipelineStage[], + query: string, +): PipelineStage[] { + const q = query.trim().toLowerCase(); + if (!q) return stages; + return stages.map((stage) => ({ + ...stage, + deals: stage.deals.filter( + (d) => + d.title.toLowerCase().includes(q) || + d.description.toLowerCase().includes(q), + ), + })); +} + +export function toFaDealCount(n: number): string { + return `${new Intl.NumberFormat('fa-IR').format(n)} معامله`; +} diff --git a/apps/web/src/views/DealFormView.vue b/apps/web/src/views/DealFormView.vue index b57c060..e55fe7a 100644 --- a/apps/web/src/views/DealFormView.vue +++ b/apps/web/src/views/DealFormView.vue @@ -253,6 +253,13 @@ async function load() { companyId.value = route.query.companyId; if (!personId.value) contactKind.value = 'company'; } + if ( + typeof route.query.stageId === 'string' && + route.query.stageId && + stages.value.some((s) => s.id === route.query.stageId) + ) { + stageId.value = route.query.stageId; + } } } catch { error.value = 'بارگذاری فرم معامله ناموفق بود.'; diff --git a/apps/web/src/views/PipelineBoardView.vue b/apps/web/src/views/PipelineBoardView.vue index 32ab427..3b7b110 100644 --- a/apps/web/src/views/PipelineBoardView.vue +++ b/apps/web/src/views/PipelineBoardView.vue @@ -1,46 +1,80 @@ @@ -272,67 +345,27 @@ onMounted(load);