Add activity detail, Jalali pickers, and customer/user profiles.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -176,13 +176,97 @@ export function formatJalaliShort(d: Date): string {
|
||||
return `${weekdayName(d)} ${toFaDigits(jd)} ${jalaliMonthName(jm)}`;
|
||||
}
|
||||
|
||||
/** مثل: ۸ شهریور ۱۴۰۵ */
|
||||
export function formatJalaliDay(d: Date): string {
|
||||
const { jy, jm, jd } = dateToJalali(d);
|
||||
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${toFaDigits(jy)}`;
|
||||
}
|
||||
|
||||
export function toFaDigits(value: number | string): string {
|
||||
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!);
|
||||
}
|
||||
|
||||
export function startOfLocalDay(d = new Date()): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function formatClockFa(d: Date): string {
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${toFaDigits(hh)}:${toFaDigits(mm)}`;
|
||||
}
|
||||
|
||||
/** مثل: ۲۴ مرداد ۱۱:۲۰ */
|
||||
export function formatJalaliDayClock(d: Date): string {
|
||||
const { jm, jd } = dateToJalali(d);
|
||||
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${formatClockFa(d)}`;
|
||||
}
|
||||
|
||||
export function calendarDaysBetween(from: Date, to: Date): number {
|
||||
return Math.round(
|
||||
(startOfLocalDay(to).getTime() - startOfLocalDay(from).getTime()) / 86_400_000,
|
||||
);
|
||||
}
|
||||
|
||||
/** آخرین ارتباط: امروز ۱۶:۳۰ / ۶ روز پیش */
|
||||
export function formatRelativeContact(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const days = calendarDaysBetween(d, new Date());
|
||||
if (days <= 0) return `امروز ${formatClockFa(d)}`;
|
||||
if (days === 1) return 'دیروز';
|
||||
return `${toFaDigits(days)} روز پیش`;
|
||||
}
|
||||
|
||||
/** فعالیت بعدی: امروز ۱۶:۳۰ / فردا ۱۰:۰۰ / ۲۴ مرداد ۱۱:۲۰ */
|
||||
export function formatNextSlot(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const days = calendarDaysBetween(new Date(), d);
|
||||
if (days === 0) return `امروز ${formatClockFa(d)}`;
|
||||
if (days === 1) return `فردا ${formatClockFa(d)}`;
|
||||
if (days === -1) return `دیروز ${formatClockFa(d)}`;
|
||||
return formatJalaliDayClock(d);
|
||||
}
|
||||
|
||||
export function gregorianDayKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function parseLocalInput(value: string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/.exec(value);
|
||||
if (match) {
|
||||
return new Date(
|
||||
Number(match[1]),
|
||||
Number(match[2]) - 1,
|
||||
Number(match[3]),
|
||||
match[4] ? Number(match[4]) : 0,
|
||||
match[5] ? Number(match[5]) : 0,
|
||||
);
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
export function formatLocalDateInput(d: Date): string {
|
||||
return gregorianDayKey(d);
|
||||
}
|
||||
|
||||
export function formatLocalDateTimeInput(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${gregorianDayKey(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** مثل: ۱۴۰۵/۰۶/۱۲ */
|
||||
export function formatJalaliNumeric(d: Date): string {
|
||||
const { jy, jm, jd } = dateToJalali(d);
|
||||
return `${toFaDigits(jy)}/${toFaDigits(String(jm).padStart(2, '0'))}/${toFaDigits(String(jd).padStart(2, '0'))}`;
|
||||
}
|
||||
|
||||
@@ -22,3 +22,20 @@ export function formatMoney(amount: number, currency = 'IRR'): string {
|
||||
return `${amount} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** مثل ماکاپ: ۲۴۰ میلیون ریال */
|
||||
export function formatCompactMoney(amount: number, currency = 'IRR'): string {
|
||||
const suffix = currencySuffix(currency);
|
||||
const abs = Math.abs(amount);
|
||||
if (abs >= 1_000_000_000) {
|
||||
const n = amount / 1_000_000_000;
|
||||
const rounded = Math.abs(n - Math.round(n)) < 0.05 ? Math.round(n) : Math.round(n * 10) / 10;
|
||||
return `${new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 1 }).format(rounded)} میلیارد ${suffix}`;
|
||||
}
|
||||
if (abs >= 1_000_000) {
|
||||
const n = amount / 1_000_000;
|
||||
const rounded = Math.abs(n - Math.round(n)) < 0.05 ? Math.round(n) : Math.round(n * 10) / 10;
|
||||
return `${new Intl.NumberFormat('fa-IR', { maximumFractionDigits: 1 }).format(rounded)} میلیون ${suffix}`;
|
||||
}
|
||||
return formatMoney(amount, currency);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { MemoryItemDto, NoteDto, PersonDto } from '@mento/shared';
|
||||
|
||||
export type ProbabilityBand = 'بالا' | 'متوسط' | 'پایین';
|
||||
|
||||
export function personInitials(fullName: string | null | undefined): string {
|
||||
const parts = fullName?.trim().split(/\s+/) ?? [];
|
||||
if (!parts.length) return 'م';
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0])
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function isVipPerson(person: PersonDto | null): boolean {
|
||||
if (!person) return false;
|
||||
const tier = person.loyaltyTier?.name ?? '';
|
||||
if (/vip|طلا|طلایی|gold|پلاتین|platinum/i.test(tier)) return true;
|
||||
if ((person.loyaltyPoints ?? 0) >= 800) return true;
|
||||
const fields = person.customFields ?? {};
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (/vip/i.test(key) && (value === true || value === 'true' || value === 1 || value === '1')) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === 'string' && /^vip$/i.test(value.trim())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function purchaseProbability(input: {
|
||||
daysSinceContact: number | null;
|
||||
openDealCount: number;
|
||||
quoteCount: number;
|
||||
hasUpcoming: boolean;
|
||||
noteCount: number;
|
||||
}): { score: number; band: ProbabilityBand } {
|
||||
let score = 38;
|
||||
if (input.openDealCount > 0) score += 16 + Math.min(8, input.openDealCount * 2);
|
||||
if (input.quoteCount > 0) score += 8;
|
||||
if (input.noteCount > 0) score += 4;
|
||||
if (input.hasUpcoming) score += 6;
|
||||
if (input.daysSinceContact != null) {
|
||||
if (input.daysSinceContact <= 2) score += 24;
|
||||
else if (input.daysSinceContact <= 7) score += 18;
|
||||
else if (input.daysSinceContact <= 14) score += 8;
|
||||
else if (input.daysSinceContact <= 30) score += 2;
|
||||
else score -= 10;
|
||||
} else {
|
||||
score -= 6;
|
||||
}
|
||||
score = Math.max(8, Math.min(95, Math.round(score)));
|
||||
const band: ProbabilityBand = score >= 70 ? 'بالا' : score >= 45 ? 'متوسط' : 'پایین';
|
||||
return { score, band };
|
||||
}
|
||||
|
||||
export function buildSmartSummary(input: {
|
||||
memories: MemoryItemDto[];
|
||||
notes: NoteDto[];
|
||||
lastQuoteLabel: string | null;
|
||||
daysSinceCall: number | null;
|
||||
probabilityBand: ProbabilityBand;
|
||||
}): { text: string; cta: string; ctaKind: 'call' | 'activity' } {
|
||||
const parts: string[] = [];
|
||||
const memoryBits = input.memories
|
||||
.map((m) => m.content.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 2);
|
||||
if (memoryBits.length) {
|
||||
parts.push(memoryBits.join(' '));
|
||||
} else {
|
||||
const note = input.notes.find((n) => n.body.trim());
|
||||
if (note?.body) {
|
||||
const snippet = note.body.replace(/\s+/g, ' ').trim();
|
||||
parts.push(snippet.length > 90 ? `${snippet.slice(0, 90)}…` : snippet);
|
||||
}
|
||||
}
|
||||
if (input.lastQuoteLabel) {
|
||||
parts.push(`آخرین پیشنهاد ${input.lastQuoteLabel} بوده.`);
|
||||
}
|
||||
if (input.daysSinceCall != null) {
|
||||
if (input.daysSinceCall <= 0) parts.push('امروز با مشتری تماس گرفته شده.');
|
||||
else parts.push(`${input.daysSinceCall} روز از آخرین تماس گذشته`);
|
||||
}
|
||||
parts.push(`و احتمال خرید ${input.probabilityBand} است.`);
|
||||
|
||||
const hasSignal = memoryBits.length > 0 || input.notes.length > 0 || input.lastQuoteLabel;
|
||||
const text = hasSignal
|
||||
? parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
: 'هنوز خلاصهٔ پایداری از این مشتری ثبت نشده. با یادداشت یا حافظهٔ منتو، پیشنهاد هوشمند اینجا ظاهر میشود.';
|
||||
|
||||
let cta = 'پیشنهاد منتو: امروز با مشتری تماس بگیر';
|
||||
let ctaKind: 'call' | 'activity' = 'call';
|
||||
if (input.daysSinceCall != null && input.daysSinceCall <= 1) {
|
||||
cta = 'پیشنهاد منتو: پیگیری بعدی را ثبت کن';
|
||||
ctaKind = 'activity';
|
||||
}
|
||||
return { text, cta, ctaKind };
|
||||
}
|
||||
Reference in New Issue
Block a user