Files
CRM_Montzar/apps/web/src/lib/pipeline-board.ts
T

349 lines
8.6 KiB
TypeScript

export type PipelineAccent = 'green' | 'orange' | 'purple';
export type PipelineDeal = {
id: string;
title: string;
description: string;
amount: number | null;
date: string;
initials: string;
/** شناسه مرحله فعلی — هنگام جابه‌جایی به‌روز می‌شود */
stageId?: string;
currencyUnit?: string;
};
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 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),
};
}),
};
});
}
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;
}>;
}>;
},
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) => ({
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,
})),
}));
}
/**
* جابه‌جایی معامله بین مراحل یا تغییر ترتیب داخل یک مرحله.
* فقط 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;
};
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 hasFilter =
Boolean(q) ||
(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 (
q &&
!d.title.toLowerCase().includes(q) &&
!d.description.toLowerCase().includes(q)
) {
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;
}