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:
2026-09-05 00:18:39 +03:30
co-authored by Cursor
parent 4b47b753c3
commit aa3fe49ce6
11 changed files with 1519 additions and 365 deletions
+227
View File
@@ -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)} معامله`;
}