Files
CRM_Montzar/apps/web/src/lib/pipeline-board.ts
T
2026-10-17 17:28:57 +03:30

439 lines
12 KiB
TypeScript

import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
export type PipelineDeal = {
id: string;
title: string;
description: string;
amount: number | null;
date: string;
initials: string;
/** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */
stageId?: string;
currencyUnit?: string;
createdAt?: string;
promotedFromPriorAt?: string | null;
/** نام مسئولان برای نمایش روی کارت */
assigneeNames?: string[];
ownerId?: string | null;
assigneeIds?: string[];
/** متن‌های قابل جستجو (مشتری، شرکت، یادداشت، مسئولان …) */
searchFields?: Array<string | null | undefined>;
};
export type PipelineUiCopy = {
itemNoun: string;
addItem: string;
empty: string;
addItemAria: string;
itemMenuAria: string;
listColTitle: string;
hideAmount?: boolean;
};
export const DEAL_BOARD_COPY: PipelineUiCopy = {
itemNoun: 'معامله',
addItem: 'افزودن معامله',
empty: 'معامله‌ای نیست',
addItemAria: 'افزودن معامله به این مرحله',
itemMenuAria: 'گزینه‌های معامله',
listColTitle: 'معامله',
};
export const PROJECT_BOARD_COPY: PipelineUiCopy = {
itemNoun: 'کارت',
addItem: 'افزودن کارت',
empty: 'کارتی نیست',
addItemAria: 'افزودن کارت به این مرحله',
itemMenuAria: 'گزینه‌های کارت',
listColTitle: 'کارت',
hideAmount: true,
};
export const ACTIVITY_BOARD_COPY: PipelineUiCopy = {
itemNoun: 'فعالیت',
addItem: 'افزودن فعالیت',
empty: 'فعالیتی نیست',
addItemAria: 'افزودن فعالیت به این ستون',
itemMenuAria: 'گزینه‌های فعالیت',
listColTitle: 'فعالیت',
hideAmount: true,
};
export type PipelineStage = {
id: string;
title: string;
color: PipelineAccent;
deals: PipelineDeal[];
isWon?: boolean;
isLost?: boolean;
/** تعداد کل از سرور وقتی کارت‌ها صفحه‌بندی‌شده بارگذاری می‌شوند */
total?: number;
hasMore?: boolean;
loadingMore?: boolean;
/** برچسب کوچک کنار عنوان ستون (مثلاً نوع مرحله) */
badge?: string;
};
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',
},
blue: {
soft: '#eff6ff',
solid: '#3b82f6',
text: '#1d4ed8',
border: '#bfdbfe',
},
rose: {
soft: '#fff1f2',
solid: '#f43f5e',
text: '#be123c',
border: '#fecdd3',
},
slate: {
soft: '#f8fafc',
solid: '#64748b',
text: '#334155',
border: '#e2e8f0',
},
};
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 {
return currencySuffix(currency || 'IRR');
}
/** نگاشت برد 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;
promotedFromPriorAt: string | null;
person: { fullName: string } | null;
company: { name: string } | null;
owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}>;
}>;
},
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;
const assigneeNames = [
...(deal.owner?.fullName ? [deal.owner.fullName] : []),
...(deal.assignees ?? [])
.map((a) => a.fullName)
.filter((n) => n && n !== deal.owner?.fullName),
];
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),
createdAt: deal.createdAt,
promotedFromPriorAt: deal.promotedFromPriorAt,
assigneeNames,
ownerId: deal.owner?.id ?? null,
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
searchFields: [
deal.notes,
deal.company?.name,
deal.person?.fullName,
...assigneeNames,
],
};
}),
};
});
}
export function mapProjectBoardToStages(
board: {
stages: Array<{
id: string;
name: string;
cards: Array<{
id: string;
title: string;
notes: string | null;
stageId: string;
createdAt: string;
person: { fullName: string } | null;
company: { name: string } | null;
deal: { title: string } | null;
owner?: { id: string; fullName: string } | null;
assignees?: Array<{ id: string; fullName: string }>;
}>;
}>;
},
formatDate: (iso: string) => string,
): PipelineStage[] {
return board.stages.map((stage, index) => ({
id: stage.id,
title: stage.name,
color: ACCENT_CYCLE[index % ACCENT_CYCLE.length]!,
deals: stage.cards.map((card) => {
const assigneeNames = [
...(card.owner?.fullName ? [card.owner.fullName] : []),
...(card.assignees ?? [])
.map((a) => a.fullName)
.filter((n) => n && n !== card.owner?.fullName),
];
return {
id: card.id,
title: card.title,
description:
card.company?.name ||
card.person?.fullName ||
card.deal?.title ||
card.notes?.trim() ||
'',
amount: null,
date: formatDate(card.createdAt),
initials: initialsFromTitle(
card.company?.name || card.person?.fullName || card.title,
),
stageId: card.stageId,
assigneeNames,
ownerId: card.owner?.id ?? null,
assigneeIds: (card.assignees ?? []).map((a) => a.id),
searchFields: [
card.notes,
card.company?.name,
card.person?.fullName,
card.deal?.title,
...assigneeNames,
],
};
}),
}));
}
/**
* جابه‌جایی معامله بین مراحل یا تغییر ترتیب داخل یک مرحله.
* فقط 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 type PipelineBoardFilter = {
query?: string;
amountMin?: number | null;
amountMax?: number | null;
/** اگر ست باشد فقط کارت‌های مربوط به این کاربر (مالک یا مسئول) */
mineUserId?: string | null;
};
export function filterStagesByQuery(
stages: PipelineStage[],
query: string,
): PipelineStage[] {
return filterStages(stages, { query });
}
export function filterStages(
stages: PipelineStage[],
filter: PipelineBoardFilter,
): PipelineStage[] {
const q = filter.query?.trim().toLowerCase() ?? '';
const min = filter.amountMin;
const max = filter.amountMax;
const mineUserId = filter.mineUserId?.trim() || null;
const hasFilter =
Boolean(q) ||
Boolean(mineUserId) ||
(min != null && !Number.isNaN(min)) ||
(max != null && !Number.isNaN(max));
if (!hasFilter) return stages;
return stages.map((stage) => ({
...stage,
deals: stage.deals.filter((d) => {
if (mineUserId) {
const isMine =
d.ownerId === mineUserId ||
(d.assigneeIds ?? []).includes(mineUserId);
if (!isMine) return false;
}
if (q && !matchesSearch(q, [d.title, d.description, ...(d.searchFields ?? [])])) {
return false;
}
if (min != null && !Number.isNaN(min) && (d.amount == null || d.amount < min)) {
return false;
}
if (max != null && !Number.isNaN(max) && (d.amount == null || d.amount > max)) {
return false;
}
return true;
}),
}));
}
export function toFaItemCount(n: number, noun = 'معامله'): string {
return `${new Intl.NumberFormat('fa-IR').format(n)} ${noun}`;
}
export function toFaDealCount(n: number): string {
return toFaItemCount(n, 'معامله');
}
/** جابه‌جایی یک ستون به ایندکس جدید */
export function moveStageToIndex(
stages: PipelineStage[],
stageId: string,
toIndex: number,
): PipelineStage[] {
const from = stages.findIndex((s) => s.id === stageId);
if (from < 0) return stages;
const next = [...stages];
const [item] = next.splice(from, 1);
if (!item) return stages;
const clamped = Math.max(0, Math.min(toIndex, next.length));
next.splice(clamped, 0, item);
return next;
}