Files
CRM_Montzar/apps/web/src/views/ProfileView.vue
T
MahdiandCursor 7757c0837d Add Ask Mento, customer mood, and Pipecat speech-to-text.
Ship assistant answers, contact moods, voice dictation, and the related CRM UI so these can be deployed to production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 14:20:55 +03:30

1249 lines
31 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 { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import type { AskMentoResponse } from '@mento/shared';
import { formatCompactMoney } from '@/lib/money';
import {
calendarDaysBetween,
formatJalaliDayClock,
formatNextSlot,
formatRelativeContact,
toFaDigits,
} from '@/lib/jalali';
import { purchaseProbability } from '@/lib/person-insights';
import { USER_ROLE_LABELS } from '@/lib/roles';
import UiIcon from '@/components/UiIcon.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import AskMentoPanel from '@/components/AskMentoPanel.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,
PaginatedResponse,
PipelineBoardDto,
ProjectBoardBoardDto,
ProjectCardDto,
} 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 auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const loading = ref(true);
const error = ref('');
const activities = ref<ActivityDto[]>([]);
const deals = ref<DealDto[]>([]);
const cards = ref<ProjectCardDto[]>([]);
const moreOpen = ref(false);
const fabOpen = ref(false);
const tab = ref<TabKey>('summary');
const aiBrief = ref('');
const moreItems: ProfileMoreItem[] = [
{ key: 'info', label: 'ویرایش اطلاعات', to: { name: 'profile', query: { tab: 'info' } } },
{ key: 'activity', label: 'فعالیت جدید', to: { name: 'activity-new' } },
{ key: 'deal', label: 'معامله جدید', to: { name: 'deal-new' } },
{ key: 'quote', label: 'پیش‌فاکتور جدید', to: { name: 'quote-new' } },
{ key: 'ticket', label: 'تیکت جدید', to: { name: 'ticket-new' } },
];
const name = computed(() => auth.user?.fullName || 'کاربر');
const roleLabel = computed(() =>
auth.user?.role ? USER_ROLE_LABELS[auth.user.role] : 'کاربر',
);
const myId = computed(() => auth.user?.id ?? '');
const myDeals = computed(() =>
deals.value.filter((d) => d.owner?.id === myId.value),
);
const openDeals = computed(() => myDeals.value.filter((d) => d.status === 'open'));
const dealTotal = computed(() =>
myDeals.value.reduce((sum, d) => sum + (d.amount ?? 0), 0),
);
const dealCurrency = computed(() => myDeals.value[0]?.currency ?? 'IRR');
const myCards = computed(() =>
cards.value.filter((c) => c.owner?.id === myId.value),
);
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 lastTouchAt = computed(() => {
const stamps = activities.value
.map((a) => a.completedAt || a.dueAt || a.createdAt)
.filter(Boolean)
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime());
return stamps[0] ?? null;
});
const daysSinceContact = computed(() =>
lastTouchAt.value ? calendarDaysBetween(new Date(lastTouchAt.value), new Date()) : null,
);
const probability = computed(() =>
purchaseProbability({
daysSinceContact: daysSinceContact.value,
openDealCount: openDeals.value.length,
quoteCount: 0,
hasUpcoming: Boolean(nextActivity.value),
noteCount: 0,
}),
);
const smartText = computed(() => {
if (aiBrief.value) return aiBrief.value;
const overdue = activities.value.filter(
(a) => a.status === 'open' && a.dueAt && new Date(a.dueAt).getTime() < Date.now(),
).length;
const bits: string[] = [];
if (openDeals.value.length) bits.push(`${toFaDigits(openDeals.value.length)} معامله باز`);
if (overdue) bits.push(`${toFaDigits(overdue)} پیگیری عقب‌افتاده`);
if (nextActivity.value) bits.push(`فعالیت بعدی ${formatNextSlot(nextActivity.value.dueAt)}`);
if (!bits.length) {
return 'برنامهٔ امروز خالی است. با ثبت فعالیت یا معامله، خلاصهٔ هوشمند اینجا پر می‌شود.';
}
return `${bits.join('، ')}. احتمال بستن کارها ${probability.value.band} است.`;
});
const smartCta = computed(() => {
if (daysSinceContact.value != null && daysSinceContact.value <= 1) {
return 'پیشنهاد منتو: پیگیری بعدی را ثبت کن';
}
return 'پیشنهاد منتو: امروز با مشتری تماس بگیر';
});
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: myDeals.value.length
? formatCompactMoney(dealTotal.value, dealCurrency.value)
: '—',
icon: 'chart' as const,
tone: 'chart',
},
{
key: 'deals',
label: 'معاملات',
value: myDeals.value.length
? `${toFaDigits(myDeals.value.length)} معامله`
: 'بدون معامله',
icon: 'bag' as const,
tone: 'bag',
},
{
key: 'last',
label: 'آخرین ارتباط',
value: formatRelativeContact(lastTouchAt.value),
icon: 'calendar' as const,
tone: 'cal',
},
]);
const dealStatusLabel: Record<string, string> = {
open: 'باز',
won: 'برنده',
lost: 'باخته',
};
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' } {
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' };
}
function timelineIcon(tone: TimelineTone) {
if (tone === 'call') return 'phone' as const;
if (tone === 'quote') return 'document' as const;
if (tone === 'meeting') return 'user-plus' as const;
if (tone === 'task') return 'check' as const;
return 'document' as const;
}
const timeline = computed<TimelineRow[]>(() =>
activities.value
.map((a) => {
const badge = activityBadge(a);
return {
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: new Date(a.dueAt || a.createdAt).getTime(),
};
})
.sort((a, b) => b.at - a.at),
);
const summaryTimeline = computed(() => timeline.value.slice(0, 6));
const summaryEmpty = computed(() => !activities.value.length && !myDeals.value.length);
const activitiesEmpty = computed(() => !timeline.value.length);
const dealsEmpty = computed(() => !myDeals.value.length);
const cardsEmpty = computed(() => !myCards.value.length);
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;
moreOpen.value = false;
void router.replace({ query: { ...route.query, tab: next === 'summary' ? undefined : next } });
}
function closeMenus() {
moreOpen.value = false;
fabOpen.value = false;
}
function onSmartCta() {
closeMenus();
void router.push({ name: 'activity-new', query: { type: 'call' } });
}
async function load() {
loading.value = true;
error.value = '';
aiBrief.value = '';
try {
const [actRes, boardRes] = await Promise.all([
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { filter: 'all', limit: 80 },
}),
api.get<PipelineBoardDto>('/pipelines/board'),
]);
activities.value = actRes.data.items;
deals.value = boardRes.data.stages.flatMap((s) => s.deals);
try {
const { data } = await api.get<ProjectBoardBoardDto>('/project-boards/board');
cards.value = data.stages.flatMap((s) => s.cards);
} catch {
cards.value = [];
}
void loadAiBrief();
} catch {
error.value = 'بارگذاری پروفایل ناموفق بود.';
} finally {
loading.value = false;
}
}
async function loadAiBrief() {
try {
const { data } = await api.post<AskMentoResponse>('/ai/ask', {
question:
'وضعیت کاری امروز من را خلاصه کن: فعالیت‌های عقب‌افتاده، معاملات باز و مهم‌ترین پیگیری بعدی.',
});
aiBrief.value = data.answer;
} catch {
aiBrief.value = '';
}
}
watch(() => route.query.tab, readTab, { immediate: true });
watch(() => auth.user?.id, () => void load(), { immediate: true });
</script>
<template>
<section class="profile" @click="closeMenus">
<header class="nav">
<RouterLink class="icon-btn back-btn" :to="{ name: 'home' }" aria-label="بازگشت" @click.stop>
<UiIcon name="chevron" :size="18" />
</RouterLink>
<span class="nav-brand">mento</span>
<ProfileMoreMenu v-model="moreOpen" :items="moreItems" />
</header>
<p v-if="loading" class="muted pad">در حال بارگذاری…</p>
<p v-else-if="error" class="error pad">{{ error }}</p>
<template v-else>
<div class="identity">
<div class="avatar online">
<img src="/brand/user-avatar.png" :alt="name" width="76" height="76" />
</div>
<div class="who">
<h1>{{ name }}</h1>
<p class="sub">{{ roleLabel }} · {{ auth.user?.email }}</p>
<div class="badges">
<span class="pill active">آنلاین</span>
</div>
</div>
</div>
<div class="qa">
<RouterLink class="qa-card" :to="{ name: 'activity-new', query: { type: 'call' } }">
<span class="qa-ico call"><UiIcon name="phone" :size="18" /></span>
<span>تماس</span>
</RouterLink>
<RouterLink class="qa-card" :to="{ name: 'notes' }">
<span class="qa-ico msg"><UiIcon name="chat" :size="18" /></span>
<span>پیام</span>
</RouterLink>
<RouterLink class="qa-card" :to="{ name: 'activity-new', query: { type: 'task' } }">
<span class="qa-ico follow"><UiIcon name="check" :size="18" /></span>
<span>پیگیری</span>
</RouterLink>
<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>{{ smartText }}</p>
</div>
</header>
<AskMentoPanel placeholder="از منتو درباره کار امروز بپرس..." />
<button class="smart-cta" type="button" @click="onSmartCta">
<span>{{ smartCta }}</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"
title="هنوز اطلاعاتی ثبت نشده!"
description="برای شروع، اطلاعات اولیه را تکمیل کنید یا اولین فعالیت خود را اضافه نمایید."
primary-label="افزودن اطلاعات"
:primary-to="{ name: 'profile', query: { tab: 'info' } }"
secondary-label="افزودن اولین فعالیت"
:secondary-to="{ name: 'activity-new' }"
></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="timelineIcon(row.tone)" :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' }"
secondary-label="افزودن معامله"
:secondary-to="{ name: 'deal-new' }"
></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="timelineIcon(row.tone)" :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' }">فعالیت جدید</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' }"
secondary-label="افزودن اولین فعالیت"
:secondary-to="{ name: 'activity-new' }"
></ProfileEmptyState>
<div v-if="!dealsEmpty" class="list-body">
<RouterLink
v-for="deal in myDeals"
: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>
<RouterLink class="ghost-link" :to="{ name: 'deal-new' }">معامله جدید</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' }"
secondary-label="افزودن اولین فعالیت"
:secondary-to="{ name: 'activity-new' }"
></ProfileEmptyState>
<div v-if="!cardsEmpty" class="list-body">
<RouterLink
v-for="card in myCards"
: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' }">کارت جدید</RouterLink>
</div>
</div>
<div v-show="tab === 'info'" class="panel info-panel">
<dl>
<div><dt>نام</dt><dd>{{ name }}</dd></div>
<div><dt>ایمیل</dt><dd>{{ auth.user?.email || '—' }}</dd></div>
<div><dt>نقش</dt><dd>{{ roleLabel }}</dd></div>
</dl>
</div>
</template>
<footer v-if="!loading && !error" class="dock">
<RouterLink :to="{ name: 'activity-new', query: { 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' }">فعالیت</RouterLink>
<RouterLink :to="{ name: 'notes' }">یادداشت</RouterLink>
<RouterLink :to="{ name: 'deal-new' }">معامله</RouterLink>
<RouterLink :to="{ name: 'quote-new' }">پیشنهاد</RouterLink>
</div>
</div>
<RouterLink :to="{ name: 'activity-new', query: { type: 'call' } }">
<UiIcon name="phone" :size="18" />
تماس سریع
</RouterLink>
</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;
}
.identity {
display: flex;
gap: 0.95rem;
align-items: center;
min-width: 0;
}
.avatar {
position: relative;
width: 76px;
height: 76px;
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.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;
}
.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.active {
background: #dcfce7;
color: #166534;
}
.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-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 {
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;
}
.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;
}
.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;
}
.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 {
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
color: #0f766e;
font-weight: 700;
font-size: 0.82rem;
}
.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 {
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 {
font-size: 0.72rem;
gap: 0.2rem;
}
.avatar {
width: 64px;
height: 64px;
}
.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>