1510 lines
38 KiB
Vue
1510 lines
38 KiB
Vue
<script setup lang="ts">
|
||||
|
|
import { computed, ref, watch } from 'vue';
|
|||
|
|
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
|||
|
|
import { api } from '@/lib/api';
|
|||
|
|
import { formatCompactMoney, formatMoney } from '@/lib/money';
|
|||
|
|
import {
|
|||
|
|
calendarDaysBetween,
|
|||
|
|
formatJalaliDayClock,
|
|||
|
|
formatNextSlot,
|
|||
|
|
formatRelativeContact,
|
|||
|
|
toFaDigits,
|
|||
|
|
} from '@/lib/jalali';
|
|||
|
|
import {
|
|||
|
|
buildSmartSummary,
|
|||
|
|
isVipPerson,
|
|||
|
|
personInitials,
|
|||
|
|
purchaseProbability,
|
|||
|
|
} from '@/lib/person-insights';
|
|||
|
|
import UiIcon from '@/components/UiIcon.vue';
|
|||
|
|
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
|
|||
|
|
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
|
|||
|
|
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
|
|||
|
|
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
|
|||
|
|
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
|
|||
|
|
import type {
|
|||
|
|
ActivityDto,
|
|||
|
|
DealDto,
|
|||
|
|
InvoiceDto,
|
|||
|
|
MemoryItemDto,
|
|||
|
|
NoteDto,
|
|||
|
|
PaginatedResponse,
|
|||
|
|
PersonDto,
|
|||
|
|
PipelineBoardDto,
|
|||
|
|
ProjectBoardBoardDto,
|
|||
|
|
ProjectCardDto,
|
|||
|
|
QuoteDto,
|
|||
|
|
TicketDto,
|
|||
|
|
} from '@mento/shared';
|
|||
|
|
|
|||
|
|
type TabKey = 'summary' | 'activities' | 'deals' | 'cards' | 'info';
|
|||
|
|
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
|
|||
|
|
|
|||
|
|
type TimelineRow = {
|
|||
|
|
key: string;
|
|||
|
|
title: string;
|
|||
|
|
body?: string | null;
|
|||
|
|
when: string;
|
|||
|
|
badge?: string;
|
|||
|
|
badgeTone?: 'ok' | 'warn' | 'muted';
|
|||
|
|
tone: TimelineTone;
|
|||
|
|
href: string;
|
|||
|
|
at: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const TABS: Array<{ key: TabKey; label: string }> = [
|
|||
|
|
{ key: 'summary', label: 'خلاصه' },
|
|||
|
|
{ key: 'activities', label: 'فعالیتها' },
|
|||
|
|
{ key: 'deals', label: 'معاملات' },
|
|||
|
|
{ key: 'cards', label: 'کارتها' },
|
|||
|
|
{ key: 'info', label: 'اطلاعات' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const route = useRoute();
|
|||
|
|
const router = useRouter();
|
|||
|
|
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
|||
|
|
|
|||
|
|
const loading = ref(true);
|
|||
|
|
const error = ref('');
|
|||
|
|
const person = ref<PersonDto | null>(null);
|
|||
|
|
const activities = ref<ActivityDto[]>([]);
|
|||
|
|
const notes = ref<NoteDto[]>([]);
|
|||
|
|
const quotes = ref<QuoteDto[]>([]);
|
|||
|
|
const invoices = ref<InvoiceDto[]>([]);
|
|||
|
|
const tickets = ref<TicketDto[]>([]);
|
|||
|
|
const memories = ref<MemoryItemDto[]>([]);
|
|||
|
|
const deals = ref<DealDto[]>([]);
|
|||
|
|
const cards = ref<ProjectCardDto[]>([]);
|
|||
|
|
|
|||
|
|
const moreOpen = ref(false);
|
|||
|
|
const fabOpen = ref(false);
|
|||
|
|
const tab = ref<TabKey>('summary');
|
|||
|
|
|
|||
|
|
const moreItems = computed<ProfileMoreItem[]>(() => [
|
|||
|
|
{ key: 'edit', label: 'ویرایش اطلاعات', to: { name: 'person-edit', params: { id: id.value } } },
|
|||
|
|
{ key: 'deal', label: 'معامله جدید', to: { name: 'deal-new', query: { personId: id.value } } },
|
|||
|
|
{ key: 'quote', label: 'پیشفاکتور جدید', to: { name: 'quote-new', query: { personId: id.value } } },
|
|||
|
|
{ key: 'ticket', label: 'تیکت جدید', to: { name: 'ticket-new', query: { personId: id.value } } },
|
|||
|
|
{ key: 'delete', label: 'حذف شخص', danger: true },
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
function onMoreSelect(key: string) {
|
|||
|
|
if (key === 'delete') void removePerson();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fullName = computed(() => person.value?.fullName ?? 'مشتری');
|
|||
|
|
const initials = computed(() => personInitials(fullName.value));
|
|||
|
|
const subtitle = computed(() => {
|
|||
|
|
const bits = [person.value?.position, person.value?.company?.name].filter(Boolean);
|
|||
|
|
return bits.join(' · ');
|
|||
|
|
});
|
|||
|
|
const vip = computed(() => isVipPerson(person.value));
|
|||
|
|
const phoneNumber = computed(() => person.value?.mobile || person.value?.phone || '');
|
|||
|
|
const telHref = computed(() => (phoneNumber.value ? `tel:${phoneNumber.value}` : ''));
|
|||
|
|
const smsHref = computed(() => (phoneNumber.value ? `sms:${phoneNumber.value}` : ''));
|
|||
|
|
|
|||
|
|
const lastCallAt = computed(() => {
|
|||
|
|
const calls = activities.value
|
|||
|
|
.filter((a) => a.type === 'call')
|
|||
|
|
.map((a) =>
|
|||
|
|
a.status === 'done' ? a.completedAt || a.dueAt || a.createdAt : a.createdAt,
|
|||
|
|
)
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
|
|||
|
|
return calls[0] ?? null;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const lastTouchAt = computed(() => {
|
|||
|
|
const stamps = [
|
|||
|
|
...activities.value.map((a) => a.completedAt || a.dueAt || a.createdAt),
|
|||
|
|
...notes.value.map((n) => n.createdAt),
|
|||
|
|
...quotes.value.map((q) => q.createdAt),
|
|||
|
|
]
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
|
|||
|
|
return stamps[0] ?? person.value?.updatedAt ?? null;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const daysSinceCall = computed(() =>
|
|||
|
|
lastCallAt.value ? calendarDaysBetween(new Date(lastCallAt.value), new Date()) : null,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const nextActivity = computed(() => {
|
|||
|
|
const now = Date.now();
|
|||
|
|
return activities.value
|
|||
|
|
.filter((a) => a.status === 'open' && a.dueAt)
|
|||
|
|
.sort((a, b) => new Date(a.dueAt!).getTime() - new Date(b.dueAt!).getTime())
|
|||
|
|
.find((a) => new Date(a.dueAt!).getTime() >= now - 60 * 60 * 1000) ??
|
|||
|
|
activities.value
|
|||
|
|
.filter((a) => a.status === 'open' && a.dueAt)
|
|||
|
|
.sort((a, b) => new Date(a.dueAt!).getTime() - new Date(b.dueAt!).getTime())[0] ??
|
|||
|
|
null;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const openDeals = computed(() => deals.value.filter((d) => d.status === 'open'));
|
|||
|
|
const dealTotal = computed(() =>
|
|||
|
|
deals.value.reduce((sum, d) => sum + (d.amount ?? 0), 0),
|
|||
|
|
);
|
|||
|
|
const dealCurrency = computed(() => deals.value[0]?.currency ?? quotes.value[0]?.currency ?? 'IRR');
|
|||
|
|
|
|||
|
|
const lastQuote = computed(() =>
|
|||
|
|
[...quotes.value].sort(
|
|||
|
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|||
|
|
)[0] ?? null,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const probability = computed(() =>
|
|||
|
|
purchaseProbability({
|
|||
|
|
daysSinceContact: daysSinceCall.value,
|
|||
|
|
openDealCount: openDeals.value.length,
|
|||
|
|
quoteCount: quotes.value.length,
|
|||
|
|
hasUpcoming: Boolean(nextActivity.value),
|
|||
|
|
noteCount: notes.value.length,
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const isActiveCustomer = computed(() => {
|
|||
|
|
if (openDeals.value.length) return true;
|
|||
|
|
if (daysSinceCall.value != null && daysSinceCall.value <= 30) return true;
|
|||
|
|
return false;
|
|||
|
|
});
|
|||
|
|
const isOnlineish = computed(
|
|||
|
|
() => daysSinceCall.value != null && daysSinceCall.value <= 2,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const smart = computed(() =>
|
|||
|
|
buildSmartSummary({
|
|||
|
|
memories: memories.value,
|
|||
|
|
notes: notes.value,
|
|||
|
|
lastQuoteLabel: lastQuote.value
|
|||
|
|
? formatCompactMoney(lastQuote.value.totalAmount, lastQuote.value.currency)
|
|||
|
|
: null,
|
|||
|
|
daysSinceCall: daysSinceCall.value,
|
|||
|
|
probabilityBand: probability.value.band,
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const kpis = computed(() => [
|
|||
|
|
{
|
|||
|
|
key: 'prob',
|
|||
|
|
label: 'احتمال خرید',
|
|||
|
|
value: `${toFaDigits(probability.value.score)}٪`,
|
|||
|
|
hint: probability.value.band,
|
|||
|
|
icon: 'fire' as const,
|
|||
|
|
tone: 'fire',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: 'next',
|
|||
|
|
label: 'فعالیت بعدی',
|
|||
|
|
value: formatNextSlot(nextActivity.value?.dueAt),
|
|||
|
|
icon: 'clock' as const,
|
|||
|
|
tone: 'clock',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: 'value',
|
|||
|
|
label: 'ارزش کل معاملات',
|
|||
|
|
value: deals.value.length
|
|||
|
|
? formatCompactMoney(dealTotal.value, dealCurrency.value)
|
|||
|
|
: '—',
|
|||
|
|
icon: 'chart' as const,
|
|||
|
|
tone: 'chart',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: 'deals',
|
|||
|
|
label: 'معاملات',
|
|||
|
|
value: deals.value.length ? `${toFaDigits(deals.value.length)} معامله` : 'بدون معامله',
|
|||
|
|
icon: 'bag' as const,
|
|||
|
|
tone: 'bag',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
key: 'last',
|
|||
|
|
label: 'آخرین ارتباط',
|
|||
|
|
value: formatRelativeContact(lastTouchAt.value),
|
|||
|
|
icon: 'calendar' as const,
|
|||
|
|
tone: 'cal',
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
function activityTitle(a: ActivityDto) {
|
|||
|
|
if (a.title?.trim()) return a.title;
|
|||
|
|
if (a.type === 'call') return 'پیگیری تلفنی';
|
|||
|
|
if (a.type === 'meeting') return 'جلسه حضوری';
|
|||
|
|
if (a.type === 'task') return 'پیگیری';
|
|||
|
|
return 'یادداشت';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function activityTone(a: ActivityDto): TimelineTone {
|
|||
|
|
if (a.type === 'call') return 'call';
|
|||
|
|
if (a.type === 'meeting') return 'meeting';
|
|||
|
|
if (a.type === 'note') return 'note';
|
|||
|
|
return 'task';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function activityBadge(a: ActivityDto): { label: string; tone: 'ok' | 'warn' | 'muted' } | null {
|
|||
|
|
if (a.status === 'done') return { label: 'انجام شد', tone: 'muted' };
|
|||
|
|
if (a.status === 'canceled') return { label: 'لغو شده', tone: 'muted' };
|
|||
|
|
if (a.dueAt && new Date(a.dueAt).getTime() < Date.now()) {
|
|||
|
|
return { label: 'عقبافتاده', tone: 'warn' };
|
|||
|
|
}
|
|||
|
|
return { label: 'برنامهریزی شده', tone: 'ok' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const timeline = computed<TimelineRow[]>(() => {
|
|||
|
|
const rows: TimelineRow[] = [];
|
|||
|
|
for (const a of activities.value) {
|
|||
|
|
const badge = activityBadge(a);
|
|||
|
|
const at = new Date(a.dueAt || a.createdAt).getTime();
|
|||
|
|
rows.push({
|
|||
|
|
key: `a-${a.id}`,
|
|||
|
|
title: activityTitle(a),
|
|||
|
|
body: a.description,
|
|||
|
|
when: a.dueAt ? formatNextSlot(a.dueAt) : formatJalaliDayClock(new Date(a.createdAt)),
|
|||
|
|
badge: badge?.label,
|
|||
|
|
badgeTone: badge?.tone,
|
|||
|
|
tone: activityTone(a),
|
|||
|
|
href: `/activities/${a.id}`,
|
|||
|
|
at,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
for (const n of notes.value) {
|
|||
|
|
rows.push({
|
|||
|
|
key: `n-${n.id}`,
|
|||
|
|
title: n.title?.trim() || 'یادداشت',
|
|||
|
|
body: n.body,
|
|||
|
|
when: formatJalaliDayClock(new Date(n.createdAt)),
|
|||
|
|
tone: 'note',
|
|||
|
|
href: '/notes',
|
|||
|
|
at: new Date(n.createdAt).getTime(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
for (const q of quotes.value) {
|
|||
|
|
rows.push({
|
|||
|
|
key: `q-${q.id}`,
|
|||
|
|
title: 'پیشنهاد ارسال شد',
|
|||
|
|
body: `پیشنهاد شماره ${q.number} با مبلغ ${formatMoney(q.totalAmount, q.currency)} ارسال شد`,
|
|||
|
|
when: formatJalaliDayClock(new Date(q.createdAt)),
|
|||
|
|
tone: 'quote',
|
|||
|
|
href: `/quotes/${q.id}`,
|
|||
|
|
at: new Date(q.createdAt).getTime(),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return rows.sort((a, b) => b.at - a.at);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const summaryTimeline = computed(() => timeline.value.slice(0, 6));
|
|||
|
|
|
|||
|
|
const summaryEmpty = computed(
|
|||
|
|
() =>
|
|||
|
|
!activities.value.length &&
|
|||
|
|
!notes.value.length &&
|
|||
|
|
!quotes.value.length &&
|
|||
|
|
!deals.value.length,
|
|||
|
|
);
|
|||
|
|
const activitiesEmpty = computed(() => !timeline.value.length);
|
|||
|
|
const dealsEmpty = computed(() => !deals.value.length && !invoices.value.length);
|
|||
|
|
const cardsEmpty = computed(() => !cards.value.length);
|
|||
|
|
const infoEmpty = computed(() => {
|
|||
|
|
const p = person.value;
|
|||
|
|
if (!p) return true;
|
|||
|
|
return (
|
|||
|
|
!p.mobile &&
|
|||
|
|
!p.phone &&
|
|||
|
|
!p.email &&
|
|||
|
|
!p.position &&
|
|||
|
|
!p.company &&
|
|||
|
|
!p.notes?.trim() &&
|
|||
|
|
!tickets.value.length
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const dealStatusLabel: Record<string, string> = {
|
|||
|
|
open: 'باز',
|
|||
|
|
won: 'برنده',
|
|||
|
|
lost: 'باخته',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function readTab() {
|
|||
|
|
const q = route.query.tab;
|
|||
|
|
if (q === 'activities' || q === 'deals' || q === 'cards' || q === 'info' || q === 'summary') {
|
|||
|
|
tab.value = q;
|
|||
|
|
} else {
|
|||
|
|
tab.value = 'summary';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setTab(next: TabKey) {
|
|||
|
|
tab.value = next;
|
|||
|
|
void router.replace({ query: { ...route.query, tab: next === 'summary' ? undefined : next } });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function load() {
|
|||
|
|
if (!id.value) return;
|
|||
|
|
loading.value = true;
|
|||
|
|
error.value = '';
|
|||
|
|
moreOpen.value = false;
|
|||
|
|
fabOpen.value = false;
|
|||
|
|
try {
|
|||
|
|
const [personRes, actRes, noteRes, quoteRes, memRes, boardRes] = await Promise.all([
|
|||
|
|
api.get<PersonDto>(`/people/${id.value}`),
|
|||
|
|
api.get<PaginatedResponse<ActivityDto>>('/activities', {
|
|||
|
|
params: { filter: 'all', personId: id.value, limit: 80 },
|
|||
|
|
}),
|
|||
|
|
api.get<PaginatedResponse<NoteDto>>('/notes', {
|
|||
|
|
params: { personId: id.value, limit: 80 },
|
|||
|
|
}),
|
|||
|
|
api.get<PaginatedResponse<QuoteDto>>('/quotes', {
|
|||
|
|
params: { personId: id.value, limit: 50 },
|
|||
|
|
}),
|
|||
|
|
api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
|
|||
|
|
params: { personId: id.value, limit: 50 },
|
|||
|
|
}),
|
|||
|
|
api.get<PipelineBoardDto>('/pipelines/board'),
|
|||
|
|
]);
|
|||
|
|
person.value = personRes.data;
|
|||
|
|
activities.value = actRes.data.items;
|
|||
|
|
notes.value = noteRes.data.items;
|
|||
|
|
quotes.value = quoteRes.data.items;
|
|||
|
|
memories.value = memRes.data.items;
|
|||
|
|
deals.value = boardRes.data.stages
|
|||
|
|
.flatMap((s) => s.deals)
|
|||
|
|
.filter((d) => d.person?.id === id.value);
|
|||
|
|
|
|||
|
|
const extras = await Promise.allSettled([
|
|||
|
|
api.get<PaginatedResponse<InvoiceDto>>('/invoices', {
|
|||
|
|
params: { personId: id.value, limit: 50 },
|
|||
|
|
}),
|
|||
|
|
api.get<PaginatedResponse<TicketDto>>('/tickets', {
|
|||
|
|
params: { personId: id.value, limit: 50 },
|
|||
|
|
}),
|
|||
|
|
api.get<ProjectBoardBoardDto>('/project-boards/board'),
|
|||
|
|
]);
|
|||
|
|
invoices.value =
|
|||
|
|
extras[0].status === 'fulfilled' ? extras[0].value.data.items : [];
|
|||
|
|
tickets.value =
|
|||
|
|
extras[1].status === 'fulfilled' ? extras[1].value.data.items : [];
|
|||
|
|
cards.value =
|
|||
|
|
extras[2].status === 'fulfilled'
|
|||
|
|
? extras[2].value.data.stages
|
|||
|
|
.flatMap((s) => s.cards)
|
|||
|
|
.filter((c) => c.person?.id === id.value)
|
|||
|
|
: [];
|
|||
|
|
} catch {
|
|||
|
|
error.value = 'بارگذاری پرونده مشتری ناموفق بود.';
|
|||
|
|
person.value = null;
|
|||
|
|
} finally {
|
|||
|
|
loading.value = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeMenus() {
|
|||
|
|
moreOpen.value = false;
|
|||
|
|
fabOpen.value = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function goCall() {
|
|||
|
|
closeMenus();
|
|||
|
|
if (telHref.value) window.location.href = telHref.value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function goMessage() {
|
|||
|
|
closeMenus();
|
|||
|
|
if (smsHref.value) {
|
|||
|
|
window.location.href = smsHref.value;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
void router.push({ name: 'notes', query: { personId: id.value } });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function goFollowup() {
|
|||
|
|
closeMenus();
|
|||
|
|
void router.push({
|
|||
|
|
name: 'activity-new',
|
|||
|
|
query: { personId: id.value, type: 'task', title: 'پیگیری' },
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onSmartCta() {
|
|||
|
|
if (smart.value.ctaKind === 'call' && telHref.value) {
|
|||
|
|
goCall();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
void router.push({
|
|||
|
|
name: 'activity-new',
|
|||
|
|
query: {
|
|||
|
|
personId: id.value,
|
|||
|
|
type: 'call',
|
|||
|
|
title: `تماس با ${fullName.value}`,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function removePerson() {
|
|||
|
|
closeMenus();
|
|||
|
|
if (!id.value || !confirm('این شخص حذف شود؟')) return;
|
|||
|
|
try {
|
|||
|
|
await api.delete(`/people/${id.value}`);
|
|||
|
|
await router.push({ name: 'contacts' });
|
|||
|
|
} catch {
|
|||
|
|
error.value = 'حذف ناموفق بود.';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(id, () => void load(), { immediate: true });
|
|||
|
|
watch(() => route.query.tab, readTab, { immediate: true });
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<section class="profile" @click="closeMenus">
|
|||
|
|
<header class="nav">
|
|||
|
|
<RouterLink class="icon-btn back-btn" :to="{ name: 'contacts' }" aria-label="بازگشت" @click.stop>
|
|||
|
|
<UiIcon name="chevron" :size="18" />
|
|||
|
|
</RouterLink>
|
|||
|
|
<span class="nav-brand">mento</span>
|
|||
|
|
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<p v-if="loading" class="muted pad">در حال بارگذاری پرونده…</p>
|
|||
|
|
<p v-else-if="error" class="error pad">{{ error }}</p>
|
|||
|
|
|
|||
|
|
<template v-else-if="person">
|
|||
|
|
<div class="identity">
|
|||
|
|
<div class="avatar" :class="{ online: isOnlineish }">
|
|||
|
|
<span>{{ initials }}</span>
|
|||
|
|
</div>
|
|||
|
|
<div class="who">
|
|||
|
|
<h1>
|
|||
|
|
{{ fullName }}
|
|||
|
|
<span v-if="vip" class="crown" title="VIP" aria-label="VIP">♛</span>
|
|||
|
|
</h1>
|
|||
|
|
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
|
|||
|
|
<div class="badges">
|
|||
|
|
<span v-if="vip" class="pill vip">VIP</span>
|
|||
|
|
<span class="pill" :class="isActiveCustomer ? 'active' : 'idle'">
|
|||
|
|
{{ isActiveCustomer ? 'مشتری فعال' : 'غیرفعال' }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="qa">
|
|||
|
|
<a v-if="telHref" class="qa-card" :href="telHref">
|
|||
|
|
<span class="qa-ico call"><UiIcon name="phone" :size="18" /></span>
|
|||
|
|
<span>تماس</span>
|
|||
|
|
</a>
|
|||
|
|
<button v-else class="qa-card" type="button" disabled>
|
|||
|
|
<span class="qa-ico call"><UiIcon name="phone" :size="18" /></span>
|
|||
|
|
<span>تماس</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="qa-card" type="button" @click.stop="goMessage">
|
|||
|
|
<span class="qa-ico msg"><UiIcon name="chat" :size="18" /></span>
|
|||
|
|
<span>پیام</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="qa-card" type="button" @click.stop="goFollowup">
|
|||
|
|
<span class="qa-ico follow"><UiIcon name="check" :size="18" /></span>
|
|||
|
|
<span>پیگیری</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="qa-card" type="button" @click.stop="moreOpen = !moreOpen">
|
|||
|
|
<span class="qa-ico more"><UiIcon name="dots" :size="18" /></span>
|
|||
|
|
<span>بیشتر</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<article class="smart">
|
|||
|
|
<header class="smart-head">
|
|||
|
|
<MentoAiAvatar :size="54" :show-bubble="false" />
|
|||
|
|
<div>
|
|||
|
|
<h2>
|
|||
|
|
<UiIcon name="sparkle" :size="14" />
|
|||
|
|
خلاصه هوشمند منتو
|
|||
|
|
</h2>
|
|||
|
|
<p>{{ smart.text }}</p>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
<button class="smart-cta" type="button" @click="onSmartCta">
|
|||
|
|
<span>{{ smart.cta }}</span>
|
|||
|
|
<UiIcon name="chevron" :size="16" />
|
|||
|
|
</button>
|
|||
|
|
</article>
|
|||
|
|
|
|||
|
|
<nav class="tabs" aria-label="بخشهای پرونده">
|
|||
|
|
<button
|
|||
|
|
v-for="item in TABS"
|
|||
|
|
:key="item.key"
|
|||
|
|
type="button"
|
|||
|
|
:class="{ on: tab === item.key }"
|
|||
|
|
@click="setTab(item.key)"
|
|||
|
|
>
|
|||
|
|
{{ item.label }}
|
|||
|
|
</button>
|
|||
|
|
</nav>
|
|||
|
|
|
|||
|
|
<div v-show="tab === 'summary'" class="panel" :class="{ 'is-empty': summaryEmpty }">
|
|||
|
|
<ProfileEmptyState
|
|||
|
|
v-if="summaryEmpty"
|
|||
|
|
primary-label="افزودن اطلاعات مشتری"
|
|||
|
|
:primary-to="{ name: 'person-edit', params: { id } }"
|
|||
|
|
secondary-label="افزودن اولین فعالیت"
|
|||
|
|
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
|
|||
|
|
></ProfileEmptyState>
|
|||
|
|
<div v-if="!summaryEmpty">
|
|||
|
|
<div class="kpis">
|
|||
|
|
<article v-for="kpi in kpis" :key="kpi.key" class="kpi" :class="kpi.tone">
|
|||
|
|
<span class="kpi-ico"><UiIcon :name="kpi.icon" :size="16" /></span>
|
|||
|
|
<p>{{ kpi.label }}</p>
|
|||
|
|
<strong>{{ kpi.value }}</strong>
|
|||
|
|
<em v-if="kpi.hint">{{ kpi.hint }}</em>
|
|||
|
|
</article>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="tl-head">
|
|||
|
|
<h3>آخرین فعالیتها</h3>
|
|||
|
|
<button type="button" @click="setTab('activities')">مشاهده همه</button>
|
|||
|
|
</div>
|
|||
|
|
<ol v-if="summaryTimeline.length" class="tl">
|
|||
|
|
<li v-for="row in summaryTimeline" :key="row.key">
|
|||
|
|
<RouterLink :to="row.href">
|
|||
|
|
<span class="dot" :class="row.tone">
|
|||
|
|
<UiIcon
|
|||
|
|
:name="
|
|||
|
|
row.tone === 'call'
|
|||
|
|
? 'phone'
|
|||
|
|
: row.tone === 'quote'
|
|||
|
|
? 'document'
|
|||
|
|
: row.tone === 'meeting'
|
|||
|
|
? 'user-plus'
|
|||
|
|
: row.tone === 'task'
|
|||
|
|
? 'check'
|
|||
|
|
: 'document'
|
|||
|
|
"
|
|||
|
|
:size="14"
|
|||
|
|
/>
|
|||
|
|
</span>
|
|||
|
|
<div>
|
|||
|
|
<strong>
|
|||
|
|
{{ row.title }}
|
|||
|
|
<span v-if="row.badge" class="tl-badge" :class="row.badgeTone">{{ row.badge }}</span>
|
|||
|
|
</strong>
|
|||
|
|
<p v-if="row.body">{{ row.body }}</p>
|
|||
|
|
<time>{{ row.when }}</time>
|
|||
|
|
</div>
|
|||
|
|
</RouterLink>
|
|||
|
|
</li>
|
|||
|
|
</ol>
|
|||
|
|
<p v-else class="empty">هنوز فعالیتی روی این پرونده نیست.</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-show="tab === 'activities'" class="panel" :class="{ 'is-empty': activitiesEmpty }">
|
|||
|
|
<ProfileEmptyState
|
|||
|
|
v-if="activitiesEmpty"
|
|||
|
|
title="هنوز فعالیتی ثبت نشده!"
|
|||
|
|
description="برای شروع، اولین تماس، جلسه یا پیگیری این مشتری را ثبت کنید."
|
|||
|
|
primary-label="افزودن اولین فعالیت"
|
|||
|
|
:primary-to="{ name: 'activity-new', query: { personId: id } }"
|
|||
|
|
secondary-label="افزودن اطلاعات مشتری"
|
|||
|
|
:secondary-to="{ name: 'person-edit', params: { id } }"
|
|||
|
|
secondary-icon="user-plus"
|
|||
|
|
></ProfileEmptyState>
|
|||
|
|
<div v-if="!activitiesEmpty">
|
|||
|
|
<ol class="tl">
|
|||
|
|
<li v-for="row in timeline" :key="row.key">
|
|||
|
|
<RouterLink :to="row.href">
|
|||
|
|
<span class="dot" :class="row.tone">
|
|||
|
|
<UiIcon
|
|||
|
|
:name="
|
|||
|
|
row.tone === 'call'
|
|||
|
|
? 'phone'
|
|||
|
|
: row.tone === 'quote'
|
|||
|
|
? 'document'
|
|||
|
|
: row.tone === 'meeting'
|
|||
|
|
? 'user-plus'
|
|||
|
|
: row.tone === 'task'
|
|||
|
|
? 'check'
|
|||
|
|
: 'document'
|
|||
|
|
"
|
|||
|
|
:size="14"
|
|||
|
|
/>
|
|||
|
|
</span>
|
|||
|
|
<div>
|
|||
|
|
<strong>
|
|||
|
|
{{ row.title }}
|
|||
|
|
<span v-if="row.badge" class="tl-badge" :class="row.badgeTone">{{ row.badge }}</span>
|
|||
|
|
</strong>
|
|||
|
|
<p v-if="row.body">{{ row.body }}</p>
|
|||
|
|
<time>{{ row.when }}</time>
|
|||
|
|
</div>
|
|||
|
|
</RouterLink>
|
|||
|
|
</li>
|
|||
|
|
</ol>
|
|||
|
|
<RouterLink class="ghost-link" :to="{ name: 'activity-new', query: { personId: id } }">
|
|||
|
|
فعالیت جدید
|
|||
|
|
</RouterLink>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-show="tab === 'deals'" class="panel list-panel" :class="{ 'is-empty': dealsEmpty }">
|
|||
|
|
<ProfileEmptyState
|
|||
|
|
v-if="dealsEmpty"
|
|||
|
|
title="هنوز معاملهای ثبت نشده!"
|
|||
|
|
description="اولین معامله این مشتری را در کاریز ثبت کنید یا یک فعالیت پیگیری بسازید."
|
|||
|
|
primary-label="افزودن معامله"
|
|||
|
|
:primary-to="{ name: 'deal-new', query: { personId: id } }"
|
|||
|
|
secondary-label="افزودن اولین فعالیت"
|
|||
|
|
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
|
|||
|
|
></ProfileEmptyState>
|
|||
|
|
<div v-if="!dealsEmpty">
|
|||
|
|
<RouterLink
|
|||
|
|
v-for="deal in deals"
|
|||
|
|
:key="deal.id"
|
|||
|
|
class="row-card"
|
|||
|
|
:to="{ name: 'deal-detail', params: { id: deal.id } }"
|
|||
|
|
>
|
|||
|
|
<strong>{{ deal.title }}</strong>
|
|||
|
|
<span>{{ deal.amount != null ? formatCompactMoney(deal.amount, deal.currency) : 'بدون مبلغ' }}</span>
|
|||
|
|
<em>{{ dealStatusLabel[deal.status] ?? deal.status }}</em>
|
|||
|
|
</RouterLink>
|
|||
|
|
<div v-if="invoices.length" class="subblock">
|
|||
|
|
<h3>فاکتورها</h3>
|
|||
|
|
<RouterLink
|
|||
|
|
v-for="inv in invoices"
|
|||
|
|
:key="inv.id"
|
|||
|
|
class="row-card"
|
|||
|
|
:to="{ name: 'invoice-detail', params: { id: inv.id } }"
|
|||
|
|
>
|
|||
|
|
<strong>{{ inv.number }} · {{ inv.title }}</strong>
|
|||
|
|
<span>{{ formatMoney(inv.totalAmount, inv.currency) }}</span>
|
|||
|
|
</RouterLink>
|
|||
|
|
</div>
|
|||
|
|
<RouterLink class="ghost-link" :to="{ name: 'deal-new', query: { personId: id } }">
|
|||
|
|
معامله جدید
|
|||
|
|
</RouterLink>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-show="tab === 'cards'" class="panel list-panel" :class="{ 'is-empty': cardsEmpty }">
|
|||
|
|
<ProfileEmptyState
|
|||
|
|
v-if="cardsEmpty"
|
|||
|
|
title="هنوز کارتی ثبت نشده!"
|
|||
|
|
description="یک کارت پروژه برای پیگیری کار این مشتری بسازید."
|
|||
|
|
primary-label="افزودن کارت"
|
|||
|
|
:primary-to="{ name: 'project-card-new', query: { personId: id } }"
|
|||
|
|
secondary-label="افزودن اولین فعالیت"
|
|||
|
|
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
|
|||
|
|
></ProfileEmptyState>
|
|||
|
|
<div v-if="!cardsEmpty">
|
|||
|
|
<RouterLink
|
|||
|
|
v-for="card in cards"
|
|||
|
|
:key="card.id"
|
|||
|
|
class="row-card"
|
|||
|
|
:to="{ name: 'project-card-detail', params: { id: card.id } }"
|
|||
|
|
>
|
|||
|
|
<strong>{{ card.title }}</strong>
|
|||
|
|
<span v-if="card.notes">{{ card.notes }}</span>
|
|||
|
|
</RouterLink>
|
|||
|
|
<RouterLink class="ghost-link" :to="{ name: 'project-card-new', query: { personId: id } }">
|
|||
|
|
کارت جدید
|
|||
|
|
</RouterLink>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-show="tab === 'info'" class="panel info-panel" :class="{ 'is-empty': infoEmpty }">
|
|||
|
|
<ProfileEmptyState
|
|||
|
|
v-if="infoEmpty"
|
|||
|
|
primary-label="افزودن اطلاعات مشتری"
|
|||
|
|
:primary-to="{ name: 'person-edit', params: { id } }"
|
|||
|
|
secondary-label="افزودن اولین فعالیت"
|
|||
|
|
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
|
|||
|
|
></ProfileEmptyState>
|
|||
|
|
<div v-if="!infoEmpty">
|
|||
|
|
<dl>
|
|||
|
|
<div><dt>موبایل</dt><dd>{{ person.mobile || '—' }}</dd></div>
|
|||
|
|
<div><dt>تلفن</dt><dd>{{ person.phone || '—' }}</dd></div>
|
|||
|
|
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
|
|||
|
|
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
|
|||
|
|
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
|
|||
|
|
<div><dt>مسئول</dt><dd>{{ person.owner?.fullName || '—' }}</dd></div>
|
|||
|
|
<div>
|
|||
|
|
<dt>باشگاه</dt>
|
|||
|
|
<dd>
|
|||
|
|
{{ toFaDigits(person.loyaltyPoints) }} امتیاز
|
|||
|
|
<span v-if="person.loyaltyTier"> · {{ person.loyaltyTier.name }}</span>
|
|||
|
|
</dd>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="person.notes" class="wide"><dt>یادداشت</dt><dd>{{ person.notes }}</dd></div>
|
|||
|
|
</dl>
|
|||
|
|
<div v-if="tickets.length" class="subblock">
|
|||
|
|
<h3>تیکتها</h3>
|
|||
|
|
<RouterLink
|
|||
|
|
v-for="ticket in tickets"
|
|||
|
|
:key="ticket.id"
|
|||
|
|
class="row-card"
|
|||
|
|
:to="{ name: 'ticket-detail', params: { id: ticket.id } }"
|
|||
|
|
>
|
|||
|
|
<strong>{{ ticket.number }} · {{ ticket.subject }}</strong>
|
|||
|
|
<span>{{ ticket.status }}</span>
|
|||
|
|
</RouterLink>
|
|||
|
|
</div>
|
|||
|
|
<MentoMemoryPanel :person-id="id" />
|
|||
|
|
<RouterLink class="ghost-link" :to="{ name: 'person-edit', params: { id } }">
|
|||
|
|
ویرایش اطلاعات
|
|||
|
|
</RouterLink>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<footer v-if="person" class="dock">
|
|||
|
|
<RouterLink :to="{ name: 'activity-new', query: { personId: id, type: 'task' } }">
|
|||
|
|
<UiIcon name="calendar" :size="18" />
|
|||
|
|
فعالیت جدید
|
|||
|
|
</RouterLink>
|
|||
|
|
<div class="fab-wrap" @click.stop>
|
|||
|
|
<button class="fab" type="button" aria-label="ایجاد" @click="fabOpen = !fabOpen">
|
|||
|
|
<UiIcon name="plus" :size="22" />
|
|||
|
|
</button>
|
|||
|
|
<div v-if="fabOpen" class="menu fab-menu">
|
|||
|
|
<RouterLink :to="{ name: 'activity-new', query: { personId: id } }">فعالیت</RouterLink>
|
|||
|
|
<RouterLink :to="{ name: 'notes', query: { personId: id } }">یادداشت</RouterLink>
|
|||
|
|
<RouterLink :to="{ name: 'deal-new', query: { personId: id } }">معامله</RouterLink>
|
|||
|
|
<RouterLink :to="{ name: 'quote-new', query: { personId: id } }">پیشنهاد</RouterLink>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" :disabled="!telHref" @click="goCall">
|
|||
|
|
<UiIcon name="phone" :size="18" />
|
|||
|
|
تماس سریع
|
|||
|
|
</button>
|
|||
|
|
</footer>
|
|||
|
|
</section>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.profile {
|
|||
|
|
max-width: 760px;
|
|||
|
|
width: 100%;
|
|||
|
|
min-width: 0;
|
|||
|
|
margin: 0 auto;
|
|||
|
|
padding-bottom: 5.6rem;
|
|||
|
|
display: grid;
|
|||
|
|
gap: 1rem;
|
|||
|
|
overflow: visible;
|
|||
|
|
|
|||
|
|
|
|||
|
|
.profile > * {
|
|||
|
|
min-width: 0;
|
|||
|
|
max-width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel {
|
|||
|
|
min-width: 0;
|
|||
|
|
max-width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.panel.is-empty {
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
min-height: min(52vh, 460px);
|
|||
|
|
gap: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.nav {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: row-reverse;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
padding: 0.15rem 0.1rem;
|
|||
|
|
position: relative;
|
|||
|
|
z-index: 20;
|
|||
|
|
overflow: visible;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.nav-brand {
|
|||
|
|
font-weight: 800;
|
|||
|
|
letter-spacing: -0.04em;
|
|||
|
|
color: #0f766e;
|
|||
|
|
font-size: 1.15rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.icon-btn {
|
|||
|
|
width: 38px;
|
|||
|
|
height: 38px;
|
|||
|
|
border: 0;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
color: #334155;
|
|||
|
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-btn :deep(.ico) {
|
|||
|
|
transform: scaleX(-1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.more-wrap,
|
|||
|
|
.fab-wrap {
|
|||
|
|
position: relative;
|
|||
|
|
z-index: 30;
|
|||
|
|
overflow: visible;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.menu {
|
|||
|
|
position: absolute;
|
|||
|
|
top: calc(100% + 8px);
|
|||
|
|
inset-inline-end: 0;
|
|||
|
|
min-width: 12.75rem;
|
|||
|
|
width: max-content;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 14px;
|
|||
|
|
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
|
|||
|
|
padding: 0.4rem;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 0.1rem;
|
|||
|
|
z-index: 40;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.menu a,
|
|||
|
|
.menu button {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: flex-start;
|
|||
|
|
width: 100%;
|
|||
|
|
text-align: start;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
background: none;
|
|||
|
|
border: 0;
|
|||
|
|
padding: 0.68rem 0.8rem;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
color: #0f172a;
|
|||
|
|
font: inherit;
|
|||
|
|
font-weight: 600;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.menu a:hover,
|
|||
|
|
.menu button:hover {
|
|||
|
|
background: #f0fdfa;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.menu .danger {
|
|||
|
|
color: #b91c1c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.identity {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.95rem;
|
|||
|
|
align-items: center;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar {
|
|||
|
|
position: relative;
|
|||
|
|
width: 76px;
|
|||
|
|
height: 76px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: linear-gradient(145deg, #5eead4, #0d9488);
|
|||
|
|
color: #fff;
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
font-weight: 800;
|
|||
|
|
font-size: 1.35rem;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar.online::after {
|
|||
|
|
content: '';
|
|||
|
|
position: absolute;
|
|||
|
|
width: 14px;
|
|||
|
|
height: 14px;
|
|||
|
|
background: #22c55e;
|
|||
|
|
border: 3px solid #eef2f6;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
inset-inline-start: 4px;
|
|||
|
|
bottom: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.who {
|
|||
|
|
min-width: 0;
|
|||
|
|
flex: 1;
|
|||
|
|
overflow-wrap: anywhere;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h1 {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 1.45rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.crown {
|
|||
|
|
color: #7c3aed;
|
|||
|
|
font-size: 1.05rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.sub {
|
|||
|
|
margin: 0.2rem 0 0.55rem;
|
|||
|
|
color: #64748b;
|
|||
|
|
font-size: 0.88rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.badges {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pill {
|
|||
|
|
border-radius: 999px;
|
|||
|
|
padding: 0.18rem 0.62rem;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pill.vip {
|
|||
|
|
background: #ede9fe;
|
|||
|
|
color: #6d28d9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pill.active {
|
|||
|
|
background: #dcfce7;
|
|||
|
|
color: #166534;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pill.idle {
|
|||
|
|
background: #f1f5f9;
|
|||
|
|
color: #64748b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qa {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|||
|
|
gap: 0.65rem;
|
|||
|
|
width: 100%;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qa-card {
|
|||
|
|
background: #fff;
|
|||
|
|
border: 0;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
padding: 0.85rem 0.4rem 0.75rem;
|
|||
|
|
display: grid;
|
|||
|
|
justify-items: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
color: #334155;
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qa-card:disabled {
|
|||
|
|
opacity: 0.45;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qa-ico {
|
|||
|
|
width: 38px;
|
|||
|
|
height: 38px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qa-ico.call { background: #ecfdf5; color: #0f766e; }
|
|||
|
|
.qa-ico.msg { background: #eff6ff; color: #2563eb; }
|
|||
|
|
.qa-ico.follow { background: #fff7ed; color: #ea580c; }
|
|||
|
|
.qa-ico.more { background: #f1f5f9; color: #64748b; }
|
|||
|
|
|
|||
|
|
.smart {
|
|||
|
|
background: linear-gradient(135deg, #ecfeff, #f5f3ff);
|
|||
|
|
border: 1px solid #e0e7ff;
|
|||
|
|
border-radius: 20px;
|
|||
|
|
padding: 0.95rem 1rem 0.85rem;
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.smart-head {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.smart-head h2 {
|
|||
|
|
margin: 0 0 0.4rem;
|
|||
|
|
font-size: 0.95rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
color: #6d28d9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.smart-head p {
|
|||
|
|
margin: 0;
|
|||
|
|
color: #334155;
|
|||
|
|
font-size: 0.86rem;
|
|||
|
|
line-height: 1.75;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.smart-cta {
|
|||
|
|
width: 100%;
|
|||
|
|
border: 0;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
background: #ccfbf1;
|
|||
|
|
color: #0f766e;
|
|||
|
|
font-weight: 700;
|
|||
|
|
padding: 0.72rem 0.9rem;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.smart-cta :deep(.ico) {
|
|||
|
|
transform: scaleX(-1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
width: 100%;
|
|||
|
|
min-width: 0;
|
|||
|
|
gap: 0;
|
|||
|
|
overflow-x: auto;
|
|||
|
|
border-bottom: 1px solid #e2e8f0;
|
|||
|
|
padding: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs button {
|
|||
|
|
flex: 1 1 0;
|
|||
|
|
min-width: 0;
|
|||
|
|
border: 0;
|
|||
|
|
background: none;
|
|||
|
|
padding: 0.55rem 0.2rem 0.7rem;
|
|||
|
|
color: #64748b;
|
|||
|
|
font-weight: 700;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
text-align: center;
|
|||
|
|
cursor: pointer;
|
|||
|
|
box-shadow: inset 0 -2px 0 transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs button.on {
|
|||
|
|
color: #0f766e;
|
|||
|
|
box-shadow: inset 0 -2px 0 #14b8a6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpis {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|||
|
|
gap: 0.55rem;
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: 100%;
|
|||
|
|
min-width: 0;
|
|||
|
|
overflow-x: clip;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi {
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
padding: 0.7rem 0.65rem 0.75rem;
|
|||
|
|
min-width: 0;
|
|||
|
|
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.22rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi p {
|
|||
|
|
margin: 0;
|
|||
|
|
color: #94a3b8;
|
|||
|
|
font-size: 0.7rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi strong {
|
|||
|
|
font-size: 0.86rem;
|
|||
|
|
line-height: 1.35;
|
|||
|
|
overflow-wrap: anywhere;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi em {
|
|||
|
|
font-style: normal;
|
|||
|
|
font-size: 0.68rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
color: #16a34a;
|
|||
|
|
background: #dcfce7;
|
|||
|
|
width: fit-content;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
padding: 0.05rem 0.4rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi-ico {
|
|||
|
|
width: 28px;
|
|||
|
|
height: 28px;
|
|||
|
|
border-radius: 9px;
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
margin-bottom: 0.15rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi.fire .kpi-ico { background: #fff1f2; color: #e11d48; }
|
|||
|
|
.kpi.clock .kpi-ico { background: #eff6ff; color: #2563eb; }
|
|||
|
|
.kpi.chart .kpi-ico { background: #fefce8; color: #ca8a04; }
|
|||
|
|
.kpi.bag .kpi-ico { background: #ecfdf5; color: #0f766e; }
|
|||
|
|
.kpi.cal .kpi-ico { background: #f5f3ff; color: #7c3aed; }
|
|||
|
|
|
|||
|
|
.tl-head {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
margin: 1rem 0 0.55rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl-head h3,
|
|||
|
|
.subblock h3 {
|
|||
|
|
margin: 0;
|
|||
|
|
font-size: 0.95rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl-head button,
|
|||
|
|
.ghost-link {
|
|||
|
|
color: #0f766e;
|
|||
|
|
background: none;
|
|||
|
|
border: 0;
|
|||
|
|
font-weight: 700;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font: inherit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ghost-link {
|
|||
|
|
display: inline-block;
|
|||
|
|
margin-top: 0.7rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl {
|
|||
|
|
list-style: none;
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
display: grid;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl li {
|
|||
|
|
position: relative;
|
|||
|
|
padding-inline-end: 0.2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl li:not(:last-child)::before {
|
|||
|
|
content: '';
|
|||
|
|
position: absolute;
|
|||
|
|
top: 34px;
|
|||
|
|
bottom: -8px;
|
|||
|
|
inset-inline-start: 15px;
|
|||
|
|
width: 2px;
|
|||
|
|
background: #e2e8f0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl a {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 0.75rem;
|
|||
|
|
padding: 0.55rem 0;
|
|||
|
|
color: inherit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dot {
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
position: relative;
|
|||
|
|
z-index: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dot.call { background: #dcfce7; color: #15803d; }
|
|||
|
|
.dot.quote { background: #ffedd5; color: #c2410c; }
|
|||
|
|
.dot.note { background: #ede9fe; color: #6d28d9; }
|
|||
|
|
.dot.meeting { background: #dbeafe; color: #1d4ed8; }
|
|||
|
|
.dot.task { background: #ccfbf1; color: #0f766e; }
|
|||
|
|
|
|||
|
|
.tl strong {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
font-size: 0.9rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl p {
|
|||
|
|
margin: 0.2rem 0 0;
|
|||
|
|
color: #64748b;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
line-height: 1.55;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl time {
|
|||
|
|
display: block;
|
|||
|
|
margin-top: 0.2rem;
|
|||
|
|
color: #94a3b8;
|
|||
|
|
font-size: 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl-badge {
|
|||
|
|
font-size: 0.68rem;
|
|||
|
|
font-weight: 700;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
padding: 0.08rem 0.45rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tl-badge.ok { background: #dcfce7; color: #166534; }
|
|||
|
|
.tl-badge.warn { background: #fee2e2; color: #b91c1c; }
|
|||
|
|
.tl-badge.muted { background: #f1f5f9; color: #64748b; }
|
|||
|
|
|
|||
|
|
.row-card {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.15rem;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 14px;
|
|||
|
|
padding: 0.85rem 0.95rem;
|
|||
|
|
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.row-card span,
|
|||
|
|
.row-card em {
|
|||
|
|
color: #64748b;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
font-style: normal;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.list-panel,
|
|||
|
|
.info-panel {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.65rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-panel dl {
|
|||
|
|
margin: 0;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
padding: 0.4rem 1rem;
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-panel dl > div {
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 7.5rem 1fr;
|
|||
|
|
gap: 0.6rem;
|
|||
|
|
padding: 0.7rem 0;
|
|||
|
|
border-bottom: 1px solid #f1f5f9;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-panel dt {
|
|||
|
|
color: #94a3b8;
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-panel dd {
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.subblock {
|
|||
|
|
display: grid;
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
margin-top: 0.4rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty,
|
|||
|
|
.muted {
|
|||
|
|
color: #64748b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error {
|
|||
|
|
color: #b91c1c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.pad {
|
|||
|
|
padding: 0.4rem 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dock {
|
|||
|
|
position: sticky;
|
|||
|
|
bottom: 0.6rem;
|
|||
|
|
margin-top: 0.4rem;
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 22px;
|
|||
|
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 1fr auto 1fr;
|
|||
|
|
align-items: center;
|
|||
|
|
padding: 0.55rem 0.7rem 0.65rem;
|
|||
|
|
gap: 0.4rem;
|
|||
|
|
z-index: 6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dock > a,
|
|||
|
|
.dock > button {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 0.35rem;
|
|||
|
|
background: none;
|
|||
|
|
border: 0;
|
|||
|
|
color: #0f766e;
|
|||
|
|
font-weight: 700;
|
|||
|
|
font-size: 0.82rem;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dock > button:disabled {
|
|||
|
|
opacity: 0.4;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.fab {
|
|||
|
|
width: 58px;
|
|||
|
|
height: 58px;
|
|||
|
|
border: 0;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
background: #14b8a6;
|
|||
|
|
color: #fff;
|
|||
|
|
display: grid;
|
|||
|
|
place-items: center;
|
|||
|
|
box-shadow: 0 10px 20px rgba(20, 184, 166, 0.4);
|
|||
|
|
cursor: pointer;
|
|||
|
|
margin-top: -1.35rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.fab-menu {
|
|||
|
|
inset-inline: auto;
|
|||
|
|
left: 50%;
|
|||
|
|
right: auto;
|
|||
|
|
top: auto;
|
|||
|
|
bottom: calc(100% + 10px);
|
|||
|
|
transform: translateX(-50%);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 900px) {
|
|||
|
|
.dock {
|
|||
|
|
position: fixed;
|
|||
|
|
inset-inline: 0.85rem;
|
|||
|
|
bottom: calc(0.55rem + env(safe-area-inset-bottom));
|
|||
|
|
width: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.dock > a,
|
|||
|
|
.dock > button {
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
gap: 0.28rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.profile {
|
|||
|
|
padding-bottom: 6.2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpis {
|
|||
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi {
|
|||
|
|
padding: 0.6rem 0.5rem 0.65rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi strong {
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs button {
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@media (max-width: 599px) {
|
|||
|
|
.dock > a,
|
|||
|
|
.dock > button {
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
gap: 0.2rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.avatar {
|
|||
|
|
width: 64px;
|
|||
|
|
height: 64px;
|
|||
|
|
font-size: 1.1rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qa {
|
|||
|
|
gap: 0.45rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qa-card {
|
|||
|
|
padding: 0.7rem 0.2rem 0.6rem;
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
border-radius: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.qa-ico {
|
|||
|
|
width: 34px;
|
|||
|
|
height: 34px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.smart {
|
|||
|
|
padding: 0.8rem 0.8rem 0.75rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.smart-head {
|
|||
|
|
gap: 0.55rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.smart-cta {
|
|||
|
|
font-size: 0.78rem;
|
|||
|
|
padding: 0.62rem 0.7rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.tabs button {
|
|||
|
|
font-size: 0.72rem;
|
|||
|
|
padding: 0.5rem 0.1rem 0.62rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpis {
|
|||
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|||
|
|
gap: 0.5rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi {
|
|||
|
|
grid-template-columns: auto minmax(0, 1fr);
|
|||
|
|
align-items: start;
|
|||
|
|
column-gap: 0.5rem;
|
|||
|
|
row-gap: 0.12rem;
|
|||
|
|
padding: 0.65rem 0.7rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi-ico {
|
|||
|
|
grid-row: 1 / span 3;
|
|||
|
|
margin-bottom: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi p,
|
|||
|
|
.kpi strong,
|
|||
|
|
.kpi em {
|
|||
|
|
grid-column: 2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.kpi strong {
|
|||
|
|
font-size: 0.8rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-panel dl > div {
|
|||
|
|
grid-template-columns: 1fr;
|
|||
|
|
gap: 0.12rem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
h1 {
|
|||
|
|
font-size: 1.22rem;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|