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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
نام مخاطب
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user