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

1574 lines
39 KiB
Vue
Raw Normal View History

<script setup lang="ts">
2026-09-15 14:10:52 +03:30
import { computed, nextTick, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
2026-09-15 14:10:52 +03:30
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,
2026-10-13 18:04:53 +03:30
rewriteGregorianDates,
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[] = [
2026-09-15 14:10:52 +03:30
{ 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' } },
];
2026-09-15 14:10:52 +03:30
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 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 myDeals = computed(() =>
deals.value.filter((d) => d.owner?.id === myId.value && inActiveLensDeal(d)),
);
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 (
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 lastTouchAt = computed(() => {
const stamps = lensActivities.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 = lensActivities.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[]>(() =>
lensActivities.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(() => !lensActivities.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;
2026-09-15 14:10:52 +03:30
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' } });
}
2026-09-15 14:10:52 +03:30
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', desk: 'mine', 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:
'وضعیت کاری امروز من را خلاصه کن: فعالیت‌های عقب‌افتاده، معاملات باز و مهم‌ترین پیگیری بعدی.',
});
2026-10-13 18:04:53 +03:30
aiBrief.value = rewriteGregorianDates(data.answer);
} catch {
aiBrief.value = '';
}
}
watch(() => route.query.tab, readTab, { immediate: true });
watch(() => auth.user?.id, () => void load(), { immediate: true });
2026-09-15 14:10:52 +03:30
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">{{ brand.name }}</span>
2026-09-15 14:10:52 +03:30
<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">
2026-09-15 14:10:52 +03:30
<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>
2026-09-15 14:10:52 +03:30
<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">
2026-09-15 14:10:52 +03:30
<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;
}
2026-09-15 14:10:52 +03:30
.avatar-edit {
position: relative;
width: 76px;
height: 76px;
2026-09-15 14:10:52 +03:30
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;
}
2026-09-15 14:10:52 +03:30
.online-dot {
position: absolute;
2026-09-15 14:10:52 +03:30
bottom: 2px;
inset-inline-start: 2px;
z-index: 2;
width: 16px;
height: 16px;
border-radius: 999px;
background: #22c55e;
border: 3px solid #eef2f6;
2026-09-15 14:10:52 +03:30
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;
2026-09-15 14:10:52 +03:30
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;
}
2026-09-15 14:10:52 +03:30
.info-form {
margin: 0;
background: #fff;
border-radius: var(--radius);
padding: 0.4rem 1rem;
2026-09-15 14:10:52 +03:30
display: grid;
gap: 0;
}
2026-09-15 14:10:52 +03:30
.info-row {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: 0.6rem;
2026-09-15 14:10:52 +03:30
align-items: center;
padding: 0.7rem 0;
border-bottom: 1px solid #f1f5f9;
}
2026-09-15 14:10:52 +03:30
.info-row span {
color: #94a3b8;
font-size: 0.8rem;
}
2026-09-15 14:10:52 +03:30
.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;
}
2026-09-15 14:10:52 +03:30
.avatar-edit,
.avatar {
width: 64px;
height: 64px;
}
2026-09-15 14:10:52 +03:30
.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;
}
2026-09-15 14:10:52 +03:30
.info-row {
grid-template-columns: 1fr;
gap: 0.12rem;
}
h1 {
font-size: 1.22rem;
}
}
</style>