Scroll the column while a card is held near its edge and show a light gap where the card will land. Co-authored-by: Cursor <cursoragent@cursor.com>
485 lines
14 KiB
TypeScript
485 lines
14 KiB
TypeScript
import { currencySuffix } from '@/lib/money';
|
|
import { matchesSearch } from '@/lib/search';
|
|
import type { ActivityPriority } from '@mento/shared';
|
|
|
|
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>;
|
|
/** اولویت فعالیت؛ فقط روی کارت فعالیت نشان داده میشود */
|
|
priority?: ActivityPriority;
|
|
};
|
|
|
|
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 ? next.length : toIndex;
|
|
if (
|
|
fromStageId === stageId &&
|
|
fromIndex >= 0 &&
|
|
toIndex != null &&
|
|
toIndex > fromIndex
|
|
) {
|
|
insertAt -= 1;
|
|
}
|
|
insertAt = Math.max(0, Math.min(insertAt, next.length));
|
|
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 pointerDropIndex(index: number, event: DragEvent): number {
|
|
const el = event.currentTarget as HTMLElement | null;
|
|
const rect = el?.getBoundingClientRect();
|
|
if (!rect || rect.height <= 0) return index;
|
|
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
|
|
}
|
|
|
|
const DRAG_SCROLL_EDGE = 72;
|
|
const DRAG_SCROLL_MAX = 16;
|
|
let dragScrollEl: HTMLElement | null = null;
|
|
let dragScrollY = 0;
|
|
let dragScrollRaf = 0;
|
|
|
|
function dragScrollTick() {
|
|
dragScrollRaf = 0;
|
|
const el = dragScrollEl;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
let delta = 0;
|
|
if (dragScrollY < rect.top + DRAG_SCROLL_EDGE) {
|
|
const closeness = (rect.top + DRAG_SCROLL_EDGE - dragScrollY) / DRAG_SCROLL_EDGE;
|
|
delta = -Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
|
|
} else if (dragScrollY > rect.bottom - DRAG_SCROLL_EDGE) {
|
|
const closeness = (dragScrollY - (rect.bottom - DRAG_SCROLL_EDGE)) / DRAG_SCROLL_EDGE;
|
|
delta = Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
|
|
}
|
|
if (!delta || el.scrollHeight <= el.clientHeight) return;
|
|
el.scrollTop += delta;
|
|
dragScrollRaf = requestAnimationFrame(dragScrollTick);
|
|
}
|
|
|
|
/** نزدیک لبهٔ بالا یا پایین ستون، فهرست خودش حرکت میکند تا مقصد دیده شود. */
|
|
export function dragScrollColumn(el: HTMLElement | null, clientY: number) {
|
|
dragScrollEl = el;
|
|
dragScrollY = clientY;
|
|
if (!dragScrollRaf) dragScrollRaf = requestAnimationFrame(dragScrollTick);
|
|
}
|
|
|
|
export function stopDragScroll() {
|
|
dragScrollEl = null;
|
|
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
|
|
dragScrollRaf = 0;
|
|
}
|
|
|
|
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;
|
|
}
|