Adjusting the lead note and dashboard header height
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user