Add activity detail, Jalali pickers, and customer/user profiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 21:29:00 +03:30
co-authored by Cursor
parent 574feedfa5
commit f884c60cab
39 changed files with 8255 additions and 441 deletions
+84
View File
@@ -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'))}`;
}
+17
View File
@@ -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);
}
+97
View File
@@ -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 };
}