Files
CRM_Montzar/apps/web/src/views/ProfileView.vue
T
2026-09-17 17:36:21 +03:30

1543 lines
38 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, nextTick, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import type { AskMentoResponse } from '@mento/shared';
import { brand, brandSuggestionFa } 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: 'ویرایش اطلاعات' },
{ 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 infoFullName = ref('');
const infoMobile = ref('');
const infoEmail = ref('');
const infoSaving = ref(false);
const infoError = ref('');
const infoOk = ref('');
const nameInput = ref<HTMLInputElement | null>(null);
const avatarInput = ref<HTMLInputElement | null>(null);
const avatarBusy = ref(false);
const avatarError = ref('');
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 brandSuggestionFa('پیگیری بعدی را ثبت کن');
}
return brandSuggestionFa('امروز با مشتری تماس بگیر');
});
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;
const query = { ...route.query };
if (next === 'summary') delete query.tab;
else query.tab = next;
void router.replace({ query });
if (next === 'info') revealInfoForm();
}
function onMoreSelect(key: string) {
if (key === 'info') setTab('info');
}
function revealInfoForm() {
void nextTick(() => {
nameInput.value?.scrollIntoView({ block: 'center', behavior: 'smooth' });
nameInput.value?.focus();
});
}
function apiErrorMessage(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
function syncInfoForm() {
infoFullName.value = auth.user?.fullName ?? '';
infoMobile.value = auth.user?.mobile ?? '';
infoEmail.value = auth.user?.email ?? '';
infoError.value = '';
infoOk.value = '';
}
async function saveInfo() {
const fullName = infoFullName.value.trim();
const mobile = infoMobile.value.trim();
const email = infoEmail.value.trim();
if (fullName.length < 2) {
infoError.value = 'نام باید حداقل دو حرف باشد';
infoOk.value = '';
return;
}
if (!/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/.test(mobile.replace(/[\s\-()]/g, ''))) {
infoError.value = 'شماره موبایل معتبر نیست';
infoOk.value = '';
return;
}
if (!email.includes('@')) {
infoError.value = 'ایمیل معتبر نیست';
infoOk.value = '';
return;
}
infoSaving.value = true;
infoError.value = '';
infoOk.value = '';
try {
await auth.updateProfile({ fullName, mobile, email });
infoFullName.value = auth.user?.fullName ?? fullName;
infoMobile.value = auth.user?.mobile ?? mobile;
infoEmail.value = auth.user?.email ?? email;
infoOk.value = 'اطلاعات ذخیره شد';
} catch (e) {
infoError.value = apiErrorMessage(e, 'ذخیره ناموفق بود');
} finally {
infoSaving.value = false;
}
}
function closeMenus() {
moreOpen.value = false;
fabOpen.value = false;
}
function onSmartCta() {
closeMenus();
void router.push({ name: 'activity-new', query: { type: 'call' } });
}
function openAvatarPicker() {
if (tab.value !== 'info' || avatarBusy.value) return;
avatarInput.value?.click();
}
async function onAvatarFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
avatarError.value = 'فقط فایل تصویر انتخاب کنید';
return;
}
if (file.size > 2 * 1024 * 1024) {
avatarError.value = 'حجم تصویر باید کمتر از ۲ مگابایت باشد';
return;
}
avatarBusy.value = true;
avatarError.value = '';
try {
await auth.uploadAvatar(file);
} catch (e) {
avatarError.value = apiErrorMessage(e, 'آپلود عکس ناموفق بود');
} finally {
avatarBusy.value = false;
}
}
async function clearAvatar() {
avatarBusy.value = true;
avatarError.value = '';
try {
await auth.removeAvatar();
} catch (e) {
avatarError.value = apiErrorMessage(e, 'حذف عکس ناموفق بود');
} finally {
avatarBusy.value = false;
}
}
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 });
watch(
() => tab.value,
(next) => {
if (next === 'info') syncInfoForm();
},
{ immediate: true },
);
watch(
() =>
[auth.user?.fullName, auth.user?.mobile, auth.user?.email] as const,
() => {
if (tab.value === 'info' && !infoSaving.value) {
infoFullName.value = auth.user?.fullName ?? '';
infoMobile.value = auth.user?.mobile ?? '';
infoEmail.value = auth.user?.email ?? '';
}
},
);
</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" @select="onMoreSelect" />
</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-edit"
:class="{ editable: tab === 'info' }"
@click.stop="openAvatarPicker"
>
<span class="avatar">
<img :src="auth.avatarSrc" :alt="name" width="76" height="76" />
</span>
<i class="online-dot" title="آنلاین" aria-hidden="true" />
<span v-if="tab === 'info'" class="camera-badge" aria-hidden="true">
<UiIcon name="camera" :size="13" />
</span>
<input
ref="avatarInput"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
@change="onAvatarFile"
/>
</div>
<div class="who">
<h1>{{ name }}</h1>
<p class="sub">{{ roleLabel }} · {{ auth.user?.email }}</p>
<div class="badges">
<span class="pill active">آنلاین</span>
</div>
<p v-if="avatarError && tab === 'info'" class="error">{{ avatarError }}</p>
<button
v-if="tab === 'info' && auth.user?.hasAvatar"
class="avatar-clear"
type="button"
:disabled="avatarBusy"
@click.stop="clearAvatar"
>
حذف عکس
</button>
</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" />
خلاصه هوشمند {{ brand.nameFa }}
</h2>
<p>{{ smartText }}</p>
</div>
</header>
<AskMentoPanel :placeholder="`${brand.askLabelFa} درباره کار امروز...`" />
<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">
<form class="info-form" @submit.prevent="saveInfo">
<label class="info-row">
<span>نام</span>
<input
ref="nameInput"
v-model="infoFullName"
type="text"
autocomplete="name"
maxlength="80"
/>
</label>
<label class="info-row">
<span>موبایل</span>
<input
v-model="infoMobile"
type="tel"
inputmode="tel"
dir="ltr"
autocomplete="tel"
maxlength="14"
placeholder="09121234567"
/>
</label>
<label class="info-row">
<span>ایمیل</span>
<input
v-model="infoEmail"
type="email"
dir="ltr"
autocomplete="email"
maxlength="120"
/>
</label>
<div class="info-row">
<span>نقش</span>
<b>{{ roleLabel }}</b>
</div>
<p v-if="infoError" class="error">{{ infoError }}</p>
<p v-else-if="infoOk" class="ok">{{ infoOk }}</p>
<button class="save-btn" type="submit" :disabled="infoSaving">
{{ infoSaving ? 'در حال ذخیره…' : 'ذخیره تغییرات' }}
</button>
</form>
</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 {
width: 100%;
max-width: 100%;
min-width: 0;
margin: 0;
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: 600;
letter-spacing: -0.04em;
color: #0f766e;
font-size: 1.15rem;
}
.icon-btn {
width: 38px;
height: 38px;
border: 0;
background: #fff;
border-radius: var(--radius);
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: var(--radius);
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: var(--radius);
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-edit {
position: relative;
width: 76px;
height: 76px;
flex-shrink: 0;
}
.avatar-edit.editable {
cursor: pointer;
}
.avatar {
display: block;
width: 76px;
height: 76px;
border-radius: 999px;
overflow: hidden;
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.online-dot {
position: absolute;
bottom: 2px;
inset-inline-start: 2px;
z-index: 2;
width: 16px;
height: 16px;
border-radius: 999px;
background: #22c55e;
border: 3px solid #eef2f6;
box-sizing: border-box;
pointer-events: none;
}
.camera-badge {
position: absolute;
inset-inline-end: 0;
bottom: 0;
z-index: 3;
width: 26px;
height: 26px;
border-radius: 999px;
background: #0f766e;
color: #fff;
display: grid;
place-items: center;
box-shadow: 0 0 0 3px #eef2f6;
pointer-events: none;
}
.avatar-clear {
margin: 0.15rem 0 0;
border: 0;
background: none;
padding: 0;
color: #64748b;
font: inherit;
font-size: 0.78rem;
cursor: pointer;
}
.avatar-clear:hover {
color: #b91c1c;
}
.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: var(--radius);
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: var(--radius);
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: var(--radius);
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: 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);
}
.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: var(--radius);
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: var(--radius);
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: 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-form {
margin: 0;
background: #fff;
border-radius: var(--radius);
padding: 0.4rem 1rem;
display: grid;
gap: 0;
}
.info-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.6rem;
align-items: center;
padding: 0.7rem 0;
border-bottom: 1px solid #f1f5f9;
}
.info-row span {
color: #94a3b8;
font-size: 0.8rem;
}
.info-row b {
font-weight: 600;
}
.info-row input {
width: 100%;
min-height: 2.5rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 0.4rem 0.7rem;
font: inherit;
background: #fff;
}
.info-form .error,
.info-form .ok {
margin: 0.75rem 0 0;
}
.ok {
color: #0f766e;
font-weight: 600;
}
.save-btn {
margin-top: 0.9rem;
justify-self: start;
min-height: 2.6rem;
padding: 0 1.1rem;
border: 0;
border-radius: 12px;
background: #14b8a6;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.save-btn:disabled {
opacity: 0.65;
cursor: wait;
}
.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: var(--radius);
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 + var(--mobile-tabbar-h));
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-edit,
.avatar {
width: 64px;
height: 64px;
}
.online-dot {
width: 14px;
height: 14px;
border-width: 2px;
}
.qa {
gap: 0.45rem;
}
.qa-card {
padding: 0.7rem 0.2rem 0.6rem;
font-size: 0.72rem;
border-radius: var(--radius);
}
.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-row {
grid-template-columns: 1fr;
gap: 0.12rem;
}
h1 {
font-size: 1.22rem;
}
}
</style>