349 lines
8.6 KiB
TypeScript
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;
|
|
}
|