From cf326d1ed9c87c49cd6ee243661af8a5baa280f9 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 13 Oct 2026 18:04:53 +0330 Subject: [PATCH] fix: keep birthdays and show Shamsi dates Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date. Co-authored-by: Cursor --- .../migration.sql | 5 + apps/api/prisma/schema.prisma | 4 + apps/api/src/ai/ai-ask.service.ts | 26 ++-- apps/api/src/ai/ai.service.ts | 17 +-- apps/api/src/common/excel.ts | 22 ++++ apps/api/src/common/jalali.ts | 115 ++++++++++++++++++ .../src/contacts/contacts-excel.service.ts | 21 +++- apps/api/src/contacts/dto.ts | 10 ++ apps/api/src/contacts/people.service.ts | 75 +++++++++++- .../src/integrations/montify/sync.service.ts | 5 + .../tahesab/person-link.service.ts | 106 ++++++++++++++++ .../integrations/tahesab/tahesab.module.ts | 6 +- apps/api/src/leads/dto.ts | 2 + apps/api/src/leads/leads-import.service.ts | 11 ++ apps/api/src/leads/leads.service.ts | 36 +++++- apps/api/test/dates.test.ts | 20 +++ apps/web/src/components/AskMentoPanel.vue | 3 +- apps/web/src/components/EntityTimeline.vue | 12 +- apps/web/src/components/MentoMemoryPanel.vue | 12 +- .../src/components/NoteAiSuggestionPanel.vue | 3 +- .../components/dashboard/HomeAiAssistant.vue | 8 +- apps/web/src/layouts/AppLayout.vue | 13 +- apps/web/src/lib/gamification.ts | 12 +- apps/web/src/lib/jalali.ts | 95 +++++++++++++++ apps/web/src/views/HomeView.vue | 15 +-- apps/web/src/views/LeadDetailView.vue | 15 ++- apps/web/src/views/PersonFormView.vue | 10 +- apps/web/src/views/PersonProfileView.vue | 14 ++- apps/web/src/views/PortalView.vue | 12 +- apps/web/src/views/ProfileView.vue | 3 +- apps/web/src/views/TicketFormView.vue | 12 +- apps/web/src/views/TicketsView.vue | 13 +- apps/web/src/views/VoipSettingsView.vue | 4 +- packages/shared/src/index.ts | 5 + 34 files changed, 637 insertions(+), 105 deletions(-) create mode 100644 apps/api/prisma/migrations/20261013140000_person_lead_birth_date/migration.sql diff --git a/apps/api/prisma/migrations/20261013140000_person_lead_birth_date/migration.sql b/apps/api/prisma/migrations/20261013140000_person_lead_birth_date/migration.sql new file mode 100644 index 0000000..72afe28 --- /dev/null +++ b/apps/api/prisma/migrations/20261013140000_person_lead_birth_date/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Person" ADD COLUMN "birthDate" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "Lead" ADD COLUMN "birthDate" TIMESTAMP(3); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9227192..46bf01b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -498,6 +498,8 @@ model Person { website String? /// پیج اینستاگرام instagram String? + /// تاریخ تولد + birthDate DateTime? /// نام معرف referrer String? /// لیست آدرس‌ها (JSON array of strings) @@ -2391,6 +2393,8 @@ model Lead { region String? /// پیج اینستاگرام instagram String? + /// تاریخ تولد + birthDate DateTime? /// لیست آدرس‌ها (JSON array of strings) addresses Json @default("[]") /// صنف diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 9b1d28d..15fdbb6 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -32,7 +32,7 @@ import { type PersonImportSource, type UserRole, } from '@mento/shared'; -import { startOfJalaliMonth, toJalali } from '../common/jalali'; +import { formatTehranJalali, rewriteGregorianDates, startOfJalaliMonth, toJalali } from '../common/jalali'; import { AiService } from './ai.service'; import { classifyCountQuestion, @@ -164,6 +164,7 @@ export class AiAskService { Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، ته‌حساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total. Lead stage and lead source lines are authoritative the same way. Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month). +Every date you write must be Shamsi/Jalali, copied from jalali dates in the context, like ۱۴۰۴/۰۷/۲۱. Never use Gregorian month names (ژانویه، سپتامبر، September) or ISO dates. Workspace facts and Period stats are complete and authoritative for counts. You cannot create, update, or delete records. Never say a customer, lead, or activity was created. Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable. If a requested filter is not in the facts, say that filtered count is not available instead of substituting another total. @@ -185,7 +186,7 @@ Write the answer in Persian, concise, with exact numbers and names when present. .filter((item): item is CatalogItem => Boolean(item)) .slice(0, 4); - const answer = String(raw.answer ?? '').trim().slice(0, 2000); + const answer = rewriteGregorianDates(String(raw.answer ?? '').trim()).slice(0, 2000); return { answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.', sources, @@ -206,12 +207,13 @@ Rules: - Mention durable memory, last contact, open deals, or next follow-up when present. - cta is one concrete next step, starting with «${brand.suggestionPrefixFa}». - ctaKind is "call" if a phone follow-up is best, otherwise "activity". -- Do not invent facts.`, +- Do not invent facts. +- Every date must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱. Never write Gregorian month names such as سپتامبر or September.`, `Customer briefing context:\n${context}`, ); - const text = String(raw.text ?? '').trim().slice(0, 700); - const cta = String(raw.cta ?? '').trim().slice(0, 120); + const text = rewriteGregorianDates(String(raw.text ?? '').trim()).slice(0, 700); + const cta = rewriteGregorianDates(String(raw.cta ?? '').trim()).slice(0, 120); const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call'; return { text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.', @@ -925,7 +927,7 @@ Rules: .filter(Boolean); const lines: string[] = [ - `Today (Asia/Tehran): ${dayKey} | now=${now.toISOString()}`, + `Today (Asia/Tehran, Jalali): ${formatTehranJalali(now)} | gregorian_key=${dayKey}`, `Periods: yesterday=${periods.yesterday.key} | this_week=${periods.week.key} (Sat–Fri) | this_month=${periods.month.key} (Jalali)`, 'Workspace facts (authoritative):', `User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`, @@ -983,7 +985,7 @@ Rules: const label = `${p.firstName} ${p.lastName}`.trim(); catalog.push({ type: 'person', id: p.id, label }); lines.push( - `Person ${p.id} ${label} created=${p.createdAt.toISOString()} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`, + `Person ${p.id} ${label} created_jalali=${formatTehranJalali(p.createdAt)} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`, ); } } @@ -1002,7 +1004,7 @@ Rules: label: clip(note.title || note.body, 60), }); lines.push( - `Note ${note.id} (${note.createdAt.toISOString()})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`, + `Note ${note.id} (jalali=${formatTehranJalali(note.createdAt)})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`, ); } for (const activity of activities) { @@ -1011,7 +1013,7 @@ Rules: : ''; catalog.push({ type: 'activity', id: activity.id, label: activity.title }); lines.push( - `Activity ${activity.id} type=${activity.type} status=${activity.status} due=${activity.dueAt?.toISOString() ?? '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`, + `Activity ${activity.id} type=${activity.type} status=${activity.status} due_jalali=${activity.dueAt ? formatTehranJalali(activity.dueAt) : '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`, ); } for (const deal of deals) { @@ -1079,7 +1081,7 @@ Rules: if (!person.firstName) missing.push('نام'); return { intent, - summary: (raw.summary ?? '').trim() || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(), + summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(), missing, canConfirm: missing.length === 0, person, @@ -1096,7 +1098,7 @@ Rules: if (!activity.title) missing.push('عنوان فعالیت'); return { intent, - summary: (raw.summary ?? '').trim() || `ثبت فعالیت «${activity.title}»`, + summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ثبت فعالیت «${activity.title}»`, missing, canConfirm: missing.length === 0, person: null, @@ -1107,7 +1109,7 @@ Rules: return { intent: 'none', summary: - (raw.summary ?? '').trim() || + rewriteGregorianDates((raw.summary ?? '').trim()) || 'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام می‌شود.', missing: [], canConfirm: false, diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts index 4722061..f15828c 100644 --- a/apps/api/src/ai/ai.service.ts +++ b/apps/api/src/ai/ai.service.ts @@ -4,6 +4,7 @@ import { ServiceUnavailableException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { rewriteGregorianDates } from '../common/jalali'; export type NoteAiExtract = { nextAction: { @@ -44,6 +45,7 @@ Rules: - dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null. - memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks. - Keep text in Persian when the note is Persian. +- If summary mentions a date, write it Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر. - Do not invent facts not supported by the note. - Maximum 5 memory items.`; @@ -76,7 +78,8 @@ Rules: - summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments. - nextAction: one short Persian follow-up if needed; otherwise null. - Do not invent facts not in the transcript. -- Keep names, numbers, and amounts accurate.`; +- Keep names, numbers, and amounts accurate. +- Dates in summary and nextAction must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.`; const userParts = [ input.personName ? `Customer: ${input.personName}` : null, @@ -89,12 +92,12 @@ Rules: nextAction?: string | null; }>(system, userParts.join('\n')); - const summary = String(raw.summary ?? '').trim().slice(0, 4000); + const summary = rewriteGregorianDates(String(raw.summary ?? '').trim()).slice(0, 4000); if (!summary) { throw new BadRequestException('خلاصه تماس خالی بود'); } const nextAction = raw.nextAction - ? String(raw.nextAction).trim().slice(0, 500) || null + ? rewriteGregorianDates(String(raw.nextAction).trim()).slice(0, 500) || null : null; return { summary, nextAction }; } @@ -169,9 +172,9 @@ Rules: const nextAction = raw.nextAction && typeof raw.nextAction === 'object' ? { - title: String(raw.nextAction.title ?? '').trim().slice(0, 200), + title: rewriteGregorianDates(String(raw.nextAction.title ?? '').trim()).slice(0, 200), description: raw.nextAction.description - ? String(raw.nextAction.description).trim().slice(0, 2000) + ? rewriteGregorianDates(String(raw.nextAction.description).trim()).slice(0, 2000) : undefined, dueAt: raw.nextAction.dueAt ? String(raw.nextAction.dueAt).trim() @@ -182,7 +185,7 @@ Rules: const memoryItems = Array.isArray(raw.memoryItems) ? raw.memoryItems .map((item) => ({ - content: String(item?.content ?? '').trim().slice(0, 500), + content: rewriteGregorianDates(String(item?.content ?? '').trim()).slice(0, 500), category: item?.category ? String(item.category).trim().slice(0, 40) : undefined, @@ -197,7 +200,7 @@ Rules: return { nextAction: cleanedNext, memoryItems, - summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined, + summary: raw.summary ? rewriteGregorianDates(String(raw.summary).trim()).slice(0, 500) : undefined, }; } } diff --git a/apps/api/src/common/excel.ts b/apps/api/src/common/excel.ts index f5c6b6c..aaae836 100644 --- a/apps/api/src/common/excel.ts +++ b/apps/api/src/common/excel.ts @@ -169,6 +169,28 @@ export function parseAmount(raw?: string): number | null { return Number.isFinite(n) ? n : null; } +/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابه‌جا نشود. */ +export function parseBirthDate(raw?: string | null): Date | null { + const text = toAsciiDigits(String(raw ?? '')).trim(); + if (!text || text === '-' || text === '0' || text === '0000-00-00') return null; + const datePart = text.split(/[T\s]/)[0] ?? text; + const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart); + if (!match) { + const fallback = new Date(text); + return Number.isNaN(fallback.getTime()) ? null : fallback; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + if (year >= 1700) { + return new Date(Date.UTC(year, month - 1, day, 12, 0, 0)); + } + if (year < 1200) return null; + const { gy, gm, gd } = toGregorian(year, month, day); + return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0)); +} + export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null { const dateText = toAsciiDigits(dateRaw ?? '').trim(); if (!dateText) return null; diff --git a/apps/api/src/common/jalali.ts b/apps/api/src/common/jalali.ts index 5da7973..925b464 100644 --- a/apps/api/src/common/jalali.ts +++ b/apps/api/src/common/jalali.ts @@ -103,6 +103,121 @@ export function jalaliMonthKey(d: Date): string { return `${jy}-${String(jm).padStart(2, '0')}`; } +const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; + +function toAsciiDigits(value: string) { + return value + .replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d))) + .replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d))); +} + +function toFaDigits(value: string | number) { + return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d); +} + +function tehranYmd(d: Date) { + const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + .format(d) + .split('-') + .map(Number); + return { gy, gm, gd }; +} + +/** تاریخ شمسی تهران، مثل ۱۴۰۲/۰۶/۱۲ */ +export function formatTehranJalali(d: Date): string { + const { gy, gm, gd } = tehranYmd(d); + const j = toJalali(gy, gm, gd); + return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`; +} + +const GREG_MONTHS: Array<[string, number]> = [ + ['ژانویه', 1], + ['january', 1], + ['فوریه', 2], + ['february', 2], + ['مارس', 3], + ['march', 3], + ['آوریل', 4], + ['april', 4], + ['ژوئن', 6], + ['june', 6], + ['ژوئیه', 7], + ['جولای', 7], + ['july', 7], + ['اوت', 8], + ['آگوست', 8], + ['آگست', 8], + ['august', 8], + ['سپتامبر', 9], + ['september', 9], + ['اکتبر', 10], + ['october', 10], + ['نوامبر', 11], + ['november', 11], + ['دسامبر', 12], + ['december', 12], + ['مه', 5], + ['می', 5], + ['may', 5], +]; + +function gregMonthNumber(name: string): number | null { + const key = name.trim().toLowerCase(); + return GREG_MONTHS.find(([label]) => label === key)?.[1] ?? null; +} + +function inferGregorianYear(month: number, day: number, now = new Date()): number { + const { gy, gm, gd } = tehranYmd(now); + if (month > gm || (month === gm && day > gd)) return gy - 1; + return gy; +} + +function jalaliFromGregorian(year: number, month: number, day: number): string | null { + if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1700 || year > 2500) return null; + const j = toJalali(year, month, day); + return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`; +} + +/** «21 سپتامبر» و تاریخ میلادی عددی را در متن کاربر به شمسی تبدیل می‌کند. */ +export function rewriteGregorianDates(text: string, now = new Date()): string { + const monthAlt = GREG_MONTHS.map(([name]) => name).join('|'); + const dayMonth = new RegExp( + `(^|[^\\p{L}\\d])([0-9۰-۹]{1,2})\\s*(${monthAlt})(?![\\p{L}\\u200c])(?:\\s+([0-9۰-۹]{4}))?`, + 'giu', + ); + const monthDay = new RegExp( + `(^|[^\\p{L}\\d])(${monthAlt})(?![\\p{L}\\u200c])\\s+([0-9۰-۹]{1,2})(?:\\s*,?\\s*([0-9۰-۹]{4}))?`, + 'giu', + ); + const iso = /\b((?:19|20)\d{2})-(\d{2})-(\d{2})\b/g; + + const fromParts = (dayRaw: string, monthName: string, yearRaw?: string) => { + const day = Number(toAsciiDigits(dayRaw)); + const month = gregMonthNumber(monthName); + if (!month) return null; + const year = yearRaw ? Number(toAsciiDigits(yearRaw)) : inferGregorianYear(month, day, now); + return jalaliFromGregorian(year, month, day); + }; + + let out = text.replace(dayMonth, (full, lead: string, day: string, month: string, year?: string) => { + const next = fromParts(day, month, year); + return next ? `${lead}${next}` : full; + }); + out = out.replace(monthDay, (full, lead: string, month: string, day: string, year?: string) => { + const next = fromParts(day, month, year); + return next ? `${lead}${next}` : full; + }); + out = out.replace(iso, (full, year: string, month: string, day: string) => { + return jalaliFromGregorian(Number(year), Number(month), Number(day)) ?? full; + }); + return out; +} + export function startOfJalaliMonth(jy: number, jm: number): Date { return jalaliToDate(jy, jm, 1); } diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index 068d77b..56870ca 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -9,6 +9,7 @@ import { } from '../common/custom-fields'; import { formatJalaliDate, + parseBirthDate, parseExcelMapping, parseJalaliDateTime, previewExcel, @@ -22,6 +23,7 @@ import { } from '../common/excel-import-session'; import { CompaniesService } from './companies.service'; import { PeopleService } from './people.service'; +import { normalizeEmail, normalizePhone } from './normalize'; import { normalizePersonImportSource } from './person-source'; type ImportChunkOpts = { @@ -112,6 +114,11 @@ const PERSON_ALIASES: Record = { تاریختولدمشتری: 'birthDate', تاریختولد1: 'birthDate', تاریختولد: 'birthDate', + تاریختولدشمسی: 'birthDate', + تاریخمیلادت: 'birthDate', + birthdate: 'birthDate', + dateofbirth: 'birthDate', + bdate: 'birthDate', منبعاطلاعات: 'source', نوعمشتری: 'customerType', type: '_type', @@ -162,6 +169,7 @@ const PERSON_FIELDS = [ { key: 'homeAddress', label: 'آدرس منزل' }, { key: 'instagram', label: 'اینستاگرام' }, { key: 'registeredAt', label: 'تاریخ ثبت' }, + { key: 'birthDate', label: 'تاریخ تولد' }, { key: 'notes', label: 'یادداشت' }, { key: 'mood', label: 'وضعیت مشتری' }, ]; @@ -393,7 +401,7 @@ export class ContactsExcelService { visibility: 'همه افراد شرکت', nationalId: extra.nationalId ?? '', registeredAt: formatJalaliDate(p.createdAt), - birthDate: extra.birthDate ?? '', + birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''), mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'], otherPhones: extra.otherPhones ?? '', address: p.addresses[0] ?? extra.address ?? '', @@ -408,7 +416,7 @@ export class ContactsExcelService { postalCode2: extra.postalCode ?? '', referrer: extra.referrer ?? p.referrer ?? '', homeAddress: p.addresses[2] ?? extra.homeAddress ?? '', - birthDate2: extra.birthDate ?? '', + birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''), instagram: p.instagram ?? extra.instagram ?? '', group: extra.group ?? '', colleagueContact: extra.colleagueContact ?? '', @@ -578,6 +586,7 @@ export class ContactsExcelService { position: row.position || undefined, website: row.website || undefined, instagram: row.instagram || undefined, + birthDate: parseBirthDate(row.birthDate)?.toISOString() ?? null, addresses, notes: row.notes || undefined, mood: parseMood(row.mood) ?? parseMood(row.group), @@ -597,6 +606,14 @@ export class ContactsExcelService { } } catch (e) { if (e instanceof ConflictException) { + const birthDate = parseBirthDate(row.birthDate); + if (birthDate) { + await this.people.fillBirthDateIfEmpty(actor.organizationId, { + mobile: normalizePhone(row.mobile), + email: normalizeEmail(row.email), + birthDate, + }); + } result.skipped += 1; } else { result.errors.push({ diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index 205f9a8..e832722 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -293,6 +293,11 @@ export class CreatePersonDto { @MaxLength(200) instagram?: string; + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + birthDate?: string | null; + @IsOptional() @IsString() @MaxLength(120) @@ -401,6 +406,11 @@ export class UpdatePersonDto { @MaxLength(200) instagram?: string; + @IsOptional() + @ValidateIf((_, v) => v !== '' && v != null) + @IsString() + birthDate?: string | null; + @IsOptional() @IsString() @MaxLength(120) diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index 94658fd..141305d 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -1,7 +1,9 @@ import { ConflictException, Injectable, + Logger, NotFoundException, + type OnModuleInit, } from '@nestjs/common'; import type { CreatePersonRequest, @@ -32,6 +34,7 @@ import { AutomationsService } from '../automations/automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { WebhooksService } from '../public-api/webhooks.service'; import { GamificationService } from '../gamification/gamification.service'; +import { parseBirthDate } from '../common/excel'; import { normalizeTags } from '../common/tags'; import { buildSearchWhere, ci } from '../common/search'; import { @@ -63,6 +66,7 @@ type PersonRow = { position: string | null; website: string | null; instagram: string | null; + birthDate: Date | null; referrer: string | null; addresses: unknown; notes: string | null; @@ -83,7 +87,8 @@ type PersonRow = { }; @Injectable() -export class PeopleService { +export class PeopleService implements OnModuleInit { + private readonly logger = new Logger(PeopleService.name); constructor( private readonly prisma: PrismaService, private readonly companies: CompaniesService, @@ -168,6 +173,71 @@ export class PeopleService { .sort((a, b) => b.count - a.count); } + onModuleInit() { + void this.backfillStoredBirthDates(); + } + + /** تاریخ تولدی که قبلاً در فیلد اضافی دیدار/اکسل یا پروفایل مونتیفای مانده، روی شخص می‌نشیند. */ + async backfillStoredBirthDates() { + try { + const fromFields = await this.prisma.$queryRaw>` + SELECT id, + COALESCE( + NULLIF("customFields"->>'birthDate', ''), + NULLIF("customFields"->>'birthDate2', '') + ) AS birth + FROM "Person" + WHERE "birthDate" IS NULL + AND ( + COALESCE("customFields"->>'birthDate', '') <> '' + OR COALESCE("customFields"->>'birthDate2', '') <> '' + ) + LIMIT 20000 + `; + const fromMontify = await this.prisma.$queryRaw>` + SELECT p.id, m.data->>'birthday' AS birth + FROM "MontifyLink" m + JOIN "Person" p ON p.id = m."entityId" + WHERE m."entityType" = 'customer' + AND p."birthDate" IS NULL + AND COALESCE(m.data->>'birthday', '') <> '' + LIMIT 20000 + `; + const seen = new Set(); + let filled = 0; + for (const row of [...fromFields, ...fromMontify]) { + if (!row.birth || seen.has(row.id)) continue; + seen.add(row.id); + const birthDate = parseBirthDate(row.birth); + if (!birthDate) continue; + const updated = await this.prisma.person.updateMany({ + where: { id: row.id, birthDate: null }, + data: { birthDate }, + }); + filled += updated.count; + } + if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از داده‌های ذخیره‌شده پر شد`); + } catch (err) { + this.logger.warn( + `پر کردن تاریخ تولد ذخیره‌شده ناموفق بود: ${err instanceof Error ? err.message : err}`, + ); + } + } + + async fillBirthDateIfEmpty( + organizationId: string, + input: { mobile?: string | null; email?: string | null; birthDate: Date }, + ) { + const or: Prisma.PersonWhereInput[] = []; + if (input.mobile) or.push({ mobile: input.mobile }); + if (input.email) or.push({ email: input.email }); + if (!or.length) return; + await this.prisma.person.updateMany({ + where: { organizationId, birthDate: null, OR: or }, + data: { birthDate: input.birthDate }, + }); + } + async get(actor: Actor, id: string): Promise { const row = await this.prisma.person.findFirst({ where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, @@ -198,6 +268,7 @@ export class PeopleService { position: emptyToNull(dto.position), website: emptyToNull(dto.website), instagram: emptyToNull(dto.instagram), + birthDate: parseBirthDate(dto.birthDate), referrer: emptyToNull(dto.referrer), addresses: toJsonAddresses(dto.addresses), notes: emptyToNull(dto.notes), @@ -279,6 +350,7 @@ export class PeopleService { if ('position' in dto) data.position = emptyToNull(dto.position); if ('website' in dto) data.website = emptyToNull(dto.website); if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram); + if ('birthDate' in dto) data.birthDate = parseBirthDate(dto.birthDate); if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer); if ('addresses' in dto) data.addresses = toJsonAddresses(dto.addresses); if ('notes' in dto) data.notes = emptyToNull(dto.notes); @@ -396,6 +468,7 @@ export class PeopleService { position: row.position, website: row.website, instagram: row.instagram, + birthDate: row.birthDate?.toISOString() ?? null, referrer: row.referrer, addresses: (() => { const fromCol = asAddresses(row.addresses); diff --git a/apps/api/src/integrations/montify/sync.service.ts b/apps/api/src/integrations/montify/sync.service.ts index 35aacc0..7788e99 100644 --- a/apps/api/src/integrations/montify/sync.service.ts +++ b/apps/api/src/integrations/montify/sync.service.ts @@ -15,6 +15,7 @@ import type { MontifySyncResultDto, MontifyTransactionDto, } from '@mento/shared'; +import { parseBirthDate } from '../../common/excel'; import { PrismaService } from '../../prisma/prisma.service'; import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; import { carryPreviousSource } from '../../contacts/person-source'; @@ -487,6 +488,9 @@ export class MontifySyncService { ...(mobile && (ownedByStore || !person.mobile) ? { mobile } : {}), ...(email && (ownedByStore || !person.email) ? { email } : {}), ...(str(c.job) && !person.position ? { position: str(c.job) } : {}), + ...(!person.birthDate && parseBirthDate(profile.birthday) + ? { birthDate: parseBirthDate(profile.birthday) } + : {}), addresses: merged as Prisma.InputJsonValue, tags, ...(!existingLink && !ownedByStore && !person.previousImportSource @@ -511,6 +515,7 @@ export class MontifySyncService { tags: [row.label], importSource: 'montify', sourceRef: externalId, + birthDate: parseBirthDate(profile.birthday), }, }); personId = created.id; diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 83049a3..aae09d7 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -2,7 +2,9 @@ import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, + type OnModuleInit, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; @@ -16,6 +18,7 @@ import { MatchPersonDto, SearchTahesabDto, } from './dto'; +import { parseBirthDate } from '../../common/excel'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */ @@ -57,8 +60,21 @@ function pickTel(row: Record): string { return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? ''); } +function pickBirthDate(row: Record): Date | null { + const raw = + row.BDate ?? + row.bDate ?? + row.Tarikh_Tavalod ?? + row.tarikh_tavalod ?? + row.BirthDate; + if (raw == null) return null; + return parseBirthDate(String(raw)); +} + @Injectable() export class TahesabPersonLinkService { + private readonly logger = new Logger(TahesabPersonLinkService.name); + constructor( private readonly prisma: PrismaService, private readonly connections: TahesabConnectionsService, @@ -522,6 +538,7 @@ export class TahesabPersonLinkService { ...carryPreviousSource(existing, 'tahesab'), importSource: 'tahesab', sourceRef, + ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(mobile ? { mobile: existing.mobile || mobile, @@ -558,6 +575,7 @@ export class TahesabPersonLinkService { ...carryPreviousSource(byMobile, 'tahesab'), importSource: 'tahesab', sourceRef, + ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), }, }); personId = byMobile.id; @@ -572,6 +590,7 @@ export class TahesabPersonLinkService { notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, importSource: 'tahesab', sourceRef, + birthDate: pickBirthDate(row), }, }); personId = created.id; @@ -702,6 +721,13 @@ export class TahesabPersonLinkService { try { const linkedPersonId = linkedByCode.get(moshtariCode); if (linkedPersonId) { + const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {}); + if (birthDate) { + await this.prisma.person.updateMany({ + where: { id: linkedPersonId, birthDate: null }, + data: { birthDate }, + }); + } items.push({ moshtariCode, ok: true, @@ -740,4 +766,84 @@ export class TahesabPersonLinkService { return { imported, skipped, failed, items }; } + + /** مشتری‌های قبلاً لینک‌شده که تاریخ تولد ندارند را از فهرست ته‌حساب پر می‌کند. */ + async fillMissingBirthDates() { + const connections = await this.prisma.accountingConnection.findMany({ + where: { enabled: true }, + select: { id: true, organizationId: true }, + }); + let filled = 0; + for (const connection of connections) { + try { + const links = await this.prisma.personAccountingLink.findMany({ + where: { connectionId: connection.id, person: { birthDate: null } }, + select: { personId: true, moshtariCode: true }, + }); + if (!links.length) continue; + const recent = await this.prisma.accountingSyncJob.findFirst({ + where: { + connectionId: connection.id, + kind: 'birthdate', + createdAt: { gte: new Date(Date.now() - 12 * 60 * 60 * 1000) }, + }, + select: { id: true }, + }); + if (recent) continue; + await this.prisma.accountingSyncJob.create({ + data: { + organizationId: connection.organizationId, + connectionId: connection.id, + kind: 'birthdate', + status: 'success', + }, + }); + const { api } = await this.connections.getApi(connection.organizationId, connection.id); + const codes = [...new Set(links.map((link) => link.moshtariCode))].sort((a, b) => a - b); + const personByCode = new Map(links.map((link) => [link.moshtariCode, link.personId])); + let index = 0; + while (index < codes.length) { + const from = codes[index]!; + let end = index; + while ( + end + 1 < codes.length && + codes[end + 1]! - from < 2000 && + end - index < 400 + ) { + end += 1; + } + const to = codes[end]!; + const raw = await api.doListMoshtariByCode(from, to); + for (const row of asMoshtariList(raw)) { + const code = pickCode(row); + const personId = code == null ? undefined : personByCode.get(code); + const birthDate = pickBirthDate(row); + if (!personId || !birthDate) continue; + const updated = await this.prisma.person.updateMany({ + where: { id: personId, birthDate: null }, + data: { birthDate }, + }); + filled += updated.count; + } + index = end + 1; + } + } catch (err) { + this.logger.warn( + `تاریخ تولد ته‌حساب برای اتصال ${connection.id} خوانده نشد: ${ + err instanceof Error ? err.message : err + }`, + ); + } + } + if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از ته‌حساب پر شد`); + } +} + +@Injectable() +export class TahesabBirthDateBackfill implements OnModuleInit { + constructor(private readonly links: TahesabPersonLinkService) {} + + onModuleInit() { + void this.links.fillMissingBirthDates(); + } } diff --git a/apps/api/src/integrations/tahesab/tahesab.module.ts b/apps/api/src/integrations/tahesab/tahesab.module.ts index 0129b83..b1dbee8 100644 --- a/apps/api/src/integrations/tahesab/tahesab.module.ts +++ b/apps/api/src/integrations/tahesab/tahesab.module.ts @@ -3,7 +3,10 @@ import { AuthModule } from '../../auth/auth.module'; import { TahesabCryptoService } from './tahesab-crypto.service'; import { TahesabHttpClient } from './tahesab-http.client'; import { TahesabConnectionsService } from './connections.service'; -import { TahesabPersonLinkService } from './person-link.service'; +import { + TahesabBirthDateBackfill, + TahesabPersonLinkService, +} from './person-link.service'; import { TahesabProductLinkService } from './product-link.service'; import { TahesabBalanceService } from './balance.service'; import { TahesabDocumentsService } from './documents.service'; @@ -17,6 +20,7 @@ import { TahesabController } from './tahesab.controller'; TahesabHttpClient, TahesabConnectionsService, TahesabPersonLinkService, + TahesabBirthDateBackfill, TahesabProductLinkService, TahesabBalanceService, TahesabDocumentsService, diff --git a/apps/api/src/leads/dto.ts b/apps/api/src/leads/dto.ts index 2264510..194122a 100644 --- a/apps/api/src/leads/dto.ts +++ b/apps/api/src/leads/dto.ts @@ -22,6 +22,7 @@ export class CreateLeadDto { @IsOptional() @IsString() city?: string; @IsOptional() @IsString() region?: string; @IsOptional() @IsString() instagram?: string; + @IsOptional() @IsString() birthDate?: string | null; @IsOptional() @IsArray() @IsString({ each: true }) addresses?: string[]; @IsOptional() @IsString() trade?: string; @IsOptional() @IsString() source?: string; @@ -42,6 +43,7 @@ export class UpdateLeadDto { @IsOptional() @IsString() city?: string | null; @IsOptional() @IsString() region?: string | null; @IsOptional() @IsString() instagram?: string | null; + @IsOptional() @IsString() birthDate?: string | null; @IsOptional() @IsArray() @IsString({ each: true }) addresses?: string[]; @IsOptional() @IsString() trade?: string | null; @IsOptional() @IsString() source?: string | null; diff --git a/apps/api/src/leads/leads-import.service.ts b/apps/api/src/leads/leads-import.service.ts index 0ae1e8a..6b93ceb 100644 --- a/apps/api/src/leads/leads-import.service.ts +++ b/apps/api/src/leads/leads-import.service.ts @@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client'; import type { LeadImportJobDto } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import { + parseBirthDate, parseExcelMapping, parseJalaliDateTime, previewExcel, @@ -55,6 +56,12 @@ const LEAD_ALIASES: Record = { اینستاگرام: 'instagram', پیجاینستاگرام: 'instagram', آیدیاینستاگرام: 'instagram', + birthdate: 'birthDate', + dateofbirth: 'birthDate', + bdate: 'birthDate', + تاریختولد: 'birthDate', + تاریختولدمشتری: 'birthDate', + تاریختولدشمسی: 'birthDate', registeredat: 'registeredAt', تاریخثبت: 'registeredAt', تاریخثبتمشتری: 'registeredAt', @@ -81,6 +88,7 @@ const LEAD_FIELDS: ExcelFieldOption[] = [ { key: 'homeAddress', label: 'آدرس منزل' }, { key: 'instagram', label: 'اینستاگرام' }, { key: 'registeredAt', label: 'تاریخ ثبت' }, + { key: 'birthDate', label: 'تاریخ تولد' }, { key: 'trade', label: 'صنف' }, { key: 'source', label: 'منبع' }, { key: 'notes', label: 'توضیحات' }, @@ -284,6 +292,9 @@ export class LeadsImportService { address2: get('address2', 'آدرس ۲', 'آدرس2', 'آدرس دوم'), homeAddress: get('homeAddress', 'آدرس منزل', 'آدرس خانه'), instagram: get('instagram', 'اینستاگرام', 'پیج اینستاگرام', 'آی دی اینستاگرام'), + birthDate: parseBirthDate( + get('birthDate', 'تاریخ تولد', 'تاریخ تولد مشتری', 'تاریخ تولد شمسی', 'birthdate'), + )?.toISOString() ?? '', registeredAt, trade: get('trade', 'صنف', 'رسته'), source: get('source', 'منبع', 'منبع لید'), diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index da8e937..34d1baf 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import { parseBirthDate } from '../common/excel'; import * as path from 'node:path'; import { randomUUID } from 'node:crypto'; import type { @@ -268,6 +269,7 @@ export class LeadsService { city?: string | null; region?: string | null; instagram?: string | null; + birthDate?: string | null; addresses?: string[]; trade?: string | null; source?: string | null; @@ -314,6 +316,7 @@ export class LeadsService { city: emptyToNull(dto.city), region: emptyToNull(dto.region), instagram: emptyToNull(dto.instagram), + birthDate: parseBirthDate(dto.birthDate), addresses: toJsonAddresses(dto.addresses), trade: emptyToNull(dto.trade), source: emptyToNull(dto.source), @@ -398,6 +401,9 @@ export class LeadsService { ...(dto.instagram !== undefined ? { instagram: emptyToNull(dto.instagram as string) } : {}), + ...(dto.birthDate !== undefined + ? { birthDate: parseBirthDate(dto.birthDate as string | null) } + : {}), ...(dto.addresses !== undefined ? { addresses: toJsonAddresses( @@ -802,6 +808,7 @@ export class LeadsService { mobile: lead.mobile, phone: lead.phone, instagram: lead.instagram, + birthDate: lead.birthDate, addresses: toJsonAddresses(asAddresses(lead.addresses)), notes: [ lead.notes, @@ -819,6 +826,11 @@ export class LeadsService { }, }); createdPerson = true; + } else if (!person.birthDate && lead.birthDate) { + await this.prisma.person.update({ + where: { id: person.id }, + data: { birthDate: lead.birthDate }, + }); } const convertedStage = await this.prisma.leadStage.findFirst({ @@ -985,6 +997,7 @@ export class LeadsService { address2?: string; homeAddress?: string; instagram?: string; + birthDate?: string; registeredAt?: string; trade?: string; source?: string; @@ -996,11 +1009,20 @@ export class LeadsService { const title = String(row.title ?? row.contactName ?? '').trim(); if (!mobile || !title) return 'error'; + const birthDate = parseBirthDate(row.birthDate); const existingLead = await this.prisma.lead.findFirst({ where: { organizationId, mobile }, select: { id: true }, }); - if (existingLead) return 'duplicate'; + if (existingLead) { + if (birthDate) { + await this.prisma.lead.updateMany({ + where: { id: existingLead.id, birthDate: null }, + data: { birthDate }, + }); + } + return 'duplicate'; + } const existingPerson = await this.prisma.person.findFirst({ where: { @@ -1009,7 +1031,15 @@ export class LeadsService { }, select: { id: true }, }); - if (existingPerson) return 'duplicate'; + if (existingPerson) { + if (birthDate) { + await this.prisma.person.updateMany({ + where: { id: existingPerson.id, birthDate: null }, + data: { birthDate }, + }); + } + return 'duplicate'; + } try { const registeredAt = row.registeredAt ? new Date(row.registeredAt) : null; @@ -1023,6 +1053,7 @@ export class LeadsService { city: emptyToNull(row.city), region: emptyToNull(row.region), instagram: emptyToNull(row.instagram), + birthDate, addresses: toJsonAddresses( collectAddresses(row.address, row.address2, row.homeAddress), ), @@ -1101,6 +1132,7 @@ export class LeadsService { city: row.city, region: row.region, instagram: row.instagram, + birthDate: row.birthDate?.toISOString() ?? null, addresses: asAddresses(row.addresses), trade: row.trade, source: row.source, diff --git a/apps/api/test/dates.test.ts b/apps/api/test/dates.test.ts index 445625a..7746550 100644 --- a/apps/api/test/dates.test.ts +++ b/apps/api/test/dates.test.ts @@ -3,7 +3,9 @@ import assert from 'node:assert/strict'; import { addJalaliMonths, endOfJalaliMonth, + formatTehranJalali, jalaliMonthName, + rewriteGregorianDates, toGregorian, toJalali, } from '../src/common/jalali'; @@ -102,3 +104,21 @@ describe('یادآور چک', () => { assert.ok(!isDueInDays(new Date('2026-10-12T12:00:00Z'), 3, now)); }); }); + +describe('بازنویسی تاریخ میلادی در متن', () => { + const now = new Date('2026-10-13T08:00:00Z'); + + it('۲۱ سپتامبر را شمسی می‌کند و مهر را دست نمی‌زند', () => { + const out = rewriteGregorianDates('آخرین خرید در تاریخ 21 سپتامبر انجام شده', now); + assert.equal(out.includes('سپتامبر'), false); + assert.match(out, /۱۴۰۵\/۰۶\/۳۰/); + assert.equal(rewriteGregorianDates('مهلت تا 21 مهر ۱۴۰۵', now), 'مهلت تا 21 مهر ۱۴۰۵'); + assert.equal(rewriteGregorianDates('می‌شود 21 بار', now), 'می‌شود 21 بار'); + }); + + it('September و تاریخ عددی میلادی را شمسی می‌کند', () => { + assert.match(rewriteGregorianDates('bought on September 21, 2025', now), /۱۴۰۴\/۰۶\/۳۰/); + assert.equal(rewriteGregorianDates('due 2026-09-21', now), 'due ۱۴۰۵/۰۶/۳۰'); + assert.equal(formatTehranJalali(new Date('2026-09-21T08:00:00Z')), '۱۴۰۵/۰۶/۳۰'); + }); +}); diff --git a/apps/web/src/components/AskMentoPanel.vue b/apps/web/src/components/AskMentoPanel.vue index ad08566..94554b2 100644 --- a/apps/web/src/components/AskMentoPanel.vue +++ b/apps/web/src/components/AskMentoPanel.vue @@ -3,6 +3,7 @@ import { ref } from 'vue'; import { RouterLink } from 'vue-router'; import axios from 'axios'; import { api } from '@/lib/api'; +import { rewriteGregorianDates } from '@/lib/jalali'; import UiIcon from '@/components/UiIcon.vue'; import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared'; @@ -43,7 +44,7 @@ async function submit() { personId: props.personId || undefined, chatContext: props.chatContext?.trim() || undefined, }); - result.value = data; + result.value = { ...data, answer: rewriteGregorianDates(data.answer) }; } catch (e) { if (axios.isAxiosError(e)) { const msg = (e.response?.data as { message?: string | string[] })?.message; diff --git a/apps/web/src/components/EntityTimeline.vue b/apps/web/src/components/EntityTimeline.vue index f3d3d1e..7a750d6 100644 --- a/apps/web/src/components/EntityTimeline.vue +++ b/apps/web/src/components/EntityTimeline.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref, watch } from 'vue'; import { RouterLink } from 'vue-router'; import { api } from '@/lib/api'; +import { formatJalaliDateTime } from '@/lib/jalali'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import { appendSpokenText } from '@/lib/spoken-text'; import type { @@ -63,14 +64,9 @@ function noteTypeLabel(value?: string | null) { } function formatTime(iso: string) { - try { - return new Intl.DateTimeFormat('fa-IR', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(iso)); - } catch { - return iso; - } + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return formatJalaliDateTime(d); } async function load() { diff --git a/apps/web/src/components/MentoMemoryPanel.vue b/apps/web/src/components/MentoMemoryPanel.vue index 3a60c49..c1bd4d9 100644 --- a/apps/web/src/components/MentoMemoryPanel.vue +++ b/apps/web/src/components/MentoMemoryPanel.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'; import axios from 'axios'; import { api } from '@/lib/api'; import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared'; +import { formatJalaliDateTime } from '@/lib/jalali'; const props = defineProps<{ personId: string; @@ -97,14 +98,9 @@ async function removeItem(id: string) { } function formatWhen(value: string) { - try { - return new Intl.DateTimeFormat('fa-IR', { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(value)); - } catch { - return value; - } + const d = new Date(value); + if (Number.isNaN(d.getTime())) return value; + return formatJalaliDateTime(d); } const hasItems = computed(() => items.value.length > 0); diff --git a/apps/web/src/components/NoteAiSuggestionPanel.vue b/apps/web/src/components/NoteAiSuggestionPanel.vue index bb07b73..8ac3396 100644 --- a/apps/web/src/components/NoteAiSuggestionPanel.vue +++ b/apps/web/src/components/NoteAiSuggestionPanel.vue @@ -4,6 +4,7 @@ import axios from 'axios'; import { api } from '@/lib/api'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared'; +import { rewriteGregorianDates } from '@/lib/jalali'; const props = defineProps<{ noteId: string; @@ -142,7 +143,7 @@ async function dismiss() { function formatPayloadSummary(value: NoteAiPayloadDto | null) { if (!value?.summary) return ''; - return value.summary; + return rewriteGregorianDates(value.summary); } diff --git a/apps/web/src/components/dashboard/HomeAiAssistant.vue b/apps/web/src/components/dashboard/HomeAiAssistant.vue index 2b8e25b..8b6ccab 100644 --- a/apps/web/src/components/dashboard/HomeAiAssistant.vue +++ b/apps/web/src/components/dashboard/HomeAiAssistant.vue @@ -5,7 +5,7 @@ import axios from 'axios'; import UiIcon from '@/components/UiIcon.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; -import { formatLocalDateTimeInput } from '@/lib/jalali'; +import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali'; import { UiButton, UiModal } from '@/components/ui'; import { api } from '@/lib/api'; import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared'; @@ -150,7 +150,7 @@ async function parseCommand(text: string, fallbackToAsk: boolean) { } voiceError.value = looksLikeAction(text) ? 'دستور را کامل نفهمیدم. نام و مشخصات را واضح‌تر بگویید تا قبل از ساخت، تأیید کنید.' - : data.summary; + : rewriteGregorianDates(data.summary); return; } draft.value = data; @@ -328,7 +328,7 @@ watch( :title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'" @close="cancelDraft" > - +

قبل از تأیید می‌توانید متن را اصلاح کنید.