diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index 835ea7d..9b1d28d 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -12,22 +12,33 @@ import { PrismaService } from '../prisma/prisma.service'; import { type Actor, assigneeWhere, + elevated, + forScope, noteAccessWhere, + dealAccessWhere, ownerWhere, + projectCardAccessWhere, ticketAccessWhere, } from '../common/record-scope'; import { BILLING_PLAN_LABELS, BUSINESS_FIELD_LABELS, + PERSON_IMPORT_SOURCE_LABELS, USER_ROLE_LABELS, brand, brandSuggestionFa, isBusinessField, type BillingPlanCode, + type PersonImportSource, type UserRole, } from '@mento/shared'; import { startOfJalaliMonth, toJalali } from '../common/jalali'; import { AiService } from './ai.service'; +import { + classifyCountQuestion, + personSourceKeys, + type CountIntent, +} from './ask-count'; type CatalogItem = AskMentoSourceDto; @@ -136,6 +147,9 @@ export class AiAskService { personId?: string, chatContext?: string, ): Promise { + const direct = await this.directCount(actor, question); + if (direct) return { answer: direct, sources: [] }; + const { context, catalog } = await this.buildContext(actor, personId); const chatBlock = String(chatContext || '') .trim() @@ -146,10 +160,13 @@ export class AiAskService { }>( `You are ${brand.name}, a Persian CRM assistant for this user's account. "محیط" means a workspace/organization the user belongs to. -"مشتری" / "مشتری جدید" means a Person (contact) record. +"مشتری" means a Person record. "لید" means a Lead record. They are different. Never answer a lead question with the people/customer total. +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). -Workspace facts and Period stats are complete and authoritative for counts of customers, companies, activities, deals, won deals, invoices, quotes, products, notes, and tickets. -Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable. +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. If a "Recent conversation" block is provided, use it only to understand the user's question and tone; do not invent CRM facts from chat alone. Do not invent customers, amounts, dates, or promises. Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] } @@ -203,6 +220,333 @@ Rules: }; } + private leadWhere(actor: Actor) { + const scoped = forScope(actor, 'leads'); + if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {}; + if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) { + return { assigneeId: { in: scoped.visibleUserIds } }; + } + return { assigneeId: scoped.userId }; + } + + private fa(n: number) { + return new Intl.NumberFormat('fa-IR').format(n); + } + + private periodRange(key: NonNullable) { + const periods = tehranPeriods(); + if (key === 'today') return periods.today; + if (key === 'yesterday') return periods.yesterday; + if (key === 'week') return periods.week; + return periods.month; + } + + private async directCount(actor: Actor, question: string): Promise { + const intent = classifyCountQuestion(question); + if (!intent) return null; + const orgId = actor.organizationId; + const created = + intent.period != null + ? { + createdAt: { + gte: this.periodRange(intent.period).start, + lte: this.periodRange(intent.period).end, + }, + } + : {}; + const periodNote = + intent.period === 'today' + ? 'امروز' + : intent.period === 'yesterday' + ? 'دیروز' + : intent.period === 'week' + ? 'این هفته' + : intent.period === 'month' + ? 'این ماه' + : ''; + + if (intent.entity === 'person' || intent.entity === 'company') { + const where = { + organizationId: orgId, + ...(intent.entity === 'person' ? ownerWhere(actor) : ownerWhere(actor)), + ...(intent.personSource ? { importSource: intent.personSource } : {}), + ...(intent.entity === 'person' && intent.mood ? { mood: intent.mood } : {}), + ...created, + }; + const n = + intent.entity === 'person' + ? await this.prisma.person.count({ where }) + : await this.prisma.company.count({ where }); + const who = intent.entity === 'person' ? 'مشتری' : 'شرکت'; + const source = intent.personSource + ? PERSON_IMPORT_SOURCE_LABELS[intent.personSource] + : ''; + const mood = + intent.mood === 'happy' + ? 'خوشحال' + : intent.mood === 'unhappy' + ? 'ناراضی' + : intent.mood === 'special' + ? 'خاص' + : intent.mood === 'newcomer' + ? 'تازه‌وارد' + : ''; + const bits = [source, mood, periodNote].filter(Boolean).join(' '); + const label = bits ? `${who}های ${bits}` : `کل ${who}ها`; + return `${label}: ${this.fa(n)} تا.`; + } + + if (intent.entity === 'lead') { + return this.directLeadCount(actor, intent, created, periodNote); + } + + if (intent.entity === 'deal') { + const scope = dealAccessWhere(actor); + const where = { + organizationId: orgId, + ...scope, + ...created, + ...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}), + ...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}), + ...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}), + ...(intent.dealBucket === 'stale' + ? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] } + : {}), + }; + const n = await this.prisma.deal.count({ where }); + const name = + intent.dealBucket === 'open' + ? 'معاملات باز' + : intent.dealBucket === 'won' + ? 'معاملات برنده' + : intent.dealBucket === 'lost' + ? 'معاملات ازدست‌رفته' + : intent.dealBucket === 'stale' + ? 'معاملات در خطر' + : 'کل معاملات'; + return `${periodNote ? `${name} ${periodNote}` : name}: ${this.fa(n)} تا.`; + } + + const simple: Record Promise> = { + invoice: () => + this.prisma.invoice.count({ + where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created }, + }), + quote: () => + this.prisma.quote.count({ + where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created }, + }), + product: () => + this.prisma.product.count({ where: { organizationId: orgId, ...created } }), + activity: () => + this.prisma.activity.count({ + where: { organizationId: orgId, ...assigneeWhere(actor), ...created }, + }), + ticket: () => + this.prisma.ticket.count({ + where: { organizationId: orgId, ...ticketAccessWhere(actor), ...created }, + }), + note: () => + this.prisma.note.count({ + where: { organizationId: orgId, ...noteAccessWhere(actor), ...created }, + }), + cheque: () => this.prisma.cheque.count({ where: { organizationId: orgId, ...created } }), + reservation: () => + this.prisma.reservationAppointment.count({ + where: { organizationId: orgId, ...created }, + }), + routine: () => + this.prisma.routineTask.count({ where: { organizationId: orgId, ...created } }), + project: () => + this.prisma.projectCard.count({ + where: { organizationId: orgId, ...projectCardAccessWhere(actor), ...created }, + }), + }; + const counter = simple[intent.entity]; + if (!counter) return null; + const labels: Record = { + invoice: 'فاکتور', + quote: 'پیش‌فاکتور', + product: 'محصول', + activity: 'فعالیت', + ticket: 'تیکت', + note: 'یادداشت', + cheque: 'چک', + reservation: 'رزرو', + routine: 'کار روتین', + project: 'کارت پروژه', + }; + const n = await counter(); + const name = labels[intent.entity] ?? intent.entity; + return `${periodNote ? `${name}های ${periodNote}` : `کل ${name}ها`}: ${this.fa(n)} تا.`; + } + + private async directLeadCount( + actor: Actor, + intent: CountIntent, + created: { createdAt?: { gte: Date; lte: Date } }, + periodNote: string, + ) { + const orgId = actor.organizationId; + const base = { organizationId: orgId, ...this.leadWhere(actor), ...created }; + const [stages, sources] = await Promise.all([ + this.prisma.leadStage.findMany({ + where: { organizationId: orgId }, + select: { id: true, name: true }, + }), + this.prisma.lead.groupBy({ + by: ['source'], + where: base, + _count: { _all: true }, + }), + ]); + const compact = (value: string) => value.replace(/[\s‌]+/g, '').toLowerCase(); + const hint = compact(intent.hint).length >= 2 ? compact(intent.hint) : ''; + const stage = hint + ? stages.find((row) => { + const name = compact(row.name); + return name.length >= 2 && (name.includes(hint) || hint.includes(name)); + }) + : undefined; + const sourceRow = hint + ? sources.find((row) => { + const name = compact(row.source ?? ''); + return name.length >= 2 && (name.includes(hint) || hint.includes(name)); + }) + : undefined; + + if (hint && !stage && !sourceRow && intent.leadBucket === 'summary') { + return `لیدی با فیلتر «${intent.hint}» پیدا نشد: ${this.fa(0)} تا.`; + } + + if (intent.leadBucket === 'summary' && !stage && !sourceRow) { + const [open, archived, converted, total] = await Promise.all([ + this.prisma.lead.count({ + where: { ...base, archivedAt: null, convertedPersonId: null }, + }), + this.prisma.lead.count({ where: { ...base, archivedAt: { not: null } } }), + this.prisma.lead.count({ + where: { ...base, convertedPersonId: { not: null }, archivedAt: null }, + }), + this.prisma.lead.count({ where: base }), + ]); + const when = periodNote ? ` ${periodNote}` : ''; + return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`; + } + + const where = { + ...base, + ...(intent.leadBucket === 'archived' ? { archivedAt: { not: null } } : {}), + ...(intent.leadBucket === 'converted' + ? { convertedPersonId: { not: null }, archivedAt: null } + : {}), + ...(intent.leadBucket === 'open' + ? { archivedAt: null, convertedPersonId: null } + : {}), + ...(stage ? { stageId: stage.id } : {}), + ...(sourceRow ? { source: sourceRow.source } : {}), + }; + const n = await this.prisma.lead.count({ where }); + const bits = [ + stage?.name, + sourceRow?.source || '', + intent.leadBucket === 'archived' + ? 'بایگانی' + : intent.leadBucket === 'converted' + ? 'تبدیل‌شده' + : intent.leadBucket === 'open' + ? 'باز' + : '', + periodNote, + ].filter(Boolean); + return `لیدهای ${bits.join(' ')}: ${this.fa(n)} تا.`; + } + + private async filterFactLines(actor: Actor) { + const orgId = actor.organizationId; + const personScope = ownerWhere(actor); + const companyScope = ownerWhere(actor); + const leadBase = { organizationId: orgId, ...this.leadWhere(actor) }; + const [peopleSources, companySources, stages, leadSources, leadCounts, extras] = + await Promise.all([ + this.prisma.person.groupBy({ + by: ['importSource'], + where: { organizationId: orgId, ...personScope }, + _count: { _all: true }, + }), + this.prisma.company.groupBy({ + by: ['importSource'], + where: { organizationId: orgId, ...companyScope }, + _count: { _all: true }, + }), + this.prisma.leadStage.findMany({ + where: { organizationId: orgId }, + select: { id: true, name: true }, + orderBy: { position: 'asc' }, + }), + this.prisma.lead.groupBy({ + by: ['source'], + where: leadBase, + _count: { _all: true }, + }), + Promise.all([ + this.prisma.lead.count({ where: leadBase }), + this.prisma.lead.count({ + where: { ...leadBase, archivedAt: null, convertedPersonId: null }, + }), + this.prisma.lead.count({ where: { ...leadBase, archivedAt: { not: null } } }), + this.prisma.lead.count({ + where: { ...leadBase, convertedPersonId: { not: null }, archivedAt: null }, + }), + ]), + Promise.all([ + this.prisma.cheque.count({ where: { organizationId: orgId } }), + this.prisma.reservationAppointment.count({ where: { organizationId: orgId } }), + this.prisma.routineTask.count({ where: { organizationId: orgId } }), + this.prisma.projectCard.count({ where: { organizationId: orgId } }), + ]), + ]); + + const sourceLine = ( + label: string, + rows: Array<{ importSource: string; _count: { _all: number } }>, + ) => { + const parts = personSourceKeys().map((key) => { + const hit = rows.find((row) => (row.importSource || 'manual') === key); + const name = PERSON_IMPORT_SOURCE_LABELS[key as PersonImportSource]; + return `${name}/${key}=${hit?._count._all ?? 0}`; + }); + return `${label}: ${parts.join(' | ')}`; + }; + + const stageCounts = await this.prisma.lead.groupBy({ + by: ['stageId'], + where: { ...leadBase, archivedAt: null, convertedPersonId: null }, + _count: { _all: true }, + }); + const stageLine = stages + .map((stage) => { + const hit = stageCounts.find((row) => row.stageId === stage.id); + return `${stage.name}=${hit?._count._all ?? 0}`; + }) + .join(' | '); + const sourceBits = leadSources + .map((row) => `${row.source?.trim() || '(بدون منبع)'}=${row._count._all}`) + .join(' | '); + const [leadTotal, leadOpen, leadArchived, leadConverted] = leadCounts; + const [cheques, reservations, routines, projects] = extras; + + return [ + 'People by import source (مشتری؛ دستی=manual). Use the matching source, never the people total, when a source is named:', + sourceLine('People sources', peopleSources), + sourceLine('Company sources', companySources), + `Leads are NOT people. open=${leadOpen} | archived=${leadArchived} | converted=${leadConverted} | total=${leadTotal}`, + `Lead stages (open only): ${stageLine || 'none'}`, + `Lead sources: ${sourceBits || 'none'}`, + `Other totals: cheques=${cheques} | reservations=${reservations} | routine tasks=${routines} | project cards=${projects}`, + ]; + } + private async countInPeriods( count: (start: Date, end: Date) => Promise, periods: ReturnType, @@ -589,6 +933,7 @@ Rules: `Environments: ${envLines.join(' | ') || 'none'}`, `Current environment: ${org?.name ?? '—'} | business field: ${field} | team members: ${teamCount}`, `Totals: people=${peopleTotal} | companies=${companiesTotal} | products=${productsTotal} | open deals=${openDealCount} | at-risk deals=${staleDealCount} | overdue activities=${overdueActivityCount} | activities due today=${activitiesDueToday}`, + ...(await this.filterFactLines(actor)), 'Period stats (created/registered unless noted):', formatPeriodCounts('People / مشتریان جدید', peoplePeriods), peopleTodayNames.length diff --git a/apps/api/src/ai/ask-count.ts b/apps/api/src/ai/ask-count.ts new file mode 100644 index 0000000..e09a83c --- /dev/null +++ b/apps/api/src/ai/ask-count.ts @@ -0,0 +1,236 @@ +import { + PERSON_IMPORT_SOURCES, + type PersonImportSource, +} from '@mento/shared'; + +export type CountEntity = + | 'lead' + | 'person' + | 'company' + | 'deal' + | 'invoice' + | 'quote' + | 'product' + | 'activity' + | 'ticket' + | 'note' + | 'cheque' + | 'reservation' + | 'routine' + | 'project'; + +export type CountPeriod = 'today' | 'yesterday' | 'week' | 'month'; + +export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted'; +export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale'; + +export type CountIntent = { + entity: CountEntity; + personSource?: PersonImportSource; + mood?: 'newcomer' | 'happy' | 'unhappy' | 'special'; + leadBucket: LeadBucket; + dealBucket: DealBucket; + period?: CountPeriod; + /** متن باقی‌مانده برای تطبیق مرحله یا منبع لید */ + hint: string; +}; + +const SOURCE_PATTERNS: Array<{ re: RegExp; source: PersonImportSource }> = [ + { re: /دستی/, source: 'manual' }, + { re: /اکسل/, source: 'excel' }, + { re: /دیدار/, source: 'didar' }, + { re: /ته\s*حساب/, source: 'tahesab' }, + { re: /گوشی|مخاطب\s*گوشی|مخاطبین/, source: 'phone' }, + { re: /فرم/, source: 'lead_form' }, + { re: /مونتیفای/, source: 'montify' }, + { re: /سایر|بقیه/, source: 'other' }, +]; + +export function normalizeAskText(raw: string) { + return raw + .replace(/ي/g, 'ی') + .replace(/ك/g, 'ک') + .replace(/‌/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +export function isCountQuestion(q: string) { + return /چند|تعداد|چقدر|چنتا|چند\s*تا/.test(q); +} + +function detectEntity(q: string): CountEntity | null { + if (/لید|سرنخ/.test(q)) return 'lead'; + if (/پیش\s*فاکتور/.test(q)) return 'quote'; + if (/فاکتور/.test(q)) return 'invoice'; + if (/معامله/.test(q)) return 'deal'; + if (/چک/.test(q)) return 'cheque'; + if (/رزرو|نوبت/.test(q)) return 'reservation'; + if (/روتین/.test(q)) return 'routine'; + if (/پروژه/.test(q)) return 'project'; + if (/تیکت/.test(q)) return 'ticket'; + if (/یادداشت/.test(q)) return 'note'; + if (/فعالیت/.test(q)) return 'activity'; + if (/محصول|کالا/.test(q)) return 'product'; + if (/شرکت/.test(q) && !/مشتری/.test(q)) return 'company'; + if (/مشتری|اشخاص|شخص ها|افراد|مخاطب/.test(q)) return 'person'; + return null; +} + +function detectPeriod(q: string): CountPeriod | undefined { + if (/دیروز/.test(q)) return 'yesterday'; + if (/امروز/.test(q)) return 'today'; + if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week'; + if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month'; + return undefined; +} + +function detectLeadBucket(q: string): LeadBucket { + if (/بایگانی/.test(q)) return 'archived'; + if (/تبدیل/.test(q)) return 'converted'; + if (/باز|فعال|جاری/.test(q)) return 'open'; + return 'summary'; +} + +function detectDealBucket(q: string): DealBucket { + if (/در\s*خطر|راکد|گندیده/.test(q)) return 'stale'; + if (/برنده|موفق|بسته\s*شده\s*موفق/.test(q)) return 'won'; + if (/باخته|از\s*دست\s*رفته|ناموفق/.test(q)) return 'lost'; + if (/باز|جاری|فعال/.test(q)) return 'open'; + return 'all'; +} + +function detectMood(q: string): CountIntent['mood'] { + if (/ناراضی/.test(q)) return 'unhappy'; + if (/خوشحال/.test(q)) return 'happy'; + if (/خاص/.test(q)) return 'special'; + if (/تازه/.test(q)) return 'newcomer'; + return undefined; +} + +const HINT_STOP = [ + 'چند تا', + 'ته حساب', + 'مخاطب گوشی', + 'مخاطبین', + 'پیش فاکتور', + 'بایگانی', + 'مونتیفای', + 'معاملات', + 'مشتریان', + 'سرنخ', + 'لیدها', + 'فعالیت', + 'یادداشت', + 'بگویید', + 'داریم', + 'دارید', + 'هستند', + 'تعداد', + 'چقدر', + 'چنتا', + 'امروز', + 'دیروز', + 'جاری', + 'تبدیل', + 'برنده', + 'باخته', + 'ناراضی', + 'خوشحال', + 'محصول', + 'فاکتور', + 'معامله', + 'مشتری', + 'شرکت', + 'پروژه', + 'روتین', + 'رزرو', + 'نوبت', + 'تیکت', + 'منبع', + 'مرحله', + 'وضعیت', + 'هفته', + 'لطفا', + 'الان', + 'شده', + 'وارد', + 'تازه', + 'خاص', + 'بگو', + 'دارم', + 'هست', + 'است', + 'جاری', + 'فعال', + 'باز', + 'لید', + 'کالا', + 'چک', + 'ماه', + 'پیش', + 'های', + 'چند', + 'کل', + 'همه', + 'این', + 'یک', + 'یه', + 'از', + 'با', + 'در', + 'تا', + 'ها', +].sort((a, b) => b.length - a.length); + +const PERSON_HINT_STOP = [ + 'ته حساب', + 'مخاطب گوشی', + 'مخاطبین', + 'مونتیفای', + 'ناراضی', + 'خوشحال', + 'دستی', + 'اکسل', + 'دیدار', + 'گوشی', + 'مخاطب', + 'فرم', + 'سایر', + 'تازه', + 'خاص', +].sort((a, b) => b.length - a.length); + +function leftoverHint(q: string, entity: CountEntity) { + let text = ` ${q} `; + const words = entity === 'person' || entity === 'company' + ? [...HINT_STOP, ...PERSON_HINT_STOP] + : HINT_STOP; + for (const word of [...words].sort((a, b) => b.length - a.length)) { + text = text.replaceAll(word, ' '); + } + return text.replace(/\s+/g, ' ').trim(); +} + +export function classifyCountQuestion(raw: string): CountIntent | null { + const q = normalizeAskText(raw); + if (!isCountQuestion(q)) return null; + const entity = detectEntity(q); + if (!entity) return null; + const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source; + return { + entity, + personSource: + entity === 'person' || entity === 'company' ? personSource : undefined, + mood: entity === 'person' ? detectMood(q) : undefined, + leadBucket: detectLeadBucket(q), + dealBucket: detectDealBucket(q), + period: detectPeriod(q), + hint: leftoverHint(q, entity), + }; +} + +export function personSourceKeys(): readonly PersonImportSource[] { + return PERSON_IMPORT_SOURCES; +} diff --git a/apps/web/src/components/dashboard/HomeAiAssistant.vue b/apps/web/src/components/dashboard/HomeAiAssistant.vue index 809fd36..2b8e25b 100644 --- a/apps/web/src/components/dashboard/HomeAiAssistant.vue +++ b/apps/web/src/components/dashboard/HomeAiAssistant.vue @@ -22,6 +22,8 @@ const emit = defineEmits<{ 'update:askQuery': [value: string]; ask: [preset?: string]; clear: []; + busy: [value: boolean]; + notice: [value: { error: string; done: string }]; }>(); const shortcuts = [ @@ -69,7 +71,9 @@ function onVoiceTranscript(text: string) { } function looksLikeAction(text: string) { - return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید/.test(text); + return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید|یک لید|لید جدید/.test( + text, + ); } function refreshDraft() { @@ -140,11 +144,13 @@ async function parseCommand(text: string, fallbackToAsk: boolean) { try { const { data } = await api.post('/ai/voice-command', { text }); if (data.intent === 'none') { - if (fallbackToAsk) { + if (fallbackToAsk && !looksLikeAction(text)) { emit('ask', text); return; } - voiceError.value = data.summary; + voiceError.value = looksLikeAction(text) + ? 'دستور را کامل نفهمیدم. نام و مشخصات را واضح‌تر بگویید تا قبل از ساخت، تأیید کنید.' + : data.summary; return; } draft.value = data; @@ -177,6 +183,9 @@ async function onTypedSubmit() { await parseCommand(text, true); return; } + voiceError.value = ''; + voiceDone.value = ''; + draft.value = null; emit('ask'); } @@ -241,6 +250,16 @@ watch( }, ); +watch([voiceBusy, confirming], () => { + emit('busy', voiceBusy.value || confirming.value); +}); + +watch([voiceError, voiceDone], ([error, done]) => { + emit('notice', { error, done }); +}); + +defineExpose({ submitFromInput: onTypedSubmit }); + watch( () => props.answer, (value) => { diff --git a/apps/web/src/components/dashboard/HomeHero.vue b/apps/web/src/components/dashboard/HomeHero.vue index d6502c8..be7d849 100644 --- a/apps/web/src/components/dashboard/HomeHero.vue +++ b/apps/web/src/components/dashboard/HomeHero.vue @@ -15,6 +15,7 @@ const props = defineProps<{ asking?: boolean; askAnswer?: string; askError?: string; + statusText?: string; }>(); const emit = defineEmits<{ @@ -113,7 +114,7 @@ const shortcutLabel = computed(() => -

در حال پاسخ…

+

{{ statusText || 'در حال پاسخ…' }}

{{ askError }}

{{ askAnswer }}

diff --git a/apps/web/src/components/pipeline/DealCard.vue b/apps/web/src/components/pipeline/DealCard.vue index 6825ef9..5addce2 100644 --- a/apps/web/src/components/pipeline/DealCard.vue +++ b/apps/web/src/components/pipeline/DealCard.vue @@ -34,7 +34,7 @@ const showAmount = computed( () => !props.copy.hideAmount && props.deal.amount != null, ); const money = computed(() => - formatCurrencyParts(props.deal.amount ?? 0, props.deal.currencyUnit ?? 'تومان'), + formatCurrencyParts(props.deal.amount ?? 0, props.deal.currencyUnit ?? 'ریال'), ); const colors = computed(() => PIPELINE_ACCENT[props.accent]); const assigneeTitle = computed(() => diff --git a/apps/web/src/components/pipeline/PipelineList.vue b/apps/web/src/components/pipeline/PipelineList.vue index 2e4d109..a62c119 100644 --- a/apps/web/src/components/pipeline/PipelineList.vue +++ b/apps/web/src/components/pipeline/PipelineList.vue @@ -37,7 +37,7 @@ const rows = computed(() => ); function money(deal: PipelineDeal) { - return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'تومان'); + return formatCurrencyParts(deal.amount ?? 0, deal.currencyUnit ?? 'ریال'); } const hideAmount = computed(() => Boolean(props.copy.hideAmount)); diff --git a/apps/web/src/components/routines/RelatedTasksPanel.vue b/apps/web/src/components/routines/RelatedTasksPanel.vue index 0a097b0..ca82945 100644 --- a/apps/web/src/components/routines/RelatedTasksPanel.vue +++ b/apps/web/src/components/routines/RelatedTasksPanel.vue @@ -17,8 +17,13 @@ const props = withDefaults( defineProps<{ entity: RoutineEntityRefDto; title?: string; + emptyTitle?: string; + emptyHint?: string; }>(), - { title: 'کارهای مرتبط' }, + { + title: 'کارهای مرتبط', + emptyTitle: 'هنوز کاری برای این پرونده ثبت نشده.', + }, ); const router = useRouter(); @@ -83,7 +88,7 @@ function onCreated(t: RoutineTaskDto) {