Files
CRM_Montzar/apps/web/src/views/PersonProfileView.vue
T

2260 lines
60 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { formatCompactMoney, formatMoney } from '@/lib/money';
import {
calendarDaysBetween,
formatClockFa,
formatJalaliNumeric,
rewriteGregorianDates,
toFaDigits,
} from '@/lib/jalali';
import {
buildSmartSummary,
isVipPerson,
purchaseProbability,
} from '@/lib/person-insights';
import UiIcon from '@/components/UiIcon.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import { brand } from '@mento/shared';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import AskMentoPanel from '@/components/AskMentoPanel.vue';
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import PersonAssetsPanel from '@/components/person-assets/PersonAssetsPanel.vue';
import MontifyStoresCard from '@/components/person-assets/MontifyStoresCard.vue';
import InternalPartyBalance from '@/components/person-assets/InternalPartyBalance.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
import type {
ActivityDto,
DealDto,
InvoiceDto,
MemoryItemDto,
NoteAttachmentDto,
NoteDto,
PaginatedResponse,
PersonDto,
PersonAiSummaryDto,
PersonAccountingLinkDto,
PipelineBoardDto,
ProjectBoardBoardDto,
ProjectCardDto,
QuoteDto,
TicketDto,
} from '@mento/shared';
import { PERSON_GENDER_LABELS } from '@mento/shared';
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
import { canUseBehavior } from '@/lib/behavior-access';
import DiscBadge from '@/components/behavior/DiscBadge.vue';
import CustomerGroupChips from '@/components/CustomerGroupChips.vue';
import DiscPanel from '@/components/behavior/DiscPanel.vue';
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
type TabKey = 'activities' | 'tasks' | 'deals' | 'cards' | 'assets' | 'info' | 'disc' | 'copilot';
type TimelineTone = 'call' | 'quote' | 'note' | 'meeting' | 'task';
type HistoryFilter = 'all' | TimelineTone;
type TimelineRow = {
key: string;
title: string;
body?: string | null;
when: string;
tone: TimelineTone;
href: string;
at: number;
prior?: boolean;
};
type AttachmentRow = {
key: string;
noteId: string;
att: NoteAttachmentDto;
kind: 'pdf' | 'doc' | 'img' | 'file';
};
const auth = useAuthStore();
const canBehavior = computed(() => canUseBehavior(auth.user));
const TABS = computed<Array<{ key: TabKey; label: string }>>(() => {
const items: Array<{ key: TabKey; label: string }> = [
{ key: 'activities', label: 'فعالیت‌ها' },
{ key: 'tasks', label: 'کارهای مرتبط' },
{ key: 'deals', label: 'معاملات' },
{ key: 'cards', label: 'کارت‌ها' },
{ key: 'assets', label: 'ته‌حساب و دارایی' },
{ key: 'info', label: 'اطلاعات' },
];
if (canBehavior.value) {
items.push(
{ key: 'disc', label: 'تحلیل رفتاری DISC' },
{ key: 'copilot', label: 'دستیار هوشمند فروش' },
);
}
return items;
});
const HISTORY_FILTERS: Array<{ key: HistoryFilter; label: string }> = [
{ key: 'all', label: 'همه' },
{ key: 'call', label: 'تماس' },
{ key: 'meeting', label: 'جلسه' },
{ key: 'task', label: 'یادآوری' },
{ key: 'note', label: 'یادداشت' },
{ key: 'quote', 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 tahesabMoshtariCode = ref<number | null>(null);
const moreOpen = ref(false);
const smsOpen = ref(false);
const mobMoreOpen = ref(false);
const filterOpen = ref(false);
const historyFilter = ref<HistoryFilter>('all');
const tab = ref<TabKey>('activities');
const completing = ref(false);
const aiSummary = ref<PersonAiSummaryDto | null>(null);
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: 'note', label: 'یادداشت جدید', to: { name: 'notes', query: { personId: id.value } } },
{ key: 'sms', label: 'ارسال پیامک' },
{ key: 'delete', label: 'حذف شخص', danger: true },
]);
function onMoreSelect(key: string) {
if (key === 'sms') smsOpen.value = true;
if (key === 'delete') void removePerson();
}
const fullName = computed(() => person.value?.fullName ?? 'مشتری');
const subtitle = computed(() => {
const bits = [person.value?.position, person.value?.company?.name].filter(Boolean);
return bits.join(' • ');
});
const vip = computed(() => isVipPerson(person.value));
const mood = computed(() => person.value?.mood || 'newcomer');
const moodLabel = computed(() => PERSON_MOOD_LABELS[mood.value]);
const phoneNumber = computed(() => person.value?.mobile || person.value?.phone || '');
const phoneDisplay = computed(() => (phoneNumber.value ? toFaDigits(phoneNumber.value) : '—'));
const emailDisplay = computed(() => person.value?.email || '—');
const telHref = computed(() => (phoneNumber.value ? `tel:${phoneNumber.value}` : ''));
const mailHref = computed(() =>
person.value?.email ? `mailto:${person.value.email}` : '',
);
const registeredAt = computed(() =>
person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—',
);
const birthDateLabel = computed(() =>
person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—',
);
const genderLabel = computed(() => {
const key = person.value?.gender;
if (key === 'male' || key === 'female' || key === 'none') return PERSON_GENDER_LABELS[key];
return '—';
});
const externalCode = computed(() => {
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
const fields = person.value?.customFields ?? {};
for (const key of ['didarId', 'customerCode'] as const) {
const raw = fields[key];
if (typeof raw === 'string' && raw.trim()) return raw.trim();
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
}
return '';
});
function customFieldText(...keys: string[]) {
const fields = person.value?.customFields ?? {};
for (const key of keys) {
const raw = fields[key];
if (typeof raw === 'string' && raw.trim()) return raw.trim();
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
}
return '';
}
const nationalIdLabel = computed(() => customFieldText('nationalId', 'CodeMelli', 'codeMeli'));
const cityLabel = computed(() => customFieldText('city', 'City'));
const rastehLabel = computed(() => customFieldText('rasteh', 'Rasteh'));
const SOURCE_LABELS: Record<string, string> = {
manual: 'دستی',
tahesab: 'ته‌حساب',
didar: 'دیدار',
excel: 'اکسل',
phone: 'مخاطبین گوشی',
lead_form: 'فرم لید',
other: 'سایر',
};
const importSourceLabel = computed(() => {
const src = person.value?.importSource || 'manual';
return SOURCE_LABELS[src] || src;
});
const previousSourceLabel = computed(() => {
const src = person.value?.previousImportSource;
if (!src) return '';
return SOURCE_LABELS[src] || src;
});
const priorArchiveOn = computed(() => Boolean(auth.user?.priorArchive));
const priorLens = computed(() => auth.user?.priorArchive?.lens ?? null);
function isPriorActivity(row: ActivityDto) {
const cutoff = auth.user?.priorArchive?.cutoffAt;
if (!auth.user?.priorArchive || !cutoff) return false;
if (row.promotedFromPriorAt) return false;
return new Date(row.createdAt).getTime() < new Date(cutoff).getTime();
}
function isPriorDeal(row: DealDto) {
const cutoff = auth.user?.priorArchive?.cutoffAt;
if (!auth.user?.priorArchive || !cutoff) return false;
if (row.promotedFromPriorAt) return false;
return new Date(row.createdAt).getTime() < new Date(cutoff).getTime();
}
/** با لنز کار جاری فقط جاری؛ با بایگانی فقط قبلی؛ بدون قابلیت همه. */
function inActiveLensActivity(row: ActivityDto) {
if (!priorArchiveOn.value || !auth.user?.priorArchive?.cutoffAt) return true;
const prior = isPriorActivity(row);
return priorLens.value === 'archive' ? prior : !prior;
}
function inActiveLensDeal(row: DealDto) {
if (!priorArchiveOn.value || !auth.user?.priorArchive?.cutoffAt) return true;
const prior = isPriorDeal(row);
return priorLens.value === 'archive' ? prior : !prior;
}
const lensActivities = computed(() => activities.value.filter(inActiveLensActivity));
const lensDeals = computed(() => deals.value.filter(inActiveLensDeal));
const lastCallAt = computed(() => {
const calls = lensActivities.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 daysSinceCall = computed(() =>
lastCallAt.value ? calendarDaysBetween(new Date(lastCallAt.value), new Date()) : null,
);
const nextActivity = computed(() => {
const now = Date.now();
return (
lensActivities.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) ??
lensActivities.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(() => lensDeals.value.filter((d) => d.status === 'open'));
const currentDeals = computed(() =>
priorLens.value === 'archive' ? [] : lensDeals.value,
);
const priorDeals = computed(() =>
priorLens.value === 'archive' ? lensDeals.value : [],
);
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 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 shownSmart = computed(() => ({
text: rewriteGregorianDates(aiSummary.value?.text || smart.value.text),
cta: rewriteGregorianDates(aiSummary.value?.cta || smart.value.cta),
ctaKind: aiSummary.value?.ctaKind || smart.value.ctaKind,
}));
function typeTitle(type: string) {
if (type === 'call') return 'تماس';
if (type === 'meeting') return 'جلسه حضوری';
if (type === 'task') return 'یادآوری';
return 'یادداشت';
}
function historyWhen(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const days = calendarDaysBetween(d, new Date());
const clock = formatClockFa(d);
if (days === 0) return `امروز ${clock}`;
if (days === 1) return `دیروز ${clock}`;
return `${toFaDigits(days)} روز پیش ${clock}`;
}
function nextWhenLabel(iso: string | null | undefined) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const days = calendarDaysBetween(new Date(), d);
const clock = formatClockFa(d);
if (days === 0) return `${clock} - امروز`;
if (days === 1) return `${clock} - فردا`;
if (days === -1) return `${clock} - دیروز`;
return `${clock} - ${formatJalaliNumeric(d)}`;
}
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 timelineIcon(tone: TimelineTone) {
if (tone === 'call') return 'phone' as const;
if (tone === 'meeting') return 'user' as const;
if (tone === 'task') return 'calendar' as const;
if (tone === 'quote') return 'mail' as const;
return 'document' as const;
}
const timeline = computed<TimelineRow[]>(() => {
const rows: TimelineRow[] = [];
for (const a of lensActivities.value) {
const at = new Date(a.dueAt || a.createdAt).getTime();
rows.push({
key: `a-${a.id}`,
title: typeTitle(a.type) || a.title,
body: a.description || a.title,
when: historyWhen(a.dueAt || a.createdAt),
tone: activityTone(a),
href: `/activities/${a.id}`,
at,
prior: isPriorActivity(a),
});
}
// یادداشت و پیشنهاد در هر دو لنز می‌مانند (فقط معامله/فعالیت جدا می‌شوند)
if (priorLens.value !== 'archive') {
for (const n of notes.value) {
rows.push({
key: `n-${n.id}`,
title: n.title?.trim() || 'یادداشت',
body: n.body,
when: historyWhen(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: historyWhen(q.createdAt),
tone: 'quote',
href: `/quotes/${q.id}`,
at: new Date(q.createdAt).getTime(),
});
}
}
return rows.sort((a, b) => b.at - a.at);
});
const visibleTimeline = computed(() => {
const rows =
historyFilter.value === 'all'
? timeline.value
: timeline.value.filter((row) => row.tone === historyFilter.value);
return rows.slice(0, 8);
});
function fileKind(name: string, mime: string): AttachmentRow['kind'] {
const n = name.toLowerCase();
if (mime.includes('pdf') || n.endsWith('.pdf')) return 'pdf';
if (n.endsWith('.doc') || n.endsWith('.docx') || mime.includes('word')) return 'doc';
if (mime.startsWith('image/')) return 'img';
return 'file';
}
const attachments = computed<AttachmentRow[]>(() =>
notes.value.flatMap((note) =>
(note.attachments ?? []).map((att) => ({
key: `${note.id}-${att.id}`,
noteId: note.id,
att,
kind: fileKind(att.fileName, att.mimeType),
})),
),
);
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.website &&
!p.instagram &&
!p.birthDate &&
!p.referrer &&
!nationalIdLabel.value &&
!cityLabel.value &&
!rastehLabel.value &&
!(p.addresses?.length) &&
!p.company &&
!externalCode.value &&
!p.notes?.trim() &&
!tickets.value.length
);
});
const dealStatusLabel: Record<string, string> = {
open: 'باز',
won: 'برنده',
lost: 'باخته',
};
function formatSize(bytes: number) {
if (bytes < 1024) return `${toFaDigits(bytes)} B`;
if (bytes < 1024 * 1024) return `${toFaDigits((bytes / 1024).toFixed(1))} KB`;
return `${toFaDigits((bytes / (1024 * 1024)).toFixed(1))} MB`;
}
function readTab() {
const q = route.query.tab;
if (
q === 'activities' ||
q === 'tasks' ||
q === 'deals' ||
q === 'cards' ||
q === 'assets' ||
q === 'info' ||
q === 'disc' ||
q === 'copilot'
) {
if ((q === 'disc' || q === 'copilot') && !canBehavior.value) {
tab.value = 'activities';
return;
}
tab.value = q;
} else {
tab.value = 'activities';
}
}
function setTab(next: TabKey) {
tab.value = next;
void router.replace({ query: { ...route.query, tab: next === 'activities' ? undefined : next } });
}
async function load() {
if (!id.value) return;
loading.value = true;
error.value = '';
moreOpen.value = false;
mobMoreOpen.value = false;
filterOpen.value = false;
aiSummary.value = null;
tahesabMoshtariCode.value = null;
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);
void loadAiSummary();
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'),
api.get<PersonAccountingLinkDto[]>(
`/integrations/tahesab/people/${id.value}/links`,
),
]);
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)
: [];
if (extras[3].status === 'fulfilled' && extras[3].value.data.length) {
const preferred =
extras[3].value.data.find((l) => l.isDefaultConnection) ??
extras[3].value.data[0];
tahesabMoshtariCode.value = preferred?.moshtariCode ?? null;
}
} catch {
error.value = 'بارگذاری پرونده مشتری ناموفق بود.';
person.value = null;
} finally {
loading.value = false;
}
}
function closeMenus() {
moreOpen.value = false;
mobMoreOpen.value = false;
filterOpen.value = false;
}
async function goCall() {
closeMenus();
const number = phoneNumber.value;
if (!number) return;
try {
const { data } = await api.post<{ ok: boolean; mode: string; dialUrl: string }>(
'/integrations/voip/click-to-call',
{ number, personId: id.value },
);
if (data.mode === 'tel' && data.dialUrl) {
window.location.href = data.dialUrl;
}
// mode url: سانترال تماس را شروع کرده؛ نیازی به tel نیست
} catch {
if (telHref.value) window.location.href = telHref.value;
}
}
function goMail() {
closeMenus();
if (mailHref.value) window.location.href = mailHref.value;
}
function goAddActivity() {
closeMenus();
void router.push({
name: 'activity-new',
query: { personId: id.value },
});
}
function onSmartCta() {
if (shownSmart.value.ctaKind === 'call' && telHref.value) {
goCall();
return;
}
void router.push({
name: 'activity-new',
query: {
personId: id.value,
type: 'call',
title: `تماس با ${fullName.value}`,
},
});
}
async function loadAiSummary() {
if (!id.value) return;
try {
const { data } = await api.get<PersonAiSummaryDto>(`/ai/people/${id.value}/summary`);
aiSummary.value = data;
} catch {
aiSummary.value = null;
}
}
async function completeNext() {
if (!nextActivity.value || completing.value) return;
completing.value = true;
error.value = '';
try {
await api.patch(`/activities/${nextActivity.value.id}/complete`);
await load();
} catch {
error.value = 'ثبت انجام فعالیت ناموفق بود.';
} finally {
completing.value = false;
}
}
function onTahesabLinkChanged(code: number | null) {
tahesabMoshtariCode.value = code;
}
async function downloadAttachment(noteId: string, att: NoteAttachmentDto) {
const { data } = await api.get<Blob>(
`/notes/${noteId}/attachments/${att.id}/download`,
{ responseType: 'blob' },
);
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = att.fileName;
a.click();
URL.revokeObjectURL(url);
}
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="page" @click="closeMenus">
<header class="desk-head">
<div class="title-block">
<RouterLink class="back-link" :to="{ name: 'contacts' }">
بازگشت به لیست مخاطبین
</RouterLink>
<h1>جزئیات مخاطب</h1>
</div>
<div class="desk-actions">
<RouterLink class="btn edit" :to="{ name: 'person-edit', params: { id } }">
<UiIcon name="pencil" :size="16" />
ویرایش
</RouterLink>
<button class="btn danger" type="button" @click.stop="removePerson">
<UiIcon name="trash" :size="16" />
حذف
</button>
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" @select="onMoreSelect" />
</div>
</header>
<header class="mob-head">
<RouterLink class="icon-btn" :to="{ name: 'contacts' }" aria-label="بازگشت">
<UiIcon name="chevron" :size="18" />
</RouterLink>
<h1>جزئیات مخاطب</h1>
<div class="mob-actions" @click.stop>
<button class="icon-btn danger" type="button" aria-label="حذف" @click="removePerson">
<UiIcon name="trash" :size="18" />
</button>
<RouterLink class="icon-btn" :to="{ name: 'person-edit', params: { id } }" aria-label="ویرایش">
<UiIcon name="pencil" :size="18" />
</RouterLink>
<ProfileMoreMenu v-model="mobMoreOpen" :items="moreItems" @select="onMoreSelect" />
</div>
</header>
<p v-if="loading" class="muted pad">در حال بارگذاری پرونده…</p>
<p v-else-if="error && !person" class="error pad">{{ error }}</p>
<template v-else-if="person">
<p v-if="error" class="error pad">{{ error }}</p>
<article class="hero desk-hero">
<div class="hero-top">
<CustomerMoodFace :mood="mood" :size="76" />
<div class="who">
<div class="name-row">
<h2>{{ fullName }}</h2>
<span class="mood-under" :class="mood">{{ moodLabel }}</span>
<span v-if="vip" class="vip-mark">VIP</span>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
</div>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<div class="meta-chips">
<span v-if="tahesabMoshtariCode != null" class="meta-chip accent">
کد حسابداری {{ toFaDigits(String(tahesabMoshtariCode)) }}
</span>
<span class="meta-chip">منبع: {{ importSourceLabel }}</span>
<span v-if="externalCode" class="meta-chip">
کد مبدأ: <bdi>{{ toFaDigits(externalCode) }}</bdi>
</span>
<span v-if="person.previousFullName" class="meta-chip">
نام قبلی: {{ person.previousFullName }}
</span>
<span v-if="previousSourceLabel" class="meta-chip prev">
قبلاً در {{ previousSourceLabel }}
<template v-if="person.previousSourceRef">
— کد <bdi>{{ toFaDigits(person.previousSourceRef) }}</bdi>
</template>
</span>
</div>
</div>
<div class="hero-side">
<span class="status-pill" :class="isActiveCustomer ? 'on' : 'off'">
<i />
{{ isActiveCustomer ? 'مشتری فعال' : 'غیرفعال' }}
</span>
<a v-if="phoneNumber" class="contact-pill" :href="telHref || undefined" dir="ltr">
<UiIcon name="phone" :size="15" />
{{ phoneDisplay }}
</a>
<button class="contact-pill sms" type="button" :disabled="!phoneNumber" @click="smsOpen = true">
<UiIcon name="chat" :size="15" />
پیامک
</button>
<a v-if="person.email" class="contact-pill" :href="mailHref || undefined" dir="ltr">
<UiIcon name="mail" :size="15" />
{{ person.email }}
</a>
</div>
</div>
<dl class="facts-grid">
<div class="fact">
<dt>گروه‌ها</dt>
<dd><CustomerGroupChips :groups="person.groups" empty="—" /></dd>
</div>
<div class="fact">
<dt>شرکت</dt>
<dd>{{ person.company?.name || '—' }}</dd>
</div>
<div class="fact">
<dt>سمت</dt>
<dd>{{ person.position || '—' }}</dd>
</div>
<div class="fact">
<dt>جنسیت</dt>
<dd>{{ genderLabel }}</dd>
</div>
<div class="fact">
<dt>تاریخ تولد</dt>
<dd>{{ birthDateLabel }}</dd>
</div>
<div class="fact">
<dt>تاریخ ثبت</dt>
<dd>{{ registeredAt }}</dd>
</div>
<div v-if="person.referrer" class="fact">
<dt>معرف</dt>
<dd>{{ person.referrer }}</dd>
</div>
<div v-if="nationalIdLabel" class="fact">
<dt>کد ملی</dt>
<dd dir="ltr">{{ toFaDigits(nationalIdLabel) }}</dd>
</div>
<div v-if="cityLabel" class="fact">
<dt>شهر</dt>
<dd>{{ cityLabel }}</dd>
</div>
<div v-if="rastehLabel" class="fact">
<dt>رسته</dt>
<dd dir="ltr">{{ toFaDigits(rastehLabel) }}</dd>
</div>
<div v-if="person.website" class="fact">
<dt>وب‌سایت</dt>
<dd dir="ltr">{{ person.website }}</dd>
</div>
<div v-if="person.instagram" class="fact">
<dt>اینستاگرام</dt>
<dd dir="ltr">{{ person.instagram }}</dd>
</div>
<div v-if="person.addresses?.length" class="fact wide">
<dt>آدرس‌ها</dt>
<dd>
<p v-for="(addr, i) in person.addresses" :key="i" class="addr-line">{{ addr }}</p>
</dd>
</div>
</dl>
</article>
<article class="hero mob-hero">
<span class="mood-badge" :class="mood">{{ moodLabel }}</span>
<div class="mob-id">
<CustomerMoodFace :mood="mood" :size="72" />
<div class="who">
<h2>
{{ fullName }}
<span v-if="tahesabMoshtariCode != null" class="tahesab-code" dir="rtl"
>کد حسابداری {{ toFaDigits(String(tahesabMoshtariCode)) }}</span
>
</h2>
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
<CustomerGroupChips :groups="person.groups" />
</div>
</div>
<div class="contact-rows">
<div class="crow">
<UiIcon name="phone" :size="16" />
<span>{{ phoneDisplay }}</span>
<button
class="act-btn"
type="button"
:disabled="!telHref"
aria-label="تماس"
@click="goCall"
>
<UiIcon name="phone" :size="16" />
</button>
</div>
<div class="crow">
<UiIcon name="mail" :size="16" />
<span>{{ emailDisplay }}</span>
<button
class="act-btn"
type="button"
:disabled="!mailHref"
aria-label="ایمیل"
@click="goMail"
>
<UiIcon name="mail" :size="16" />
</button>
</div>
</div>
</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 === '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"
/>
<div v-else class="act-grid">
<section class="card history">
<header class="block-head">
<h3>تاریخچه فعالیت‌ها</h3>
<div class="filter-wrap" @click.stop>
<button class="ghost-btn" type="button" @click="filterOpen = !filterOpen">
<UiIcon name="filter" :size="14" />
فیلتر
</button>
<ul v-if="filterOpen" class="filter-menu">
<li v-for="item in HISTORY_FILTERS" :key="item.key">
<button
type="button"
:class="{ on: historyFilter === item.key }"
@click="historyFilter = item.key; filterOpen = false"
>
{{ item.label }}
</button>
</li>
</ul>
</div>
</header>
<ol v-if="visibleTimeline.length" class="tl">
<li v-for="row in visibleTimeline" :key="row.key">
<RouterLink :to="row.href">
<span class="dot" :class="row.tone">
<UiIcon :name="timelineIcon(row.tone)" :size="14" />
</span>
<div>
<time>{{ row.when }}</time>
<strong>{{ row.title }}</strong>
<p v-if="row.body">{{ row.body }}</p>
</div>
</RouterLink>
</li>
</ol>
<p v-else class="empty">موردی با این فیلتر نیست.</p>
<RouterLink class="see-all" :to="{ name: 'activities', query: { filter: 'all' } }">
مشاهده همه فعالیت‌ها
</RouterLink>
</section>
<aside class="widgets">
<article v-if="nextActivity" class="card next">
<header class="block-head">
<h3>فعالیت بعدی</h3>
<span class="wait">در انتظار</span>
</header>
<div class="next-title">
<i class="next-dot" :class="nextActivity.type" />
<strong>{{ typeTitle(nextActivity.type) }}</strong>
</div>
<p class="next-when">
<UiIcon name="clock" :size="14" />
{{ nextWhenLabel(nextActivity.dueAt) }}
</p>
<p v-if="nextActivity.description || nextActivity.title" class="next-body">
{{ nextActivity.description || nextActivity.title }}
</p>
<button class="done-btn" type="button" :disabled="completing" @click="completeNext">
<UiIcon name="check-plain" :size="16" />
{{ completing ? 'در حال ثبت…' : 'انجام شد' }}
</button>
</article>
<article class="card files">
<header class="block-head">
<h3>فایل‌های پیوست</h3>
</header>
<ul v-if="attachments.length" class="file-list">
<li v-for="row in attachments" :key="row.key">
<button type="button" @click="downloadAttachment(row.noteId, row.att)">
<span class="file-ico" :class="row.kind">
<UiIcon name="document" :size="16" />
</span>
<span>
<strong>{{ row.att.fileName }}</strong>
<small>{{ formatSize(row.att.sizeBytes) }}</small>
</span>
</button>
</li>
</ul>
<p v-else class="empty">فایلی پیوست نشده.</p>
</article>
</aside>
</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 } }"
/>
<div v-if="!dealsEmpty">
<RouterLink
v-for="deal in currentDeals"
: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="priorDeals.length" class="subblock">
<h3>بایگانی اطلاعات قبل</h3>
<RouterLink
v-for="deal in priorDeals"
: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>
<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 } }"
/>
<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-if="tab === 'tasks'" class="panel">
<RelatedTasksPanel :entity="{ type: 'person', id, label: fullName }" />
</div>
<div v-show="tab === 'assets'" class="panel assets-panel">
<InternalPartyBalance :person-id="id" />
<MontifyStoresCard :person-id="id" />
<PersonAssetsPanel
:person-id="id"
:person-name="fullName"
:person-phone="phoneNumber"
@link-changed="onTahesabLinkChanged"
/>
</div>
<div v-if="canBehavior && tab === 'disc'" class="panel">
<DiscPanel :person-id="id" @saved="load" />
</div>
<div v-if="canBehavior && tab === 'copilot'" class="panel">
<SalesCopilotPanel
:person-id="id"
:deal-id="typeof route.query.dealId === 'string' ? route.query.dealId : null"
:customer-name="fullName"
:disc-label="person.discReliable ? person.discLabel : null"
:stage-name="null"
:phone="phoneNumber"
:sms-endpoint="`/people/${id}/sms`"
:sms-history-endpoint="`/people/${id}/sms-history`"
/>
</div>
<div v-show="tab === 'info'" class="panel info-panel" :class="{ 'is-empty': infoEmpty }">
<article class="smart">
<header class="smart-head">
<MentoAiAvatar :size="54" :show-bubble="false" />
<div>
<h2>
<UiIcon name="sparkle" :size="14" />
خلاصه هوشمند {{ brand.nameFa }}
</h2>
<p>{{ shownSmart.text }}</p>
</div>
</header>
<AskMentoPanel
:person-id="id"
:placeholder="`درباره ${fullName} بپرس...`"
/>
<button class="smart-cta" type="button" @click="onSmartCta">
<span>{{ shownSmart.cta }}</span>
<UiIcon name="chevron" :size="16" />
</button>
</article>
<ProfileEmptyState
v-if="infoEmpty"
primary-label="افزودن اطلاعات مشتری"
:primary-to="{ name: 'person-edit', params: { id } }"
secondary-label="افزودن اولین فعالیت"
:secondary-to="{ name: 'activity-new', query: { personId: id } }"
/>
<div v-if="!infoEmpty">
<dl>
<div><dt>موبایل</dt><dd>{{ person.mobile ? toFaDigits(person.mobile) : '—' }}</dd></div>
<div><dt>تلفن</dt><dd>{{ person.phone ? toFaDigits(person.phone) : '—' }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ person.email || '—' }}</dd></div>
<div><dt>سمت</dt><dd>{{ person.position || '—' }}</dd></div>
<div><dt>جنسیت</dt><dd>{{ genderLabel }}</dd></div>
<div><dt>منبع ورود</dt><dd>{{ importSourceLabel }}</dd></div>
<div><dt>کد مبدأ</dt><dd dir="ltr">{{ externalCode ? toFaDigits(externalCode) : '—' }}</dd></div>
<div><dt>نام قبلی</dt><dd>{{ person.previousFullName || '—' }}</dd></div>
<div><dt>وب‌سایت</dt><dd>{{ person.website || '—' }}</dd></div>
<div><dt>اینستاگرام</dt><dd dir="ltr">{{ person.instagram || '—' }}</dd></div>
<div><dt>معرف</dt><dd>{{ person.referrer || '—' }}</dd></div>
<div>
<dt>کد ملی</dt>
<dd dir="ltr">{{ nationalIdLabel ? toFaDigits(nationalIdLabel) : '—' }}</dd>
</div>
<div><dt>شهر</dt><dd>{{ cityLabel || '—' }}</dd></div>
<div>
<dt>رسته</dt>
<dd dir="ltr">{{ rastehLabel ? toFaDigits(rastehLabel) : '—' }}</dd>
</div>
<div class="wide">
<dt>آدرس‌ها</dt>
<dd>
<template v-if="person.addresses?.length">
<p v-for="(addr, i) in person.addresses" :key="i" class="addr-line">{{ addr }}</p>
</template>
<template v-else>—</template>
</dd>
</div>
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
<div><dt>مسئول</dt><dd>{{ person.owner?.fullName || '—' }}</dd></div>
<div><dt>تاریخ تولد</dt><dd>{{ birthDateLabel }}</dd></div>
<div><dt>تاریخ ثبت</dt><dd>{{ registeredAt }}</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>
<SmsComposeModal
v-if="person"
:open="smsOpen"
title="ارسال پیامک"
:endpoint="`/people/${id}/sms`"
:history-endpoint="`/people/${id}/sms-history`"
:name="fullName"
:phone="phoneNumber"
:record-title="person.position?.trim() || ''"
:company="person.company?.name ?? ''"
@close="smsOpen = false"
/>
<div v-if="person" class="dock">
<button class="fab-item call" type="button" :disabled="!telHref" @click="goCall">
<span class="fab-circle">
<UiIcon name="phone" :size="22" />
</span>
<span class="fab-label">تماس</span>
</button>
<button class="fab-item add" type="button" @click="goAddActivity">
<span class="fab-circle">
<UiIcon name="plus" :size="22" />
</span>
<span class="fab-label">افزودن فعالیت</span>
</button>
</div>
</section>
</template>
<style scoped>
.page {
width: 100%;
min-width: 0;
max-width: 100%;
margin: 0;
display: grid;
gap: 1rem;
}
.page > * {
min-width: 0;
max-width: 100%;
}
.desk-head {
display: none;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.title-block h1 {
margin: 0.15rem 0 0;
font-size: 1.45rem;
}
.back-link {
color: #0f766e;
font-size: 0.82rem;
font-weight: 700;
}
.desk-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #e2e8f0;
background: #fff;
color: #334155;
border-radius: var(--radius);
padding: 0.55rem 0.9rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.btn.edit {
color: #0f766e;
border-color: #ccfbf1;
background: #f0fdfa;
}
.btn.danger {
color: #b91c1c;
border-color: #fecaca;
background: #fff;
}
.mob-head {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 0.4rem;
}
.mob-head h1 {
margin: 0;
text-align: center;
font-size: 1.02rem;
}
.mob-actions {
display: flex;
align-items: center;
gap: 0.15rem;
}
.icon-btn {
width: 38px;
height: 38px;
border: 0;
background: none;
color: #334155;
display: grid;
place-items: center;
cursor: pointer;
}
.icon-btn.danger {
color: #b91c1c;
}
.mob-head .icon-btn:first-child :deep(.ico) {
transform: scaleX(-1);
}
.hero {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05);
min-width: 0;
}
.desk-hero {
display: none;
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding: 0;
overflow: hidden;
}
.hero-top {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 1.1rem;
align-items: center;
padding: 1.25rem 1.4rem;
background: linear-gradient(135deg, #f0fdfa 0%, #fff 60%);
}
.name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.desk-hero .who h2 {
font-size: 1.3rem;
line-height: 1.5;
}
.desk-hero .sub {
margin: 0.15rem 0 0;
}
.meta-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.6rem;
}
.meta-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 0.75rem;
font-weight: 600;
}
.meta-chip.accent {
background: #ccfbf1;
color: #0f766e;
}
.meta-chip.prev {
background: #fef3c7;
color: #92400e;
}
.hero-side {
display: grid;
justify-items: end;
gap: 0.45rem;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
background: #f1f5f9;
color: #64748b;
}
.status-pill i {
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
}
.status-pill.on {
background: #dcfce7;
color: #15803d;
}
.contact-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.8rem;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
background: #fff;
color: #0f766e;
font-size: 0.86rem;
font-weight: 700;
text-decoration: none;
max-width: 18rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-pill:hover {
border-color: #5eead4;
background: #f0fdfa;
}
button.contact-pill {
font: inherit;
font-weight: 700;
cursor: pointer;
}
button.contact-pill:disabled {
opacity: 0.55;
cursor: default;
}
.facts-grid {
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 0;
border-top: 1px solid #f1f5f9;
}
.fact {
padding: 0.85rem 1.4rem;
border-inline-start: 1px solid #f1f5f9;
min-width: 0;
}
.fact:first-child {
border-inline-start: 0;
}
.fact.wide {
grid-column: 1 / -1;
border-inline-start: 0;
border-top: 1px solid #f1f5f9;
}
.fact dt {
color: #94a3b8;
font-size: 0.75rem;
margin-bottom: 0.25rem;
}
.fact dd {
margin: 0;
color: #0f172a;
font-size: 0.9rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.face-col {
display: grid;
justify-items: center;
gap: 0.65rem;
}
.mood-under,
.mood-badge {
border-radius: 999px;
padding: 0.22rem 0.7rem;
font-size: 0.72rem;
font-weight: 600;
background: #ecfdf5;
color: #0f766e;
}
.mood-under.happy,
.mood-badge.happy {
background: #f3e8ff;
color: #7c3aed;
}
.mood-under.unhappy,
.mood-badge.unhappy {
background: #ffe4e6;
color: #e11d48;
}
.mood-under.special,
.mood-badge.special {
background: #fef3c7;
color: #d97706;
}
.who {
min-width: 0;
}
.who h2 {
margin: 0;
font-size: 1.45rem;
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.vip-mark {
font-size: 0.68rem;
background: #ede9fe;
color: #6d28d9;
border-radius: 999px;
padding: 0.12rem 0.45rem;
}
.tahesab-code {
font-size: 0.78rem;
font-weight: 700;
color: #0f766e;
background: color-mix(in srgb, #0f766e 12%, transparent);
border-radius: var(--radius);
padding: 0.12rem 0.5rem;
letter-spacing: 0;
}
.sub {
margin: 0.25rem 0 0.7rem;
color: #64748b;
font-size: 0.9rem;
}
.line {
margin: 0.35rem 0 0;
display: flex;
align-items: center;
gap: 0.45rem;
color: #0f766e;
font-weight: 700;
}
.facts {
margin: 0;
display: grid;
gap: 0.45rem;
}
.facts > div {
display: grid;
grid-template-columns: 5.6rem minmax(0, 1fr);
gap: 0.5rem;
font-size: 0.86rem;
}
.facts dt {
color: #94a3b8;
}
.facts dd {
margin: 0;
color: #0f172a;
overflow-wrap: anywhere;
}
.facts .wide,
.info-panel .wide {
grid-template-columns: 5.6rem minmax(0, 1fr);
}
.info-panel .wide {
grid-template-columns: 7.5rem 1fr;
}
.addr-line {
margin: 0 0 0.25rem;
}
.addr-line:last-child {
margin-bottom: 0;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 700;
}
.status i {
width: 8px;
height: 8px;
border-radius: 999px;
background: #94a3b8;
}
.status.on {
color: #15803d;
}
.status.on i {
background: #22c55e;
}
.mob-hero {
position: relative;
padding: 1.05rem 1rem 0.95rem;
display: grid;
gap: 0.85rem;
}
.mood-badge {
position: absolute;
top: 0.85rem;
inset-inline-end: 0.9rem;
}
.mob-id {
display: flex;
align-items: center;
gap: 0.8rem;
padding-inline-end: 7.5rem;
}
.mob-hero .who h2 {
font-size: 1.18rem;
}
.contact-rows {
display: grid;
gap: 0.45rem;
}
.crow {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 36px;
align-items: center;
gap: 0.55rem;
color: #0f766e;
font-weight: 700;
font-size: 0.9rem;
}
.crow span {
overflow-wrap: anywhere;
}
.act-btn {
width: 36px;
height: 36px;
border: 0;
border-radius: var(--radius);
background: #ecfdf5;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.act-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tabs {
display: flex;
gap: 0;
overflow-x: auto;
overscroll-behavior-x: contain;
border-bottom: 1px solid #e2e8f0;
-webkit-overflow-scrolling: touch;
}
.tabs button {
flex: 0 0 auto;
border: 0;
background: none;
padding: 0.6rem 0.75rem 0.72rem;
color: #64748b;
font: inherit;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
box-shadow: inset 0 -2px 0 transparent;
}
.tabs button.on {
color: #0f766e;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.panel.is-empty {
display: grid;
place-items: center;
min-height: min(52vh, 460px);
}
.act-grid {
display: grid;
gap: 1rem;
grid-template-areas:
'next'
'history'
'files';
}
.history {
grid-area: history;
}
.next {
grid-area: next;
}
.files {
grid-area: files;
display: none;
}
.card {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem 0.95rem;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.widgets {
display: contents;
}
.block-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
position: relative;
}
.block-head h3 {
margin: 0;
font-size: 0.95rem;
}
.filter-wrap {
position: relative;
}
.ghost-btn,
.ghost-link,
.see-all {
border: 0;
background: none;
color: #0f766e;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.see-all,
.ghost-link {
display: block;
text-align: center;
margin-top: 0.85rem;
padding: 0.35rem 0 0.1rem;
}
.filter-menu {
position: absolute;
inset-inline-end: 0;
top: calc(100% + 6px);
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.14);
min-width: 8.5rem;
z-index: 4;
}
.filter-menu button {
width: 100%;
border: 0;
background: none;
text-align: start;
padding: 0.5rem 0.65rem;
border-radius: var(--radius);
font: inherit;
cursor: pointer;
}
.filter-menu button.on,
.filter-menu button:hover {
background: #f0fdfa;
color: #0f766e;
}
.tl {
list-style: none;
margin: 0;
padding: 0;
}
.tl li {
position: relative;
}
.tl li:not(:last-child)::before {
content: '';
position: absolute;
top: 34px;
bottom: -6px;
inset-inline-start: 15px;
width: 2px;
background: #e2e8f0;
}
.tl a {
display: flex;
gap: 0.75rem;
padding: 0.5rem 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.meeting { background: #d1fae5; color: #0f766e; }
.dot.task { background: #dbeafe; color: #1d4ed8; }
.dot.note { background: #fef9c3; color: #a16207; }
.dot.quote { background: #ede9fe; color: #6d28d9; }
.tl time {
display: block;
color: #94a3b8;
font-size: 0.75rem;
}
.tl strong {
display: block;
margin-top: 0.12rem;
font-size: 0.9rem;
}
.tl p {
margin: 0.18rem 0 0;
color: #64748b;
font-size: 0.8rem;
line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.wait {
background: #fef3c7;
color: #b45309;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
padding: 0.18rem 0.55rem;
}
.next-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.02rem;
}
.next-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: #ef4444;
}
.next-dot.meeting { background: #14b8a6; }
.next-dot.task { background: #2563eb; }
.next-dot.note { background: #ca8a04; }
.next-when {
margin: 0.45rem 0 0;
display: flex;
align-items: center;
gap: 0.35rem;
color: #64748b;
font-size: 0.84rem;
}
.next-body {
margin: 0.45rem 0 0;
color: #475569;
font-size: 0.84rem;
line-height: 1.65;
}
.done-btn {
margin-top: 0.85rem;
width: 100%;
border: 1.5px solid #14b8a6;
background: #fff;
color: #0f766e;
border-radius: var(--radius);
padding: 0.62rem 0.8rem;
font: inherit;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
cursor: pointer;
}
.done-btn:disabled {
opacity: 0.55;
cursor: wait;
}
.file-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.file-list button {
width: 100%;
border: 0;
background: #f8fafc;
border-radius: var(--radius);
padding: 0.55rem 0.65rem;
display: flex;
align-items: center;
gap: 0.65rem;
text-align: start;
font: inherit;
cursor: pointer;
}
.file-list strong,
.file-list small {
display: block;
}
.file-list small {
color: #94a3b8;
font-size: 0.74rem;
margin-top: 0.1rem;
}
.file-ico {
width: 36px;
height: 36px;
border-radius: var(--radius);
display: grid;
place-items: center;
background: #fee2e2;
color: #b91c1c;
flex-shrink: 0;
}
.file-ico.doc { background: #dbeafe; color: #1d4ed8; }
.file-ico.img { background: #dcfce7; color: #15803d; }
.file-ico.file { background: #f1f5f9; color: #475569; }
.row-card {
display: grid;
gap: 0.15rem;
background: #fff;
border-radius: var(--radius);
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: var(--radius);
padding: 0.4rem 1rem;
}
.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;
}
.smart {
background: linear-gradient(135deg, #ecfeff, #f5f3ff);
border: 1px solid #e0e7ff;
border-radius: var(--radius);
padding: 0.95rem 1rem 0.85rem;
display: grid;
gap: 0.75rem;
}
.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: var(--radius);
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);
}
.empty,
.muted {
color: #64748b;
}
.error {
color: #b91c1c;
}
.pad {
padding: 0.4rem 0;
}
.dock {
position: fixed;
inset-inline: 1.1rem;
bottom: calc(0.7rem + var(--mobile-tabbar-h));
z-index: 12;
display: flex;
justify-content: space-between;
align-items: flex-end;
pointer-events: none;
}
.fab-item {
pointer-events: auto;
border: 0;
background: none;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.38rem;
cursor: pointer;
}
.fab-circle {
width: 56px;
height: 56px;
border-radius: 999px;
color: #fff;
display: grid;
place-items: center;
box-shadow: 0 12px 24px rgba(15, 118, 110, 0.28);
}
.fab-item.call .fab-circle {
background: #14b8a6;
}
.fab-item.add .fab-circle {
background: #0f766e;
}
.fab-label {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.2;
color: #0f766e;
white-space: nowrap;
}
.fab-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 959px) {
.page {
padding-bottom: 6.5rem;
}
}
@media (min-width: 960px) {
.page {
padding-bottom: 1.2rem;
gap: 1.1rem;
}
.desk-head,
.desk-hero {
display: flex;
}
.desk-hero {
display: grid;
}
.mob-head,
.mob-hero,
.dock {
display: none;
}
.files {
display: grid;
}
.act-grid {
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.9fr);
grid-template-areas: 'history widgets';
align-items: start;
}
.widgets {
display: grid;
gap: 1rem;
grid-area: widgets;
align-content: start;
}
.next,
.files {
grid-area: auto;
}
.tabs button {
flex: 0 0 auto;
padding-inline: 0.85rem;
}
.info-panel.is-empty {
min-height: auto;
}
}
@media (max-width: 599px) {
.tabs button {
font-size: 0.84rem;
padding: 0.55rem 0.7rem 0.65rem;
}
.info-panel dl > div {
grid-template-columns: 1fr;
gap: 0.12rem;
}
}
</style>