Adjusting the lead note and dashboard header height

This commit is contained in:
2026-10-25 20:21:33 +03:30
parent 0daaef0b39
commit bebd1be29b
6 changed files with 272 additions and 6 deletions
+75
View File
@@ -5,6 +5,81 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>منتکت | Montact CRM</title> <title>منتکت | Montact CRM</title>
<link rel="icon" type="image/png" href="/brand/logo.png" /> <link rel="icon" type="image/png" href="/brand/logo.png" />
<style>
/* چیدمان اولیه؛ استایل کامپوننت بعداً همین را دقیق‌تر می‌کند */
.app-frame {
display: grid;
grid-template-columns: 244px minmax(0, 1fr);
min-height: 100vh;
}
.app-frame.collapsed {
grid-template-columns: 80px minmax(0, 1fr);
}
.app-frame > .sidebar {
display: flex;
flex-direction: column;
align-self: start;
box-sizing: border-box;
height: calc(100vh - 28px);
overflow: hidden;
}
.app-frame.collapsed > .sidebar {
width: 64px;
overflow: visible;
}
.app-frame .mobile-only {
display: none;
}
.app-frame .desktop-topbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.home-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 210px;
align-items: start;
}
.home-hero .hero-chips {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.home-hero .popup {
position: absolute;
}
.home-hero .tail {
width: 10px;
height: 14px;
}
@media (max-width: 900px) {
.app-frame,
.app-frame.collapsed {
grid-template-columns: minmax(0, 1fr);
}
.app-frame > .sidebar,
.app-frame .desktop-only {
display: none;
}
.app-frame .mobile-only {
display: flex;
}
.app-frame .mobile-tabbar {
display: grid;
}
.home-hero .hero-chips {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 700px) {
.home-hero {
grid-template-columns: minmax(0, 1fr);
}
.home-hero .hero-bot {
display: none;
}
}
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -20,7 +20,7 @@ withDefaults(
<div v-if="showBubble" class="popup"> <div v-if="showBubble" class="popup">
<span>من دستیار هوشمند</span> <span>من دستیار هوشمند</span>
<span>سیستم هستم 👋</span> <span>سیستم هستم 👋</span>
<svg class="tail" viewBox="0 0 10 14" aria-hidden="true"> <svg class="tail" viewBox="0 0 10 14" width="10" height="14" aria-hidden="true">
<path d="M10 0 L0 7 L10 14 Z" fill="#fff" /> <path d="M10 0 L0 7 L10 14 Z" fill="#fff" />
</svg> </svg>
</div> </div>
@@ -63,7 +63,7 @@ const shortcutLabel = computed(() =>
</script> </script>
<template> <template>
<header class="hero" style="position: relative"> <header class="hero home-hero" style="position: relative">
<div <div
class="hero-surface" class="hero-surface"
aria-hidden="true" aria-hidden="true"
+10 -2
View File
@@ -47,7 +47,15 @@ const showOwnRecordsBanner = computed(
const todayLabel = computed(() => formatJalaliShort(new Date())); const todayLabel = computed(() => formatJalaliShort(new Date()));
const SIDEBAR_KEY = 'mento_sidebar_collapsed'; const SIDEBAR_KEY = 'mento_sidebar_collapsed';
const collapsed = ref(false);
function readSidebarCollapsed() {
try {
return localStorage.getItem(SIDEBAR_KEY) === '1';
} catch {
return false;
}
}
const collapsed = ref(readSidebarCollapsed());
const openHubKey = ref<NavHubKey | null>(null); const openHubKey = ref<NavHubKey | null>(null);
const hubStack = ref<NavHubKey[]>([]); const hubStack = ref<NavHubKey[]>([]);
@@ -472,7 +480,7 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="shell" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }"> <div class="shell app-frame" :class="{ collapsed, 'hub-open': Boolean(openHub), 'more-open': moreOpen }">
<aside id="app-sidebar" class="sidebar" :class="{ collapsed }"> <aside id="app-sidebar" class="sidebar" :class="{ collapsed }">
<div class="brand-row"> <div class="brand-row">
+132
View File
@@ -0,0 +1,132 @@
import { formatJalaliNumeric } from '@/lib/jalali';
export type LeadNoteFile = {
name: string;
mime: string;
dataUrl: string | null;
};
export type LeadNoteRow = {
label: string;
text: string;
file: LeadNoteFile | null;
};
export type LeadNoteView = {
mode: 'prose' | 'answers';
prose: string;
rows: LeadNoteRow[];
};
const UTM_LABELS: Record<string, string> = {
utm_source: 'منبع',
utm_medium: 'رسانه',
utm_campaign: 'کمپین',
utm_term: 'عبارت',
utm_content: 'محتوا',
صفحه: 'صفحه',
ارجاع: 'ارجاع',
};
/** یادداشت لید را اگر جواب فرم باشد به ردیف سؤال/جواب تبدیل می‌کند. */
export function parseLeadFormNotes(raw: string | null | undefined): LeadNoteView {
const text = (raw ?? '').trim();
if (!text) return { mode: 'prose', prose: '', rows: [] };
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const rows: LeadNoteRow[] = [];
const prose: string[] = [];
for (const line of lines) {
const row = parseAnswerLine(line);
if (row) rows.push(row);
else prose.push(line);
}
const answersAreMain = rows.length >= 2 && rows.length >= prose.length;
if (!answersAreMain) return { mode: 'prose', prose: text, rows: [] };
return { mode: 'answers', prose: prose.join('\n'), rows };
}
export function leadNoteFileIsImage(file: LeadNoteFile): boolean {
return file.mime.startsWith('image/') || (file.dataUrl ?? '').startsWith('data:image/');
}
/** فیلد تاریخ تولد روی خود لید هست؛ جواب فرم نباید دوباره ردیف بسازد. */
export function isBirthDateAnswer(label: string): boolean {
return label.trim() === 'تاریخ تولد';
}
function parseAnswerLine(line: string): LeadNoteRow | null {
const splitAt = line.indexOf(':');
if (splitAt <= 0 || splitAt > 200) return null;
const label = line.slice(0, splitAt).trim();
const value = line.slice(splitAt + 1).trim();
if (!label || !value || looksLikeUrl(label)) return null;
if (label === 'UTM') {
return { label: 'اطلاعات ورود', text: humanizeUtm(value), file: null };
}
const file = readFileValue(value);
if (file) return { label, text: file.name, file };
return { label, text: humanizePlain(value), file: null };
}
function looksLikeUrl(label: string): boolean {
return /^https?:\/\//i.test(label);
}
function humanizePlain(value: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const date = new Date(`${value}T12:00:00`);
if (!Number.isNaN(date.getTime())) return formatJalaliNumeric(date);
}
if (value === 'true') return 'بله';
if (value === 'false') return 'خیر';
return value;
}
function humanizeUtm(value: string): string {
return value
.split('|')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const eq = part.indexOf('=');
if (eq <= 0) return part;
const key = part.slice(0, eq).trim();
const val = part.slice(eq + 1).trim();
return `${UTM_LABELS[key] ?? key}: ${val}`;
})
.join('، ');
}
function readFileValue(value: string): LeadNoteFile | null {
if (!value.startsWith('{') && !value.includes('"dataUrl"') && !value.includes('"name"')) {
return null;
}
try {
return asFile(JSON.parse(value));
} catch {
const name = value.match(/"name"\s*:\s*"([^"]+)"/)?.[1]?.trim();
if (name) return { name, mime: '', dataUrl: null };
if (value.includes('data:')) return { name: 'فایل پیوست', mime: '', dataUrl: null };
return null;
}
}
function asFile(value: unknown): LeadNoteFile | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const row = value as { name?: unknown; mime?: unknown; dataUrl?: unknown };
const name = typeof row.name === 'string' ? row.name.trim() : '';
const mime = typeof row.mime === 'string' ? row.mime.trim() : '';
const dataUrl = typeof row.dataUrl === 'string' && row.dataUrl.startsWith('data:') ? row.dataUrl : null;
if (!name && !dataUrl) return null;
return {
name: name || 'فایل پیوست',
mime: mime || (dataUrl?.startsWith('data:image/') ? 'image/' : ''),
dataUrl,
};
}
+53 -2
View File
@@ -15,6 +15,7 @@ import {
Empty as AEmpty, Empty as AEmpty,
Flex as AFlex, Flex as AFlex,
Form as AForm, Form as AForm,
Image as AImage,
FormItem as AFormItem, FormItem as AFormItem,
Input as AInput, Input as AInput,
List as AList, List as AList,
@@ -44,6 +45,7 @@ import {
UserAddOutlined, UserAddOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { isBirthDateAnswer, leadNoteFileIsImage, parseLeadFormNotes } from '@/lib/lead-form-notes';
import { import {
formatClockFa, formatClockFa,
formatJalaliNumeric, formatJalaliNumeric,
@@ -131,6 +133,18 @@ function formatDate(value: string | null | undefined) {
} }
} }
const noteView = computed(() => parseLeadFormNotes(lead.value?.notes));
const formBirthDate = computed(
() => noteView.value.rows.find((row) => isBirthDateAnswer(row.label) && !row.file)?.text ?? '',
);
const shownBirthDate = computed(() => {
if (lead.value?.birthDate) return formatDate(lead.value.birthDate);
return formBirthDate.value || '—';
});
const answerRows = computed(() =>
noteView.value.rows.filter((row) => !isBirthDateAnswer(row.label)),
);
function apiError(e: unknown, fallback: string) { function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message; const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -674,7 +688,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
{{ lead.assigneeName || '—' }} {{ lead.assigneeName || '—' }}
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="تاریخ تولد"> <a-descriptions-item label="تاریخ تولد">
{{ formatDate(lead.birthDate) }} {{ shownBirthDate }}
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="تاریخ ثبت"> <a-descriptions-item label="تاریخ ثبت">
{{ formatDate(lead.createdAt) }} {{ formatDate(lead.createdAt) }}
@@ -695,7 +709,44 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
> >
<div v-for="(addr, i) in lead.addresses" :key="i">{{ addr }}</div> <div v-for="(addr, i) in lead.addresses" :key="i">{{ addr }}</div>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item v-if="lead.notes" label="یادداشت" :span="2"> <template v-if="noteView.mode === 'answers'">
<a-descriptions-item
v-for="(row, index) in answerRows"
:key="`${row.label}-${index}`"
:label="row.label"
:span="2"
:label-style="{ whiteSpace: 'normal' }"
:content-style="{ whiteSpace: 'normal' }"
>
<a-space v-if="row.file" direction="vertical" :size="4">
<a-image
v-if="row.file.dataUrl && leadNoteFileIsImage(row.file)"
:src="row.file.dataUrl"
:alt="row.file.name"
:width="160"
/>
<a-typography-text>{{ row.file.name }}</a-typography-text>
</a-space>
<a-typography-paragraph
v-else
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="row.text"
style="margin-bottom: 0"
/>
</a-descriptions-item>
<a-descriptions-item
v-if="noteView.prose"
label="یادداشت"
:span="2"
>
<a-typography-paragraph
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="noteView.prose"
style="margin-bottom: 0"
/>
</a-descriptions-item>
</template>
<a-descriptions-item v-else-if="lead.notes" label="یادداشت" :span="2">
<a-typography-paragraph <a-typography-paragraph
:ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }" :ellipsis="{ rows: 4, expandable: true, symbol: 'بیشتر' }"
:content="lead.notes" :content="lead.notes"