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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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)} معامله`;
|
||||
}
|
||||
Reference in New Issue
Block a user