fix: share the dashboard assistant and count leads from data

The hero ask box now uses the same confirm-before-create path as the assistant, count questions use real filters, and lead mobile, Jalali follow-ups, and rial labels stay consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 14:45:29 +03:30
co-authored by Cursor
parent 0e420a443f
commit 5f4cc47c41
14 changed files with 1463 additions and 44 deletions
+348 -3
View File
@@ -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<AskMentoResponse> {
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<CountIntent['period']>) {
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<string | null> {
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<string, () => Promise<number>> = {
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<string, string> = {
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<number>,
periods: ReturnType<typeof tehranPeriods>,
@@ -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
+236
View File
@@ -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;
}
@@ -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<VoiceCommandDraft>('/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) => {
@@ -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(() =>
<UiIcon name="send" :size="15" />
</button>
</form>
<p v-if="asking" class="hero-ask-status">در حال پاسخ…</p>
<p v-if="asking" class="hero-ask-status">{{ statusText || 'در حال پاسخ…' }}</p>
<p v-else-if="askError" class="hero-ask-status err">{{ askError }}</p>
<p v-else-if="askAnswer" class="hero-ask-status">{{ askAnswer }}</p>
</div>
@@ -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(() =>
@@ -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));
@@ -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) {
<section v-if="enabled" class="related">
<header>
<h3>
<UiIcon name="check" :size="16" />
<span class="mark"><UiIcon name="check" :size="15" /></span>
{{ title }}
<em v-if="open.length">{{ toFaDigits(open.length) }} باز</em>
</h3>
@@ -96,7 +101,21 @@ function onCreated(t: RoutineTaskDto) {
<p v-if="error" class="error">{{ error }}</p>
<p v-if="actions.error.value" class="error">{{ actions.error.value }}</p>
<div v-if="loading && !items.length" class="muted">در حال بارگذاری…</div>
<p v-else-if="!items.length" class="muted">هنوز کاری برای این پرونده ثبت نشده.</p>
<div v-else-if="!items.length" class="empty">
<div class="empty-art" aria-hidden="true">
<svg viewBox="0 0 72 72" width="72" height="72">
<rect x="18" y="16" width="36" height="44" rx="8" fill="#f4fbf8" stroke="#b7e4d4" stroke-width="1.6" />
<rect x="26" y="10" width="20" height="12" rx="4" fill="#e7f6f1" stroke="#7dcebb" stroke-width="1.6" />
<path d="M30 40l5 5 9-11" fill="none" stroke="#12856a" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<p class="muted">{{ emptyTitle }}</p>
<p v-if="emptyHint" class="hint">{{ emptyHint }}</p>
<button class="empty-cta" type="button" @click="quickOpen = true">
ایجاد Task
<UiIcon name="plus" :size="14" />
</button>
</div>
<div v-else class="rows">
<RoutineTaskRow
@@ -197,4 +216,92 @@ h3 em {
.error {
margin: 0;
}
.empty {
display: grid;
gap: 0.35rem;
}
.empty-art,
.hint,
.empty-cta {
display: none;
}
@media (max-width: 900px) {
.fancy {
gap: 0.85rem;
padding: 0.95rem 0.85rem 1rem;
border: 1px solid #e7eef2;
border-radius: 18px;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.fancy .mark {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: #e7f6f1;
color: #0f766e;
}
.fancy header :deep(.ui-btn),
.fancy header :deep(button) {
border: 0;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
box-shadow: none;
height: auto;
min-height: 34px;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
font-weight: 700;
flex-direction: row-reverse;
gap: 0.2rem;
}
.fancy .empty {
justify-items: center;
text-align: center;
gap: 0.35rem;
padding: 0.4rem 0.5rem 0.2rem;
}
.fancy .empty-art {
display: grid;
place-items: center;
width: 92px;
height: 92px;
margin-bottom: 0.2rem;
border-radius: 24px;
background: linear-gradient(180deg, #f7fcfa, #eef8f4);
}
.fancy .hint {
display: block;
margin: 0;
color: #94a3b8;
font-size: 0.78rem;
}
.fancy .empty-cta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.35rem;
border: 0;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
padding: 0.45rem 0.9rem;
font: inherit;
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
}
}
</style>
+1
View File
@@ -8,6 +8,7 @@ export const CURRENCY_OPTIONS = [
] as const;
export function currencySuffix(code: string): string {
if (code === 'IRT') return 'ریال';
return CURRENCY_OPTIONS.find((c) => c.code === code)?.suffix ?? code;
}
+5 -5
View File
@@ -1,3 +1,4 @@
import { currencySuffix } from '@/lib/money';
import { matchesSearch } from '@/lib/search';
export type PipelineAccent = 'green' | 'orange' | 'purple' | 'blue' | 'rose' | 'slate';
@@ -118,8 +119,8 @@ export const PIPELINE_ACCENT: Record<
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'orange', 'purple'];
/** خروجی: ۲۵۰,۰۰۰,۰۰۰ تومان */
export function formatCurrency(amount: number, unit = 'تومان'): string {
/** خروجی: ۲۵۰,۰۰۰,۰۰۰ ریال */
export function formatCurrency(amount: number, unit = 'ریال'): string {
try {
return `${new Intl.NumberFormat('fa-IR').format(amount)} ${unit}`;
} catch {
@@ -129,7 +130,7 @@ export function formatCurrency(amount: number, unit = 'تومان'): string {
export function formatCurrencyParts(
amount: number,
unit = 'تومان',
unit = 'ریال',
): { number: string; unit: string } {
try {
return {
@@ -155,8 +156,7 @@ export function initialsFromTitle(title: string): string {
}
export function currencyUnitLabel(currency: string): string {
if (currency === 'IRR' || currency === 'IRT') return 'تومان';
return currency;
return currencySuffix(currency || 'IRR');
}
/** نگاشت برد API به مدل UI مسیر فروش */
+30 -2
View File
@@ -30,6 +30,8 @@ const activities = ref<ActivityDto[]>([]);
const board = ref<PipelineBoardDto | null>(null);
const askQuery = ref('');
const asking = ref(false);
const assistantBusy = ref(false);
const assistantRef = ref<{ submitFromInput: () => Promise<void> } | null>(null);
const askAnswer = ref('');
const askError = ref('');
const askSources = ref<AskMentoSourceDto[]>([]);
@@ -83,6 +85,28 @@ function clearAsk() {
askSources.value = [];
}
function onHeroAsk() {
if (!assistantRef.value) {
askError.value = 'داشبورد هنوز آماده نیست. چند لحظه بعد دوباره بفرستید.';
return;
}
void assistantRef.value.submitFromInput();
}
function onAssistantNotice(payload: { error: string; done: string }) {
if (payload.done) {
askAnswer.value = payload.done;
askError.value = '';
return;
}
if (payload.error) {
askError.value = payload.error;
askAnswer.value = '';
return;
}
if (!asking.value) askError.value = '';
}
async function submitAsk(preset?: string) {
const q = (preset || askQuery.value).trim();
if (!q || asking.value) return;
@@ -154,11 +178,12 @@ onUnmounted(() => {
:first-name="firstName"
:user-initial="userInitial"
:alerts="home?.alerts ?? []"
:asking="asking"
:asking="asking || assistantBusy"
:ask-answer="askAnswer"
:ask-error="askError"
:status-text="assistantBusy && !asking ? 'در حال فهمیدن دستور…' : ''"
v-model:ask-query="askQuery"
@ask="submitAsk"
@ask="onHeroAsk"
/>
<p v-if="loading" class="muted center">در حال بارگذاری داشبورد…</p>
@@ -181,6 +206,7 @@ onUnmounted(() => {
<HomeSourcesChart :sources="home?.sources ?? []" />
<HomeAiAssistant
id="home-ai-assistant"
ref="assistantRef"
v-model:ask-query="askQuery"
:asking="asking"
:answer="askAnswer"
@@ -188,6 +214,8 @@ onUnmounted(() => {
:sources="askSources"
@ask="submitAsk"
@clear="clearAsk"
@busy="assistantBusy = $event"
@notice="onAssistantNotice"
/>
</div>
+694 -15
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatClockFa, formatJalaliNumeric, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
@@ -14,6 +16,7 @@ import {
UiInput,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type {
@@ -53,6 +56,10 @@ const noteBody = ref('');
const followUpAt = ref('');
const followUpBody = ref('');
const editing = ref(false);
const moreOpen = ref(false);
const copied = ref(false);
const narrow = ref(typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches);
let narrowMq: MediaQueryList | null = null;
const editForm = ref({
title: '',
contactName: '',
@@ -192,6 +199,12 @@ async function assignLead() {
}
}
function localToIso(value: string): string | undefined {
const parsed = parseLocalInput(value);
if (!parsed || Number.isNaN(parsed.getTime())) return undefined;
return parsed.toISOString();
}
async function logCall() {
busy.value = true;
error.value = '';
@@ -199,7 +212,7 @@ async function logCall() {
await api.post(`/leads/${id.value}/call`, {
outcome: callOutcome.value || undefined,
body: callBody.value || undefined,
nextFollowUpAt: callFollowUp.value || undefined,
nextFollowUpAt: callFollowUp.value ? localToIso(callFollowUp.value) : undefined,
});
callOutcome.value = '';
callBody.value = '';
@@ -230,12 +243,13 @@ async function addNote() {
}
async function setFollowUp() {
if (!followUpAt.value) return;
const nextFollowUpAt = localToIso(followUpAt.value);
if (!nextFollowUpAt) return;
busy.value = true;
error.value = '';
try {
await api.post(`/leads/${id.value}/follow-up`, {
nextFollowUpAt: new Date(followUpAt.value).toISOString(),
nextFollowUpAt,
body: followUpBody.value || undefined,
});
followUpAt.value = '';
@@ -286,11 +300,66 @@ async function archiveLead() {
}
}
onMounted(load);
const telHref = computed(() => (lead.value?.mobile ? `tel:${lead.value.mobile}` : undefined));
const feed = computed(() => {
const rows: Array<{ key: string; title: string; by: string; at: string }> = [];
for (const ev of lead.value?.events ?? []) {
rows.push({
key: `ev-${ev.id}`,
title: ev.title,
by: ev.body?.trim() || 'توسط سیستم',
at: ev.createdAt,
});
}
for (const a of activities.value) {
const who = a.createdBy?.fullName || a.assignee?.fullName;
rows.push({
key: `act-${a.id}`,
title: a.title,
by: who ? `توسط ${who}` : 'توسط سیستم',
at: a.createdAt,
});
}
return rows.sort((a, b) => +new Date(b.at) - +new Date(a.at)).slice(0, 4);
});
function feedParts(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { day: '—', time: '' };
return { day: formatJalaliNumeric(d), time: formatClockFa(d) };
}
async function copyMobile() {
const value = lead.value?.mobile;
if (!value) return;
try {
await navigator.clipboard.writeText(value);
copied.value = true;
window.setTimeout(() => {
copied.value = false;
}, 1400);
} catch {
copied.value = false;
}
}
function syncNarrow() {
narrow.value = window.matchMedia('(max-width: 900px)').matches;
if (!narrow.value) moreOpen.value = false;
}
onMounted(() => {
narrowMq = window.matchMedia('(max-width: 900px)');
narrowMq.addEventListener('change', syncNarrow);
void load();
});
onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
</script>
<template>
<section class="page">
<section class="page" :class="{ editing, 'more-open': moreOpen }">
<div class="desk-only">
<UiPageHeader
:title="lead?.title || 'جزئیات لید'"
:lead="lead ? `موبایل ${lead.mobile}` : 'در حال بارگذاری…'"
@@ -326,15 +395,196 @@ onMounted(load);
</UiButton>
</template>
</UiPageHeader>
</div>
<button
v-if="moreOpen"
class="sheet-back"
type="button"
aria-label="بستن"
@click="moreOpen = false"
/>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="lead">
<div v-if="narrow" class="mob">
<article class="m-hero">
<div class="m-top">
<span class="m-mark" aria-hidden="true">
<UiIcon name="bag" :size="22" />
</span>
<div class="m-id">
<div class="m-name-row">
<h2>{{ lead.title }}</h2>
<span class="m-pill"><i />{{ lead.stageName }}</span>
</div>
<button class="m-phone" type="button" @click="copyMobile">
<span dir="ltr">{{ toFaDigits(lead.mobile) }}</span>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="8" y="8" width="11" height="11" rx="2" />
<path d="M5 15V6a2 2 0 0 1 2-2h9" />
</svg>
<em v-if="copied">کپی شد</em>
</button>
</div>
<RouterLink class="m-back" :to="{ name: 'leads' }" aria-label="بازگشت">
<UiIcon name="chevron-left" :size="20" />
</RouterLink>
</div>
<div class="m-actions" :class="{ converted: !!lead.convertedPersonId }">
<a class="m-btn mint" :href="telHref">
<UiIcon name="phone" :size="16" />
تماس
</a>
<button class="m-btn" type="button" @click="startEdit">
<UiIcon name="pencil" :size="16" />
ویرایش
</button>
<button
v-if="!lead.convertedPersonId"
class="m-btn solid"
type="button"
:disabled="busy"
@click="convertLead"
>
<UiIcon name="user-plus" :size="16" />
تبدیل به مشتری
</button>
<button class="m-btn icon" type="button" aria-label="بیشتر" @click="moreOpen = true">
<UiIcon name="dots" :size="18" />
</button>
</div>
</article>
<article v-if="!editing" class="m-card">
<header class="m-card-h">
<h3>اطلاعات لید</h3>
<button class="m-ico" type="button" aria-label="ویرایش اطلاعات" @click="startEdit">
<UiIcon name="document" :size="16" />
</button>
</header>
<dl class="m-grid">
<div class="cell">
<dt><UiIcon name="user" :size="14" /> نام</dt>
<dd>{{ lead.title }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="users" :size="14" /> مالک / مخاطب</dt>
<dd>{{ lead.contactName || '—' }}</dd>
</div>
<div class="cell hot">
<dt><UiIcon name="phone" :size="14" /> موبایل</dt>
<dd dir="ltr">{{ toFaDigits(lead.mobile) }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="phone" :size="14" /> تلفن</dt>
<dd dir="ltr">{{ lead.phone ? toFaDigits(lead.phone) : '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="pin" :size="14" /> شهر</dt>
<dd>{{ lead.city || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="grid" :size="14" /> منطقه</dt>
<dd>{{ lead.region || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="bag" :size="14" /> صنف</dt>
<dd>{{ lead.trade || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="camera" :size="14" /> اینستاگرام</dt>
<dd dir="ltr">{{ lead.instagram || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="target" :size="14" /> منبع</dt>
<dd>{{ lead.source || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="flag" :size="14" /> وضعیت</dt>
<dd><span class="m-pill"><i />{{ lead.stageName }}</span></dd>
</div>
<div class="cell">
<dt><UiIcon name="user" :size="14" /> کارشناس</dt>
<dd>{{ lead.assigneeName || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="calendar" :size="14" /> تاریخ ثبت</dt>
<dd>{{ formatDate(lead.createdAt) }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="chart" :size="14" /> روز در مرحله</dt>
<dd>{{ toFaDigits(lead.daysInStage) }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="clock" :size="14" /> پیگیری بعدی</dt>
<dd>{{ formatDate(lead.nextFollowUpAt) }}</dd>
</div>
<div v-if="lead.addresses?.length" class="cell wide">
<dt><UiIcon name="pin" :size="14" /> آدرس‌ها</dt>
<dd>
<p v-for="(addr, i) in lead.addresses" :key="i" class="addr-line">{{ addr }}</p>
</dd>
</div>
</dl>
</article>
<article v-if="!editing" class="m-card">
<header class="m-card-h">
<h3>یادداشت</h3>
<button class="m-ico" type="button" aria-label="ویرایش یادداشت" @click="startEdit">
<UiIcon name="pencil" :size="16" />
</button>
</header>
<p class="m-note">{{ lead.notes?.trim() || 'یادداشتی ثبت نشده است.' }}</p>
</article>
<RelatedTasksPanel
v-if="!editing"
class="fancy"
:entity="{ type: 'lead', id: lead.id, label: lead.title }"
empty-title="هنوز کاری برای این لید ثبت نشده."
empty-hint="برای پیگیری بهتر، یک تسک ایجاد کنید."
/>
<article v-if="!editing" class="m-card">
<header class="m-card-h">
<h3>
<span class="m-ico static"><UiIcon name="clock" :size="16" /></span>
فعالیت‌ها
</h3>
<RouterLink class="m-add" :to="{ name: 'activity-new' }">
ایجاد فعالیت
<UiIcon name="plus" :size="14" />
</RouterLink>
</header>
<ol v-if="feed.length" class="m-feed">
<li v-for="row in feed" :key="row.key">
<span class="m-feed-ic"><UiIcon name="document" :size="16" /></span>
<div class="m-feed-body">
<strong>{{ row.title }}</strong>
<small>{{ row.by }}</small>
</div>
<time>
<b>{{ feedParts(row.at).day }}</b>
<span>{{ feedParts(row.at).time }}</span>
</time>
</li>
</ol>
<p v-else class="m-note">هنوز فعالیتی ثبت نشده است.</p>
<RouterLink class="m-all" :to="{ name: 'activities' }">
مشاهده همه فعالیت‌ها
<UiIcon name="arrow-left" :size="14" />
</RouterLink>
</article>
</div>
<div class="layout">
<div class="main">
<UiCard title="اطلاعات لید">
<UiCard class="info-card" title="اطلاعات لید">
<div v-if="editing" class="edit-grid">
<UiField label="عنوان" required>
<UiInput v-model="editForm.title" />
@@ -402,9 +652,12 @@ onMounted(load);
</dl>
</UiCard>
<RelatedTasksPanel :entity="{ type: 'lead', id: lead.id, label: lead.title }" />
<RelatedTasksPanel
v-if="!narrow"
:entity="{ type: 'lead', id: lead.id, label: lead.title }"
/>
<UiCard v-if="activities.length" title="فعالیت‌های مرتبط">
<UiCard v-if="activities.length" class="desk-only" title="فعالیت‌های مرتبط">
<ul class="lead-acts">
<li v-for="a in activities" :key="a.id">
<RouterLink :to="{ name: 'activity-detail', params: { id: a.id } }">
@@ -420,7 +673,7 @@ onMounted(load);
</ul>
</UiCard>
<UiCard title="جدول زمانی">
<UiCard class="desk-only" title="جدول زمانی">
<ul v-if="lead.events.length" class="timeline">
<li v-for="ev in lead.events" :key="ev.id">
<strong>{{ ev.title }}</strong>
@@ -437,6 +690,7 @@ onMounted(load);
</div>
<aside class="side">
<button class="sheet-close" type="button" @click="moreOpen = false">بستن</button>
<UiCard title="تغییر وضعیت">
<UiField label="وضعیت">
<UiSelect v-model="stageId">
@@ -467,10 +721,10 @@ onMounted(load);
<UiInput v-model="callOutcome" placeholder="مثلاً پاسخ داد / اشغال" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="callBody" placeholder="خلاصه مکالمه" />
<UiTextarea v-model="callBody" :rows="4" placeholder="خلاصه مکالمه" />
</UiField>
<UiField label="پیگیری بعدی">
<input v-model="callFollowUp" class="native" type="datetime-local" />
<JalaliDateInput v-model="callFollowUp" mode="datetime" />
</UiField>
<UiButton size="sm" :disabled="busy" @click="logCall">ثبت تماس</UiButton>
</UiCard>
@@ -486,7 +740,7 @@ onMounted(load);
<UiCard title="پیگیری بعدی">
<UiField label="زمان">
<input v-model="followUpAt" class="native" type="datetime-local" />
<JalaliDateInput v-model="followUpAt" mode="datetime" />
</UiField>
<UiField label="توضیح">
<UiInput v-model="followUpBody" placeholder="اختیاری" />
@@ -653,13 +907,438 @@ onMounted(load);
margin: 0;
}
.sheet-back,
.sheet-close,
.mob {
display: none;
}
@media (max-width: 900px) {
.desk-only,
:deep(.desk-only) {
display: none !important;
}
.layout {
display: none;
grid-template-columns: 1fr;
}
.meta {
.editing .layout,
.more-open .layout {
display: grid;
}
.more-open:not(.editing) .main {
display: none;
}
:deep(.info-card) {
display: none;
}
.editing :deep(.info-card) {
display: block;
}
.meta,
.edit-grid {
grid-template-columns: 1fr;
}
.mob {
display: grid;
gap: 0.75rem;
width: 100%;
min-width: 0;
}
.m-hero,
.m-card,
.m-top,
.m-id,
.m-actions,
.m-grid,
.m-name-row,
.mob :deep(.related) {
min-width: 0;
max-width: 100%;
}
.m-hero,
.m-card,
.mob :deep(.related) {
background: #fff;
border: 1px solid #e7eef2;
border-radius: 18px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.m-hero {
display: grid;
gap: 0.85rem;
padding: 0.95rem 0.9rem 0.85rem;
}
.m-top {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.7rem;
align-items: start;
}
.m-mark {
width: 52px;
height: 52px;
border-radius: 16px;
display: grid;
place-items: center;
background: #e7f6f1;
color: #0f766e;
}
.m-id {
min-width: 0;
display: grid;
gap: 0.35rem;
padding-top: 0.1rem;
}
.m-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.m-name-row h2 {
margin: 0;
min-width: 0;
font-size: 1.15rem;
font-weight: 800;
line-height: 1.35;
color: #0f172a;
}
.m-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.12rem 0.55rem;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
font-size: 0.72rem;
font-weight: 700;
}
.m-pill i {
width: 6px;
height: 6px;
border-radius: 999px;
background: #14b8a6;
}
.m-phone {
justify-self: start;
width: auto;
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 0;
background: none;
padding: 0;
color: #64748b;
font: inherit;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
}
.m-phone em {
font-style: normal;
color: #0f766e;
font-size: 0.72rem;
}
.m-back {
width: 32px;
height: 32px;
display: grid;
place-items: center;
color: #94a3b8;
}
.m-actions {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) 36px;
gap: 0.35rem;
}
.m-actions.converted {
grid-template-columns: 1fr 1fr auto;
}
.m-btn {
min-width: 0;
min-height: 40px;
border: 0;
border-radius: 12px;
background: #f3f5f7;
color: #334155;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0 0.45rem;
font: inherit;
font-size: 0.74rem;
font-weight: 700;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
}
.m-btn.mint {
background: #e7f6f1;
color: #0f766e;
}
.m-btn.solid {
background: #12856a;
color: #fff;
}
.m-btn.solid:disabled {
opacity: 0.6;
}
.m-btn.icon {
width: 40px;
padding: 0;
}
.m-card {
padding: 0.9rem 0.85rem 0.85rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.75rem;
}
.m-card-h {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-width: 0;
}
.m-card-h h3 {
margin: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.98rem;
font-weight: 800;
color: #0f172a;
}
.m-ico {
width: 32px;
height: 32px;
border: 0;
border-radius: 10px;
background: #e7f6f1;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.m-ico.static {
cursor: default;
}
.m-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
margin: 0;
}
.cell {
min-width: 0;
border: 1px solid #eef2f5;
border-radius: 12px;
padding: 0.55rem 0.6rem 0.6rem;
background: #fff;
}
.cell.wide {
grid-column: 1 / -1;
}
.cell.hot {
background: #e8f7f1;
border-color: #d7f0e6;
}
.cell.hot dt,
.cell.hot :deep(.ico) {
color: #0f766e;
}
.cell dt {
display: flex;
align-items: center;
gap: 0.28rem;
margin: 0 0 0.28rem;
color: #94a3b8;
font-size: 0.7rem;
font-weight: 600;
}
.cell dd {
margin: 0;
color: #0f172a;
font-size: 0.84rem;
font-weight: 700;
overflow-wrap: anywhere;
text-align: start;
}
.cell dd[dir='ltr'] {
text-align: right;
}
.m-note {
margin: 0;
min-width: 0;
color: #334155;
font-size: 0.86rem;
line-height: 1.7;
overflow-wrap: anywhere;
}
.m-add {
display: inline-flex;
align-items: center;
gap: 0.2rem;
border-radius: 999px;
background: #e7f6f1;
color: #0f766e;
text-decoration: none;
font-size: 0.75rem;
font-weight: 700;
padding: 0.35rem 0.7rem;
}
.m-feed {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.7rem;
}
.m-feed li {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.6rem;
align-items: center;
}
.m-feed-ic {
width: 36px;
height: 36px;
border-radius: 12px;
background: #e7f6f1;
color: #0f766e;
display: grid;
place-items: center;
}
.m-feed-body {
min-width: 0;
display: grid;
gap: 0.1rem;
}
.m-feed-body strong {
overflow-wrap: anywhere;
font-size: 0.84rem;
font-weight: 800;
color: #0f172a;
}
.m-feed-body small {
color: #94a3b8;
font-size: 0.72rem;
}
.m-feed time {
display: grid;
justify-items: end;
gap: 0.1rem;
color: #64748b;
font-size: 0.72rem;
}
.m-feed time b {
font-weight: 700;
color: #334155;
}
.m-all {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
color: #64748b;
text-decoration: none;
font-size: 0.78rem;
font-weight: 700;
}
.side {
display: none;
}
.sheet-back {
display: block;
position: fixed;
inset: 0;
z-index: 60;
border: 0;
background: rgba(15, 23, 42, 0.35);
}
.more-open .side {
display: grid;
position: fixed;
z-index: 70;
inset-inline: 0;
bottom: 0;
max-height: min(78vh, 640px);
overflow: auto;
gap: 0.75rem;
margin: 0;
padding: 0.85rem 0.85rem calc(0.9rem + env(safe-area-inset-bottom));
background: #f8fafc;
border-radius: 18px 18px 0 0;
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.18);
}
.sheet-close {
display: inline-flex;
justify-self: start;
border: 0;
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.8rem;
font: inherit;
font-weight: 700;
color: #334155;
}
}
</style>
+9 -6
View File
@@ -814,15 +814,18 @@ async function onContactVcfFile(ev: Event) {
async function submitCreate() {
createError.value = '';
if (!createForm.value.title.trim() || !createForm.value.mobile.trim()) {
createError.value = 'نام/شرکت و موبایل الزامی است.';
const mobile = createForm.value.mobile.trim();
const company = createForm.value.title.trim();
const person = createForm.value.contactName.trim();
if (!mobile) {
createError.value = 'موبایل الزامی است.';
return;
}
createBusy.value = true;
const payload = {
title: createForm.value.title.trim(),
mobile: createForm.value.mobile.trim(),
contactName: createForm.value.contactName.trim() || null,
title: company || person || mobile,
mobile,
contactName: person || null,
city: createForm.value.city.trim() || null,
region: createForm.value.region.trim() || null,
source: createForm.value.source.trim() || null,
@@ -1633,7 +1636,7 @@ onBeforeUnmount(() => {
<form class="create-form" @submit.prevent="submitCreate">
<label>
نام / شرکت
<input v-model="createForm.title" required placeholder="مثلاً فروشگاه آریا" />
<input v-model="createForm.title" placeholder="مثلاً فروشگاه آریا" />
</label>
<label>
نام مخاطب
+2 -1
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { currencySuffix } from '@/lib/money';
import MoneyInput from '@/components/MoneyInput.vue';
import {
UiBadge,
@@ -280,7 +281,7 @@ onMounted(async () => {
<div class="price-input-wrapper">
<MoneyInput v-model="unitPrice" :decimals="0" placeholder="مبلغ واحد" />
<span class="currency-unit-label">
{{ currency === 'IRR' || currency === 'IRT' ? 'تومان' : currency }}
{{ currencySuffix(currency) }}
</span>
</div>
</UiField>
+2 -3
View File
@@ -7,7 +7,7 @@ import { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatMoney } from '@/lib/money';
import { currencySuffix, formatMoney } from '@/lib/money';
import {
UiBadge,
UiBusyOverlay,
@@ -141,8 +141,7 @@ function formatTime(iso: string) {
}
function currencyLabel(code: string) {
if (code === 'IRR' || code === 'IRT') return 'تومان';
return code;
return currencySuffix(code);
}
async function load() {