3000 lines
75 KiB
Vue
3000 lines
75 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import {
|
||
formatRelativeContact,
|
||
toFaDigits,
|
||
} from '@/lib/jalali';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import { canManageSettings } from '@/lib/roles';
|
||
import {
|
||
UiButton,
|
||
UiDropdownMenu,
|
||
UiEmptyState,
|
||
UiModal,
|
||
UiPageHeader,
|
||
} from '@/components/ui';
|
||
import UiIcon from '@/components/UiIcon.vue';
|
||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
||
import {
|
||
filterPhoneContacts,
|
||
isContactPickerSupported,
|
||
parseVCardFile,
|
||
pickPhoneContacts,
|
||
sortPhoneContacts,
|
||
type PhoneContactDraft,
|
||
} from '@/lib/phone-contacts';
|
||
import type {
|
||
LeadDto,
|
||
LeadImportJobDto,
|
||
LeadStageDto,
|
||
LeadStatsDto,
|
||
OrgUserDto,
|
||
PaginatedResponse,
|
||
} from '@mento/shared';
|
||
import { brand } from '@mento/shared';
|
||
|
||
const PAGE_SIZE = 10;
|
||
|
||
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
|
||
type StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal';
|
||
type RowIcon = 'building' | 'bag' | 'briefcase' | 'users' | 'target';
|
||
|
||
/** ردیف آمار صفحه لیدها — ترتیب و نامها مطابق طرح */
|
||
const LEAD_STAT_DEFS: Array<{
|
||
label: string;
|
||
/** نام وضعیتهای متناظر در دیتابیس */
|
||
match: string[];
|
||
tint: StageTint;
|
||
slot: 'pair' | 'solo';
|
||
}> = [
|
||
{
|
||
label: 'جدید',
|
||
match: ['جدید'],
|
||
tint: 'amber',
|
||
slot: 'pair',
|
||
},
|
||
{
|
||
label: 'در حال پیگیری',
|
||
match: ['در حال پیگیری', 'پیگیری مجدد', 'تماس اولیه', 'تماس موفق'],
|
||
tint: 'peach',
|
||
slot: 'pair',
|
||
},
|
||
{
|
||
label: 'مذاکره',
|
||
match: ['مذاکره'],
|
||
tint: 'mint',
|
||
slot: 'solo',
|
||
},
|
||
{
|
||
label: 'آماده خرید',
|
||
match: ['آماده خرید'],
|
||
tint: 'violet',
|
||
slot: 'solo',
|
||
},
|
||
{
|
||
label: 'عدم پاسخ',
|
||
match: ['عدم پاسخ'],
|
||
tint: 'rose',
|
||
slot: 'solo',
|
||
},
|
||
];
|
||
|
||
type LeadStatCard = {
|
||
key: string;
|
||
label: string;
|
||
tint: StageTint;
|
||
count: number;
|
||
/** مرحله اصلی برای فیلتر */
|
||
stageId: string;
|
||
slot: 'pair' | 'solo';
|
||
};
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const auth = useAuthStore();
|
||
const isAdmin = computed(() => canManageSettings(auth.user?.role));
|
||
|
||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||
const loading = ref(false);
|
||
const busy = ref(false);
|
||
const error = ref('');
|
||
const okMsg = ref('');
|
||
const data = ref<PaginatedResponse<LeadDto> | null>(null);
|
||
const stages = ref<LeadStageDto[]>([]);
|
||
const stats = ref<LeadStatsDto | null>(null);
|
||
const users = ref<OrgUserDto[]>([]);
|
||
const cityOptions = ref<string[]>([]);
|
||
const sourceOptions = ref<string[]>([]);
|
||
const selected = ref<Set<string>>(new Set());
|
||
const sortOpen = ref(false);
|
||
const rowMenuId = ref<string | null>(null);
|
||
const logoMenuId = ref<string | null>(null);
|
||
const logoUrls = ref<Record<string, string>>({});
|
||
const logoBusyId = ref<string | null>(null);
|
||
const logoFileInput = ref<HTMLInputElement | null>(null);
|
||
const logoTargetId = ref<string | null>(null);
|
||
const assignOpen = ref(false);
|
||
const stageOpen = ref(false);
|
||
const assignUserId = ref('');
|
||
const bulkStageId = ref('');
|
||
const importJob = ref<LeadImportJobDto | null>(null);
|
||
const createOpen = ref(false);
|
||
const createBusy = ref(false);
|
||
const createError = ref('');
|
||
const editingLeadId = ref<string | null>(null);
|
||
const createForm = ref({
|
||
title: '',
|
||
contactName: '',
|
||
mobile: '',
|
||
city: '',
|
||
region: '',
|
||
source: '',
|
||
stageId: '',
|
||
assigneeId: '',
|
||
});
|
||
const contactPickerSupported = isContactPickerSupported();
|
||
const pickingContact = ref(false);
|
||
const contactFileInput = ref<HTMLInputElement | null>(null);
|
||
const sourceOpen = ref(false);
|
||
const pickOpen = ref(false);
|
||
const pickDrafts = ref<PhoneContactDraft[]>([]);
|
||
const pickQuery = ref('');
|
||
let importTimer: ReturnType<typeof setInterval> | null = null;
|
||
|
||
const visiblePickDrafts = computed(() =>
|
||
filterPhoneContacts(pickDrafts.value, pickQuery.value),
|
||
);
|
||
|
||
const page = computed(() => {
|
||
const raw = Number(route.query.page);
|
||
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
||
});
|
||
const sort = computed<SortKey>(() => {
|
||
const s = route.query.sort;
|
||
if (s === 'newest' || s === 'follow_up' || s === 'last_contact') return s;
|
||
return 'updated';
|
||
});
|
||
const stageId = computed(() =>
|
||
typeof route.query.stageId === 'string' ? route.query.stageId : '',
|
||
);
|
||
const assigneeId = computed(() =>
|
||
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
|
||
);
|
||
const cityFilter = computed(() =>
|
||
typeof route.query.city === 'string' ? route.query.city : '',
|
||
);
|
||
const sourceFilter = computed(() =>
|
||
typeof route.query.source === 'string' ? route.query.source : '',
|
||
);
|
||
const unassigned = computed(
|
||
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
|
||
);
|
||
|
||
const total = computed(() => data.value?.total ?? 0);
|
||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||
const rangeFrom = computed(() =>
|
||
total.value === 0 ? 0 : (page.value - 1) * PAGE_SIZE + 1,
|
||
);
|
||
const rangeTo = computed(() =>
|
||
Math.min(page.value * PAGE_SIZE, total.value),
|
||
);
|
||
const allSelected = computed(
|
||
() =>
|
||
Boolean(data.value?.items.length) &&
|
||
data.value!.items.every((l) => selected.value.has(l.id)),
|
||
);
|
||
const selectedIds = computed(() => [...selected.value]);
|
||
|
||
const sortLabels: Record<SortKey, string> = {
|
||
newest: 'تاریخ جدیدترین',
|
||
updated: 'آخرین بهروزرسانی',
|
||
follow_up: 'پیگیری بعدی',
|
||
last_contact: 'آخرین تماس',
|
||
};
|
||
|
||
const stageCards = computed((): LeadStatCard[] => {
|
||
return LEAD_STAT_DEFS.map((def) => {
|
||
const matched = stages.value.filter((s) =>
|
||
def.match.some((name) => s.name.trim() === name),
|
||
);
|
||
const count = matched.reduce((sum, s) => sum + (s.leadCount ?? 0), 0);
|
||
const primary =
|
||
matched.find((s) => s.name.trim() === def.match[0]) ?? matched[0];
|
||
return {
|
||
key: def.label,
|
||
label: def.label,
|
||
tint: def.tint,
|
||
count,
|
||
stageId: primary?.id ?? '',
|
||
slot: def.slot,
|
||
};
|
||
});
|
||
});
|
||
|
||
const pairStatCards = computed(() => stageCards.value.filter((c) => c.slot === 'pair'));
|
||
const soloStatCards = computed(() => stageCards.value.filter((c) => c.slot === 'solo'));
|
||
|
||
const totalLeadsCount = computed(() => {
|
||
if (stats.value?.total != null) return stats.value.total;
|
||
return stages.value.reduce((sum, s) => sum + (s.leadCount ?? 0), 0) || total.value;
|
||
});
|
||
|
||
function isStatActive(card: LeadStatCard) {
|
||
if (!card.stageId || !stageId.value) return false;
|
||
if (stageId.value === card.stageId) return true;
|
||
const def = LEAD_STAT_DEFS.find((d) => d.label === card.label);
|
||
if (!def) return false;
|
||
const matchedIds = stages.value
|
||
.filter((s) => def.match.some((name) => s.name.trim() === name))
|
||
.map((s) => s.id);
|
||
return matchedIds.includes(stageId.value);
|
||
}
|
||
|
||
function selectStatCard(card: LeadStatCard) {
|
||
if (!card.stageId) {
|
||
setStageFilter('');
|
||
return;
|
||
}
|
||
setStageFilter(isStatActive(card) ? '' : card.stageId);
|
||
}
|
||
|
||
const pageNumbers = computed(() => {
|
||
const totalPages = pageCount.value;
|
||
const current = page.value;
|
||
if (totalPages <= 5) {
|
||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||
}
|
||
let start = Math.max(1, current - 2);
|
||
let end = start + 4;
|
||
if (end > totalPages) {
|
||
end = totalPages;
|
||
start = Math.max(1, end - 4);
|
||
}
|
||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||
});
|
||
|
||
function stageTint(color: string | null | undefined): StageTint {
|
||
const c = (color || '').toLowerCase();
|
||
if (c === 'amber' || c === 'yellow' || c === 'orange') return 'amber';
|
||
if (c === 'peach' || c === 'orange') return 'peach';
|
||
if (c === 'sky' || c === 'blue') return 'sky';
|
||
if (c === 'violet' || c === 'purple' || c === 'lavender') return 'violet';
|
||
if (c === 'rose' || c === 'red' || c === 'pink') return 'rose';
|
||
if (c === 'teal' || c === 'cyan') return 'teal';
|
||
if (c === 'mint' || c === 'green') return 'mint';
|
||
if (c.startsWith('#')) {
|
||
const hex = c.slice(1);
|
||
const n = parseInt(hex.length === 3 ? hex.replace(/(.)/g, '$1$1') : hex, 16);
|
||
if (!Number.isFinite(n)) return 'mint';
|
||
const r = (n >> 16) & 255;
|
||
const g = (n >> 8) & 255;
|
||
const b = n & 255;
|
||
if (r > g && r > b) return 'rose';
|
||
if (b > r && b > g) return 'sky';
|
||
if (g > r && r > b) return 'amber';
|
||
return 'mint';
|
||
}
|
||
return 'mint';
|
||
}
|
||
|
||
function stageTintForLead(lead: LeadDto): StageTint {
|
||
const stage = stages.value.find((s) => s.id === lead.stageId);
|
||
return stageTint(stage?.color);
|
||
}
|
||
|
||
function rowIcon(lead: LeadDto): RowIcon {
|
||
const key = `${lead.trade || ''}${lead.source || ''}${lead.title}`;
|
||
let hash = 0;
|
||
for (let i = 0; i < key.length; i++) hash = (hash + key.charCodeAt(i) * (i + 1)) % 5;
|
||
return (['building', 'bag', 'briefcase', 'users', 'target'] as const)[hash]!;
|
||
}
|
||
|
||
function initials(name: string | null | undefined) {
|
||
if (!name?.trim()) return '؟';
|
||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||
if (parts.length === 1) return parts[0]!.slice(0, 1);
|
||
return `${parts[0]!.slice(0, 1)}${parts[parts.length - 1]!.slice(0, 1)}`;
|
||
}
|
||
|
||
function formatPhone(raw: string | null | undefined) {
|
||
if (!raw) return '—';
|
||
const digits = raw.replace(/\D/g, '');
|
||
if (digits.length === 11 && digits.startsWith('09')) {
|
||
return toFaDigits(`${digits.slice(0, 4)} ${digits.slice(4, 7)} ${digits.slice(7)}`);
|
||
}
|
||
return toFaDigits(raw);
|
||
}
|
||
|
||
function formatCount(n: number) {
|
||
return toFaDigits(n.toLocaleString('en-US'));
|
||
}
|
||
|
||
function lastActivity(lead: LeadDto) {
|
||
if (lead.lastContactAt) {
|
||
return {
|
||
label: 'تماس تلفنی',
|
||
when: formatRelativeContact(lead.lastContactAt),
|
||
icon: 'phone' as const,
|
||
};
|
||
}
|
||
if (lead.nextFollowUpAt) {
|
||
return {
|
||
label: 'پیگیری بعدی',
|
||
when: formatRelativeContact(lead.nextFollowUpAt),
|
||
icon: 'calendar' as const,
|
||
};
|
||
}
|
||
return {
|
||
label: 'ثبت لید',
|
||
when: formatRelativeContact(lead.createdAt),
|
||
icon: 'document' as const,
|
||
};
|
||
}
|
||
|
||
function closePopups() {
|
||
sortOpen.value = false;
|
||
rowMenuId.value = null;
|
||
logoMenuId.value = null;
|
||
assignOpen.value = false;
|
||
stageOpen.value = false;
|
||
}
|
||
|
||
function revokeLogoUrls(except?: Set<string>) {
|
||
const next: Record<string, string> = {};
|
||
for (const [id, url] of Object.entries(logoUrls.value)) {
|
||
if (except?.has(id)) {
|
||
next[id] = url;
|
||
continue;
|
||
}
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
logoUrls.value = next;
|
||
}
|
||
|
||
async function loadLogos(items: LeadDto[]) {
|
||
const keep = new Set(items.filter((l) => l.hasLogo).map((l) => l.id));
|
||
revokeLogoUrls(keep);
|
||
const pending = items.filter((l) => l.hasLogo && !logoUrls.value[l.id]);
|
||
await Promise.all(
|
||
pending.map(async (lead) => {
|
||
try {
|
||
const { data } = await api.get<Blob>(`/leads/${lead.id}/logo`, {
|
||
responseType: 'blob',
|
||
});
|
||
const url = URL.createObjectURL(data);
|
||
logoUrls.value = { ...logoUrls.value, [lead.id]: url };
|
||
} catch {
|
||
/* بدون لوگو */
|
||
}
|
||
}),
|
||
);
|
||
}
|
||
|
||
function openLogoPicker(leadId: string) {
|
||
logoMenuId.value = null;
|
||
logoTargetId.value = leadId;
|
||
logoFileInput.value?.click();
|
||
}
|
||
|
||
async function onLogoFile(ev: Event) {
|
||
const input = ev.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
const leadId = logoTargetId.value;
|
||
input.value = '';
|
||
logoTargetId.value = null;
|
||
if (!file || !leadId) return;
|
||
logoBusyId.value = leadId;
|
||
error.value = '';
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const { data: updated } = await api.post<LeadDto>(`/leads/${leadId}/logo`, form, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
});
|
||
if (data.value) {
|
||
data.value = {
|
||
...data.value,
|
||
items: data.value.items.map((l) => (l.id === leadId ? { ...l, hasLogo: true } : l)),
|
||
};
|
||
}
|
||
const prev = logoUrls.value[leadId];
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
const { data: blob } = await api.get<Blob>(`/leads/${leadId}/logo`, {
|
||
responseType: 'blob',
|
||
});
|
||
logoUrls.value = { ...logoUrls.value, [leadId]: URL.createObjectURL(blob) };
|
||
okMsg.value = `لوگوی «${updated.title}» ذخیره شد.`;
|
||
} catch (e) {
|
||
error.value = axios.isAxiosError(e)
|
||
? apiErrorMessage(e, 'آپلود لوگو ناموفق بود.')
|
||
: 'آپلود لوگو ناموفق بود.';
|
||
} finally {
|
||
logoBusyId.value = null;
|
||
}
|
||
}
|
||
|
||
function apiErrorMessage(e: unknown, fallback: string) {
|
||
if (axios.isAxiosError(e)) {
|
||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
async function removeLeadLogo(leadId: string) {
|
||
logoMenuId.value = null;
|
||
if (!confirm('لوگوی این لید حذف شود؟')) return;
|
||
logoBusyId.value = leadId;
|
||
error.value = '';
|
||
try {
|
||
await api.delete(`/leads/${leadId}/logo`);
|
||
const prev = logoUrls.value[leadId];
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
const next = { ...logoUrls.value };
|
||
delete next[leadId];
|
||
logoUrls.value = next;
|
||
if (data.value) {
|
||
data.value = {
|
||
...data.value,
|
||
items: data.value.items.map((l) => (l.id === leadId ? { ...l, hasLogo: false } : l)),
|
||
};
|
||
}
|
||
okMsg.value = 'لوگو حذف شد.';
|
||
} catch {
|
||
error.value = 'حذف لوگو ناموفق بود.';
|
||
} finally {
|
||
logoBusyId.value = null;
|
||
}
|
||
}
|
||
|
||
function patchQuery(next: Record<string, string | undefined>) {
|
||
const query = { ...route.query, ...next };
|
||
for (const key of Object.keys(query)) {
|
||
if (query[key] == null || query[key] === '') delete query[key];
|
||
}
|
||
void router.replace({ name: 'leads', query });
|
||
}
|
||
|
||
function onSearch() {
|
||
patchQuery({ q: q.value.trim() || undefined, page: undefined });
|
||
}
|
||
|
||
function setSort(next: SortKey) {
|
||
sortOpen.value = false;
|
||
patchQuery({ sort: next === 'updated' ? undefined : next, page: undefined });
|
||
}
|
||
|
||
function setStageFilter(id: string) {
|
||
patchQuery({ stageId: id || undefined, page: undefined });
|
||
}
|
||
|
||
function setCityFilter(value: string) {
|
||
patchQuery({ city: value || undefined, page: undefined });
|
||
}
|
||
|
||
function setSourceFilter(value: string) {
|
||
patchQuery({ source: value || undefined, page: undefined });
|
||
}
|
||
|
||
function setPage(next: number) {
|
||
if (next < 1 || next > pageCount.value) return;
|
||
patchQuery({ page: next === 1 ? undefined : String(next) });
|
||
}
|
||
|
||
function toggleOne(id: string, checked: boolean) {
|
||
const next = new Set(selected.value);
|
||
if (checked) next.add(id);
|
||
else next.delete(id);
|
||
selected.value = next;
|
||
}
|
||
|
||
function toggleAll(checked: boolean) {
|
||
if (!checked) {
|
||
selected.value = new Set();
|
||
return;
|
||
}
|
||
selected.value = new Set(data.value?.items.map((l) => l.id) ?? []);
|
||
}
|
||
|
||
const LONG_PRESS_MS = 450;
|
||
const LONG_PRESS_MOVE_PX = 12;
|
||
let cardPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let cardPressId: string | null = null;
|
||
let cardPressTriggered = false;
|
||
let cardPressX = 0;
|
||
let cardPressY = 0;
|
||
|
||
function clearCardPress() {
|
||
if (cardPressTimer) clearTimeout(cardPressTimer);
|
||
cardPressTimer = null;
|
||
cardPressId = null;
|
||
}
|
||
|
||
function onCardPointerDown(leadId: string, event: PointerEvent) {
|
||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||
clearCardPress();
|
||
cardPressTriggered = false;
|
||
cardPressId = leadId;
|
||
cardPressX = event.clientX;
|
||
cardPressY = event.clientY;
|
||
cardPressTimer = setTimeout(() => {
|
||
cardPressTriggered = true;
|
||
cardPressTimer = null;
|
||
toggleOne(leadId, !selected.value.has(leadId));
|
||
try {
|
||
navigator.vibrate?.(18);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, LONG_PRESS_MS);
|
||
}
|
||
|
||
function onCardPointerMove(event: PointerEvent) {
|
||
if (!cardPressTimer || !cardPressId) return;
|
||
const dx = event.clientX - cardPressX;
|
||
const dy = event.clientY - cardPressY;
|
||
if (dx * dx + dy * dy > LONG_PRESS_MOVE_PX * LONG_PRESS_MOVE_PX) {
|
||
clearCardPress();
|
||
}
|
||
}
|
||
|
||
function onCardPointerEnd() {
|
||
clearCardPress();
|
||
}
|
||
|
||
function onCardClick(lead: LeadDto) {
|
||
if (cardPressTriggered) {
|
||
cardPressTriggered = false;
|
||
return;
|
||
}
|
||
if (selected.value.has(lead.id)) {
|
||
toggleOne(lead.id, false);
|
||
return;
|
||
}
|
||
goDetail(lead.id);
|
||
}
|
||
|
||
function mergeFacets(items: LeadDto[]) {
|
||
const cities = new Set(cityOptions.value);
|
||
const sources = new Set(sourceOptions.value);
|
||
for (const lead of items) {
|
||
if (lead.city?.trim()) cities.add(lead.city.trim());
|
||
if (lead.source?.trim()) sources.add(lead.source.trim());
|
||
}
|
||
cityOptions.value = [...cities].sort((a, b) => a.localeCompare(b, 'fa'));
|
||
sourceOptions.value = [...sources].sort((a, b) => a.localeCompare(b, 'fa'));
|
||
}
|
||
|
||
async function loadMeta() {
|
||
const [stagesRes, usersRes, statsRes] = await Promise.all([
|
||
api.get<LeadStageDto[]>('/leads/stages'),
|
||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||
api.get<LeadStatsDto>('/leads/stats').catch(() => ({ data: null })),
|
||
]);
|
||
stages.value = stagesRes.data;
|
||
users.value = usersRes.data.filter((u) => u.isActive !== false);
|
||
stats.value = statsRes.data;
|
||
if (!createForm.value.stageId && stages.value[0]) {
|
||
createForm.value.stageId = stages.value[0].id;
|
||
}
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
closePopups();
|
||
try {
|
||
const { data: res } = await api.get<PaginatedResponse<LeadDto>>('/leads', {
|
||
params: {
|
||
q: q.value || undefined,
|
||
page: page.value,
|
||
limit: PAGE_SIZE,
|
||
sort: sort.value === 'updated' ? undefined : sort.value,
|
||
stageId: stageId.value || undefined,
|
||
assigneeId: assigneeId.value || undefined,
|
||
city: cityFilter.value || undefined,
|
||
source: sourceFilter.value || undefined,
|
||
unassigned: unassigned.value ? '1' : undefined,
|
||
},
|
||
});
|
||
data.value = res;
|
||
mergeFacets(res.items);
|
||
selected.value = new Set(
|
||
[...selected.value].filter((id) => res.items.some((l) => l.id === id)),
|
||
);
|
||
void loadLogos(res.items);
|
||
const { data: nextStats } = await api
|
||
.get<LeadStatsDto>('/leads/stats')
|
||
.catch(() => ({ data: stats.value }));
|
||
if (nextStats) stats.value = nextStats;
|
||
const { data: nextStages } = await api.get<LeadStageDto[]>('/leads/stages');
|
||
stages.value = nextStages;
|
||
} catch (e) {
|
||
error.value =
|
||
axios.isAxiosError(e) && !e.response
|
||
? 'اتصال به API برقرار نشد.'
|
||
: 'بارگذاری لیدها ناموفق بود.';
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
const createModalTitle = computed(() =>
|
||
editingLeadId.value ? 'ویرایش لید' : 'ثبت لید جدید',
|
||
);
|
||
|
||
function openCreate() {
|
||
editingLeadId.value = null;
|
||
createError.value = '';
|
||
createForm.value = {
|
||
title: '',
|
||
contactName: '',
|
||
mobile: '',
|
||
city: '',
|
||
region: '',
|
||
source: '',
|
||
stageId: stages.value[0]?.id ?? '',
|
||
assigneeId: '',
|
||
};
|
||
closePick();
|
||
closeSource();
|
||
createOpen.value = true;
|
||
}
|
||
|
||
function openEdit(lead: LeadDto) {
|
||
rowMenuId.value = null;
|
||
editingLeadId.value = lead.id;
|
||
createError.value = '';
|
||
createForm.value = {
|
||
title: lead.title,
|
||
contactName: lead.contactName ?? '',
|
||
mobile: lead.mobile,
|
||
city: lead.city ?? '',
|
||
region: lead.region ?? '',
|
||
source: lead.source ?? '',
|
||
stageId: lead.stageId,
|
||
assigneeId: lead.assigneeId ?? '',
|
||
};
|
||
closePick();
|
||
closeSource();
|
||
createOpen.value = true;
|
||
}
|
||
|
||
async function deleteLead(lead: LeadDto) {
|
||
rowMenuId.value = null;
|
||
if (!confirm(`لید «${lead.title}» حذف (بایگانی) شود؟`)) return;
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.post('/leads/archive', { leadIds: [lead.id] });
|
||
okMsg.value = 'لید حذف شد.';
|
||
selected.value = new Set([...selected.value].filter((id) => id !== lead.id));
|
||
await load();
|
||
} catch {
|
||
error.value = 'حذف لید ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
function applyContactDraft(draft: PhoneContactDraft) {
|
||
if (draft.mobile) {
|
||
createForm.value.mobile = draft.mobile;
|
||
} else if (draft.phone && !createForm.value.mobile.trim()) {
|
||
createForm.value.mobile = draft.phone;
|
||
}
|
||
if (!createForm.value.contactName.trim() && draft.fullName) {
|
||
createForm.value.contactName = draft.fullName;
|
||
}
|
||
if (!createForm.value.title.trim() && draft.fullName) {
|
||
createForm.value.title = draft.fullName;
|
||
}
|
||
if (!createForm.value.source.trim()) {
|
||
createForm.value.source = 'مخاطبین گوشی';
|
||
}
|
||
}
|
||
|
||
function draftLine(row: PhoneContactDraft) {
|
||
return [row.mobile, row.phone, row.email].filter(Boolean).join(' · ') || 'بدون شماره/ایمیل';
|
||
}
|
||
|
||
function closePick() {
|
||
pickOpen.value = false;
|
||
pickDrafts.value = [];
|
||
pickQuery.value = '';
|
||
}
|
||
|
||
function closeSource() {
|
||
sourceOpen.value = false;
|
||
}
|
||
|
||
function choosePick(row: PhoneContactDraft) {
|
||
applyContactDraft(row);
|
||
closePick();
|
||
}
|
||
|
||
async function openPickList(rows: PhoneContactDraft[]) {
|
||
pickDrafts.value = sortPhoneContacts(rows);
|
||
pickQuery.value = '';
|
||
sourceOpen.value = false;
|
||
pickOpen.value = false;
|
||
await nextTick();
|
||
pickOpen.value = true;
|
||
}
|
||
|
||
function fillFromPhoneContact() {
|
||
if (pickingContact.value || createBusy.value) return;
|
||
createError.value = '';
|
||
if (contactPickerSupported) {
|
||
sourceOpen.value = true;
|
||
return;
|
||
}
|
||
contactFileInput.value?.click();
|
||
}
|
||
|
||
async function pickFromDevice() {
|
||
if (pickingContact.value || createBusy.value) return;
|
||
pickingContact.value = true;
|
||
createError.value = '';
|
||
sourceOpen.value = false;
|
||
try {
|
||
const rows = await pickPhoneContacts({ multiple: true });
|
||
if (!rows.length) {
|
||
createError.value = 'مخاطبی انتخاب نشد.';
|
||
return;
|
||
}
|
||
await openPickList(rows);
|
||
} catch (e) {
|
||
const name = e instanceof DOMException ? e.name : '';
|
||
const message = e instanceof Error ? e.message : '';
|
||
if (name === 'AbortError') return;
|
||
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
|
||
contactFileInput.value?.click();
|
||
return;
|
||
}
|
||
createError.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
|
||
} finally {
|
||
pickingContact.value = false;
|
||
}
|
||
}
|
||
|
||
function pickFromVcf() {
|
||
sourceOpen.value = false;
|
||
contactFileInput.value?.click();
|
||
}
|
||
|
||
async function onContactVcfFile(ev: Event) {
|
||
const el = ev.target as HTMLInputElement;
|
||
const file = el.files?.[0];
|
||
el.value = '';
|
||
if (!file) return;
|
||
pickingContact.value = true;
|
||
createError.value = '';
|
||
try {
|
||
const rows = await parseVCardFile(file);
|
||
if (!rows.length) {
|
||
createError.value = 'مخاطبی در فایل پیدا نشد.';
|
||
return;
|
||
}
|
||
await openPickList(rows);
|
||
} catch {
|
||
createError.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
|
||
} finally {
|
||
pickingContact.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitCreate() {
|
||
createError.value = '';
|
||
if (!createForm.value.title.trim() || !createForm.value.mobile.trim()) {
|
||
createError.value = 'نام/شرکت و موبایل الزامی است.';
|
||
return;
|
||
}
|
||
createBusy.value = true;
|
||
const payload = {
|
||
title: createForm.value.title.trim(),
|
||
mobile: createForm.value.mobile.trim(),
|
||
contactName: createForm.value.contactName.trim() || null,
|
||
city: createForm.value.city.trim() || null,
|
||
region: createForm.value.region.trim() || null,
|
||
source: createForm.value.source.trim() || null,
|
||
stageId: createForm.value.stageId || undefined,
|
||
assigneeId: createForm.value.assigneeId || null,
|
||
};
|
||
try {
|
||
if (editingLeadId.value) {
|
||
await api.patch(`/leads/${editingLeadId.value}`, payload);
|
||
createOpen.value = false;
|
||
okMsg.value = 'لید بهروز شد.';
|
||
await load();
|
||
} else {
|
||
const { data: created } = await api.post<LeadDto>('/leads', payload);
|
||
createOpen.value = false;
|
||
okMsg.value = 'لید جدید ثبت شد.';
|
||
await load();
|
||
void router.push({ name: 'lead-detail', params: { id: created.id } });
|
||
}
|
||
} catch (e) {
|
||
createError.value =
|
||
axios.isAxiosError(e) && (e.response?.data as { message?: string })?.message
|
||
? String((e.response?.data as { message?: string }).message)
|
||
: editingLeadId.value
|
||
? 'ویرایش لید ناموفق بود.'
|
||
: 'ثبت لید ناموفق بود.';
|
||
} finally {
|
||
createBusy.value = false;
|
||
}
|
||
}
|
||
|
||
async function assignSelected() {
|
||
if (!assignUserId.value || !selectedIds.value.length) return;
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.post('/leads/assign', {
|
||
leadIds: selectedIds.value,
|
||
assigneeId: assignUserId.value,
|
||
});
|
||
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید تخصیص داده شد.`;
|
||
selected.value = new Set();
|
||
assignOpen.value = false;
|
||
await load();
|
||
} catch {
|
||
error.value = 'تخصیص ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
async function bulkStageSelected() {
|
||
if (!bulkStageId.value || !selectedIds.value.length) return;
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.post('/leads/bulk-stage', {
|
||
leadIds: selectedIds.value,
|
||
stageId: bulkStageId.value,
|
||
});
|
||
okMsg.value = `وضعیت ${toFaDigits(selectedIds.value.length)} لید بهروز شد.`;
|
||
selected.value = new Set();
|
||
stageOpen.value = false;
|
||
await load();
|
||
} catch {
|
||
error.value = 'تغییر وضعیت گروهی ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
async function archiveSelected() {
|
||
if (!selectedIds.value.length) return;
|
||
if (!confirm(`${toFaDigits(selectedIds.value.length)} لید بایگانی شود؟`)) return;
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
await api.post('/leads/archive', { leadIds: selectedIds.value });
|
||
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید بایگانی شد.`;
|
||
selected.value = new Set();
|
||
await load();
|
||
} catch {
|
||
error.value = 'بایگانی ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
async function exportExcel() {
|
||
busy.value = true;
|
||
error.value = '';
|
||
try {
|
||
const { data: blob } = await api.get<Blob>('/leads/export', {
|
||
responseType: 'blob',
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${brand.filePrefix}-leads.xlsx`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch {
|
||
error.value = 'خروجی اکسل ناموفق بود.';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
function stopImportPoll() {
|
||
if (importTimer) {
|
||
clearInterval(importTimer);
|
||
importTimer = null;
|
||
}
|
||
}
|
||
|
||
async function pollImport(jobId: string) {
|
||
try {
|
||
const { data: job } = await api.get<LeadImportJobDto>(`/leads/imports/${jobId}`);
|
||
importJob.value = job;
|
||
if (job.status === 'done' || job.status === 'failed' || job.status === 'completed') {
|
||
stopImportPoll();
|
||
okMsg.value = `ورود: موفق ${toFaDigits(job.successCount)} · تکراری ${toFaDigits(job.duplicateCount)} · خطا ${toFaDigits(job.errorCount)}`;
|
||
await load();
|
||
}
|
||
} catch {
|
||
stopImportPoll();
|
||
error.value = 'پیگیری وضعیت ورود ناموفق بود.';
|
||
}
|
||
}
|
||
|
||
function startImportPoll(jobId: string) {
|
||
stopImportPoll();
|
||
void pollImport(jobId);
|
||
importTimer = setInterval(() => void pollImport(jobId), 1500);
|
||
}
|
||
|
||
function onExcelImportStart() {
|
||
busy.value = true;
|
||
okMsg.value = '';
|
||
error.value = '';
|
||
}
|
||
|
||
function onExcelImportDone(result: Record<string, unknown> | LeadImportJobDto) {
|
||
const job = result as LeadImportJobDto;
|
||
if (!job?.id) {
|
||
error.value = 'ورود فایل ناموفق بود.';
|
||
return;
|
||
}
|
||
importJob.value = job;
|
||
startImportPoll(job.id);
|
||
}
|
||
|
||
function onExcelImportFail(message: string) {
|
||
error.value = message;
|
||
}
|
||
|
||
function onExcelImportSettled() {
|
||
busy.value = false;
|
||
}
|
||
|
||
function onPhoneImportStart() {
|
||
busy.value = true;
|
||
okMsg.value = '';
|
||
error.value = '';
|
||
}
|
||
|
||
async function onPhoneImportDone(data: {
|
||
created: number;
|
||
skipped: number;
|
||
failed: number;
|
||
}) {
|
||
okMsg.value = `از گوشی وارد شد: ${toFaDigits(data.created)} · تکراری: ${toFaDigits(data.skipped)}${
|
||
data.failed ? ` · ناموفق: ${toFaDigits(data.failed)}` : ''
|
||
}`;
|
||
await load();
|
||
}
|
||
|
||
function onPhoneImportFail(message: string) {
|
||
error.value = message;
|
||
}
|
||
|
||
function onPhoneImportSettled() {
|
||
busy.value = false;
|
||
}
|
||
|
||
function goDetail(id: string) {
|
||
void router.push({ name: 'lead-detail', params: { id } });
|
||
}
|
||
|
||
function callLead(lead: LeadDto) {
|
||
const mobile = lead.mobile?.replace(/\D/g, '');
|
||
if (!mobile) return;
|
||
window.location.href = `tel:${mobile}`;
|
||
}
|
||
|
||
watch(
|
||
() => [
|
||
route.query.q,
|
||
route.query.page,
|
||
route.query.sort,
|
||
route.query.stageId,
|
||
route.query.assigneeId,
|
||
route.query.city,
|
||
route.query.source,
|
||
route.query.unassigned,
|
||
],
|
||
() => {
|
||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||
void load();
|
||
},
|
||
);
|
||
|
||
onMounted(async () => {
|
||
await loadMeta();
|
||
await load();
|
||
const jobId = typeof route.query.jobId === 'string' ? route.query.jobId : '';
|
||
if (jobId) startImportPoll(jobId);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
stopImportPoll();
|
||
revokeLogoUrls();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page" @click="closePopups">
|
||
<UiPageHeader
|
||
title="لیدها"
|
||
lead="مدیریت و پیگیری تمام لیدهای ورودی"
|
||
>
|
||
<template #actions>
|
||
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="exportExcel">
|
||
<span class="btn-full">خروجی اکسل</span>
|
||
<span class="btn-short">خروجی</span>
|
||
</UiButton>
|
||
<ExcelImportFlow
|
||
preview-path="/leads/import/preview"
|
||
import-path="/leads/import/file"
|
||
:disabled="busy"
|
||
@start="onExcelImportStart"
|
||
@done="onExcelImportDone"
|
||
@fail="onExcelImportFail"
|
||
@settled="onExcelImportSettled"
|
||
>
|
||
<UiButton variant="ghost" size="sm" :disabled="busy">
|
||
<span class="btn-full">ورود از اکسل</span>
|
||
<span class="btn-short">ورود</span>
|
||
</UiButton>
|
||
</ExcelImportFlow>
|
||
<input
|
||
ref="logoFileInput"
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp,image/gif,.jpg,.jpeg,.png,.webp,.gif"
|
||
hidden
|
||
@change="onLogoFile"
|
||
/>
|
||
<PhoneContactsImport
|
||
target="leads"
|
||
:disabled="busy"
|
||
@start="onPhoneImportStart"
|
||
@done="onPhoneImportDone"
|
||
@fail="onPhoneImportFail"
|
||
@settled="onPhoneImportSettled"
|
||
>
|
||
<UiButton variant="ghost" size="sm" :disabled="busy">
|
||
<UiIcon name="phone" :size="15" />
|
||
<span class="btn-full">از مخاطبین گوشی</span>
|
||
<span class="btn-short">گوشی</span>
|
||
</UiButton>
|
||
</PhoneContactsImport>
|
||
<UiButton variant="primary" @click.stop="openCreate">
|
||
<UiIcon name="plus" :size="16" />
|
||
لید جدید
|
||
</UiButton>
|
||
</template>
|
||
</UiPageHeader>
|
||
|
||
<div class="stats" role="list">
|
||
<button
|
||
type="button"
|
||
class="stat-total"
|
||
:class="{ on: !stageId }"
|
||
role="listitem"
|
||
@click="setStageFilter('')"
|
||
>
|
||
<span class="stat-total-ico" aria-hidden="true">
|
||
<UiIcon name="users" :size="20" />
|
||
</span>
|
||
<span class="stat-total-body">
|
||
<span class="stat-label">همه لیدها</span>
|
||
<strong class="stat-num">{{ formatCount(totalLeadsCount) }}</strong>
|
||
</span>
|
||
</button>
|
||
|
||
<div class="stat-pair" role="group" aria-label="وضعیتهای پیگیری">
|
||
<button
|
||
v-for="card in pairStatCards"
|
||
:key="card.key"
|
||
type="button"
|
||
class="stat-cell"
|
||
:class="{ on: isStatActive(card), wide: card.key === 'در حال پیگیری' }"
|
||
:disabled="!card.stageId"
|
||
@click="selectStatCard(card)"
|
||
>
|
||
<i class="stat-dot" :data-tint="card.tint" aria-hidden="true" />
|
||
<span class="stat-label">{{ card.label }}</span>
|
||
<strong class="stat-num">{{ formatCount(card.count) }}</strong>
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
v-for="card in soloStatCards"
|
||
:key="card.key"
|
||
type="button"
|
||
class="stat-cell alone"
|
||
:class="{ on: isStatActive(card) }"
|
||
role="listitem"
|
||
:disabled="!card.stageId"
|
||
@click="selectStatCard(card)"
|
||
>
|
||
<i class="stat-dot" :data-tint="card.tint" aria-hidden="true" />
|
||
<span class="stat-label">{{ card.label }}</span>
|
||
<strong class="stat-num">{{ formatCount(card.count) }}</strong>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="filters" @click.stop>
|
||
<div class="filters-start">
|
||
<span class="filters-label">
|
||
<UiIcon name="funnel" :size="16" />
|
||
فیلترها
|
||
</span>
|
||
<form class="search" @submit.prevent="onSearch">
|
||
<UiIcon name="search" :size="16" />
|
||
<input v-model="q" type="search" placeholder="جستجو..." />
|
||
</form>
|
||
<div class="filter-selects">
|
||
<select
|
||
class="filter-select"
|
||
:value="cityFilter"
|
||
@change="setCityFilter(($event.target as HTMLSelectElement).value)"
|
||
>
|
||
<option value="">همه مناطق</option>
|
||
<option v-for="c in cityOptions" :key="c" :value="c">{{ c }}</option>
|
||
</select>
|
||
<select
|
||
class="filter-select"
|
||
:value="sourceFilter"
|
||
@change="setSourceFilter(($event.target as HTMLSelectElement).value)"
|
||
>
|
||
<option value="">همه منابع</option>
|
||
<option v-for="s in sourceOptions" :key="s" :value="s">{{ s }}</option>
|
||
</select>
|
||
<select
|
||
class="filter-select"
|
||
:value="stageId"
|
||
@change="setStageFilter(($event.target as HTMLSelectElement).value)"
|
||
>
|
||
<option value="">همه وضعیت</option>
|
||
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="filters-end">
|
||
<div class="pop">
|
||
<button
|
||
class="sort-btn"
|
||
type="button"
|
||
@click.stop="sortOpen = !sortOpen"
|
||
>
|
||
<UiIcon name="trend" :size="15" />
|
||
{{ sortLabels[sort] }}
|
||
</button>
|
||
<ul v-if="sortOpen" class="menu">
|
||
<li v-for="(label, key) in sortLabels" :key="key">
|
||
<button
|
||
type="button"
|
||
:class="{ on: sort === key }"
|
||
@click.stop="setSort(key as SortKey)"
|
||
>
|
||
{{ label }}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div class="view-toggle" title="نمای لیست / کاریز">
|
||
<button type="button" class="view-btn on" title="لیست">
|
||
<UiIcon name="list" :size="16" />
|
||
</button>
|
||
<RouterLink class="view-btn" :to="{ name: 'leads-board' }" title="کاریز">
|
||
<UiIcon name="grid" :size="16" />
|
||
</RouterLink>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="selectedIds.length" class="bulk-bar" @click.stop>
|
||
<span>{{ toFaDigits(selectedIds.length) }} مورد انتخابشده</span>
|
||
<template v-if="isAdmin">
|
||
<div class="pop">
|
||
<UiButton size="sm" variant="ghost" @click="assignOpen = !assignOpen">
|
||
تخصیص
|
||
</UiButton>
|
||
<div v-if="assignOpen" class="menu panel">
|
||
<select v-model="assignUserId">
|
||
<option value="">انتخاب کارشناس…</option>
|
||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||
{{ u.fullName }}
|
||
</option>
|
||
</select>
|
||
<UiButton size="sm" :disabled="!assignUserId || busy" @click="assignSelected">
|
||
اعمال
|
||
</UiButton>
|
||
</div>
|
||
</div>
|
||
<div class="pop">
|
||
<UiButton size="sm" variant="ghost" @click="stageOpen = !stageOpen">
|
||
تغییر وضعیت
|
||
</UiButton>
|
||
<div v-if="stageOpen" class="menu panel">
|
||
<select v-model="bulkStageId">
|
||
<option value="">انتخاب وضعیت…</option>
|
||
<option v-for="s in stages" :key="s.id" :value="s.id">
|
||
{{ s.name }}
|
||
</option>
|
||
</select>
|
||
<UiButton size="sm" :disabled="!bulkStageId || busy" @click="bulkStageSelected">
|
||
اعمال
|
||
</UiButton>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<UiButton size="sm" variant="ghost" :disabled="busy" @click="archiveSelected">
|
||
بایگانی
|
||
</UiButton>
|
||
</div>
|
||
|
||
<p
|
||
v-if="importJob && importJob.status !== 'done' && importJob.status !== 'completed' && importJob.status !== 'failed'"
|
||
class="ok"
|
||
>
|
||
ورود فایل: {{ toFaDigits(importJob.processedCount) }} / {{ toFaDigits(importJob.totalCount) }}
|
||
</p>
|
||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||
|
||
<template v-else-if="data?.items.length">
|
||
<div class="cards">
|
||
<article
|
||
v-for="(lead, idx) in data.items"
|
||
:key="`m-${lead.id}`"
|
||
class="m-card"
|
||
:class="{ selected: selected.has(lead.id) }"
|
||
:data-tint="stageTintForLead(lead)"
|
||
@pointerdown="onCardPointerDown(lead.id, $event)"
|
||
@pointermove="onCardPointerMove"
|
||
@pointerup="onCardPointerEnd"
|
||
@pointercancel="onCardPointerEnd"
|
||
@contextmenu.prevent
|
||
@click="onCardClick(lead)"
|
||
>
|
||
<div class="m-head">
|
||
<span class="m-badge" :data-tint="stageTintForLead(lead)">
|
||
{{ toFaDigits(rangeFrom + idx) }}
|
||
</span>
|
||
<UiDropdownMenu
|
||
:open="logoMenuId === `m:${lead.id}`"
|
||
align="start"
|
||
@update:open="(v) => (logoMenuId = v ? `m:${lead.id}` : null)"
|
||
>
|
||
<template #trigger="{ toggle }">
|
||
<button
|
||
type="button"
|
||
class="row-ico"
|
||
:class="{ busy: logoBusyId === lead.id }"
|
||
:data-tint="stageTintForLead(lead)"
|
||
:title="lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو'"
|
||
@click.stop="toggle"
|
||
>
|
||
<img
|
||
v-if="logoUrls[lead.id]"
|
||
:src="logoUrls[lead.id]"
|
||
:alt="lead.title"
|
||
class="row-logo"
|
||
/>
|
||
<UiIcon v-else :name="rowIcon(lead)" :size="16" />
|
||
</button>
|
||
</template>
|
||
<button type="button" @click="openLogoPicker(lead.id)">
|
||
{{ lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو' }}
|
||
</button>
|
||
<button
|
||
v-if="lead.hasLogo"
|
||
type="button"
|
||
class="danger-item"
|
||
@click="removeLeadLogo(lead.id)"
|
||
>
|
||
حذف لوگو
|
||
</button>
|
||
</UiDropdownMenu>
|
||
<div class="m-title">
|
||
<strong class="name">{{ lead.title }}</strong>
|
||
<span v-if="lead.contactName" class="sub">{{ lead.contactName }}</span>
|
||
</div>
|
||
<UiDropdownMenu
|
||
:open="rowMenuId === `m:${lead.id}`"
|
||
align="end"
|
||
@update:open="(v) => (rowMenuId = v ? `m:${lead.id}` : null)"
|
||
>
|
||
<template #trigger="{ toggle }">
|
||
<button
|
||
type="button"
|
||
class="act-btn"
|
||
title="بیشتر"
|
||
@click.stop="toggle"
|
||
>
|
||
<UiIcon name="dots-v" :size="16" />
|
||
</button>
|
||
</template>
|
||
<button type="button" @click="goDetail(lead.id)">مشاهده</button>
|
||
<button type="button" @click="openEdit(lead)">ویرایش</button>
|
||
<a :href="`tel:${lead.mobile}`">تماس</a>
|
||
<button type="button" class="danger-item" @click="deleteLead(lead)">
|
||
حذف
|
||
</button>
|
||
</UiDropdownMenu>
|
||
</div>
|
||
|
||
<div class="m-meta-row">
|
||
<button
|
||
v-if="lead.mobile"
|
||
type="button"
|
||
class="m-line m-phone"
|
||
dir="ltr"
|
||
@click.stop="callLead(lead)"
|
||
>
|
||
<UiIcon name="phone" :size="13" />
|
||
<span>{{ formatPhone(lead.mobile) }}</span>
|
||
</button>
|
||
<p v-if="lead.city || lead.region" class="m-line m-place">
|
||
<UiIcon name="building" :size="13" />
|
||
<span>{{ [lead.city, lead.region].filter(Boolean).join(' · ') }}</span>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="m-foot">
|
||
<span v-if="lead.assigneeName" class="assignee">
|
||
<span class="avatar">{{ initials(lead.assigneeName) }}</span>
|
||
{{ lead.assigneeName }}
|
||
</span>
|
||
<span v-else class="muted-dash">بدون مسئول</span>
|
||
<span class="m-activity">{{ lastActivity(lead).when }}</span>
|
||
</div>
|
||
|
||
<div class="m-pills">
|
||
<span class="stage-pill" :data-tint="stageTintForLead(lead)">
|
||
{{ lead.stageName }}
|
||
</span>
|
||
<span v-if="lead.source" class="source-pill">{{ lead.source }}</span>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th class="check-col">
|
||
<input
|
||
type="checkbox"
|
||
:checked="allSelected"
|
||
@click.stop
|
||
@change="toggleAll(($event.target as HTMLInputElement).checked)"
|
||
/>
|
||
</th>
|
||
<th class="num-col">#</th>
|
||
<th class="icon-col"></th>
|
||
<th>نام / شرکت</th>
|
||
<th>شماره تماس</th>
|
||
<th>شهر / منطقه</th>
|
||
<th>منبع لید</th>
|
||
<th>وضعیت</th>
|
||
<th>مسئول</th>
|
||
<th>آخرین فعالیت</th>
|
||
<th class="actions-col">اقدامات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="(lead, idx) in data.items"
|
||
:key="lead.id"
|
||
class="row-click"
|
||
@click="goDetail(lead.id)"
|
||
>
|
||
<td class="check-col" @click.stop>
|
||
<input
|
||
type="checkbox"
|
||
:checked="selected.has(lead.id)"
|
||
@change="toggleOne(lead.id, ($event.target as HTMLInputElement).checked)"
|
||
/>
|
||
</td>
|
||
<td class="num-col">{{ toFaDigits(rangeFrom + idx) }}</td>
|
||
<td class="icon-col" @click.stop>
|
||
<UiDropdownMenu
|
||
:open="logoMenuId === lead.id"
|
||
align="start"
|
||
@update:open="(v) => (logoMenuId = v ? lead.id : null)"
|
||
>
|
||
<template #trigger="{ toggle }">
|
||
<button
|
||
type="button"
|
||
class="row-ico"
|
||
:class="{ busy: logoBusyId === lead.id }"
|
||
:data-tint="stageTintForLead(lead)"
|
||
:title="lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو'"
|
||
@click.stop="toggle"
|
||
>
|
||
<img
|
||
v-if="logoUrls[lead.id]"
|
||
:src="logoUrls[lead.id]"
|
||
:alt="lead.title"
|
||
class="row-logo"
|
||
/>
|
||
<UiIcon v-else :name="rowIcon(lead)" :size="16" />
|
||
</button>
|
||
</template>
|
||
<button type="button" @click="openLogoPicker(lead.id)">
|
||
{{ lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو' }}
|
||
</button>
|
||
<button
|
||
v-if="lead.hasLogo"
|
||
type="button"
|
||
class="danger-item"
|
||
@click="removeLeadLogo(lead.id)"
|
||
>
|
||
حذف لوگو
|
||
</button>
|
||
</UiDropdownMenu>
|
||
</td>
|
||
<td>
|
||
<strong class="name">{{ lead.title }}</strong>
|
||
<span v-if="lead.contactName" class="sub">{{ lead.contactName }}</span>
|
||
</td>
|
||
<td class="phone" dir="ltr">{{ formatPhone(lead.mobile) }}</td>
|
||
<td>
|
||
<span class="city">{{ lead.city || '—' }}</span>
|
||
<span v-if="lead.region" class="sub">{{ lead.region }}</span>
|
||
</td>
|
||
<td>
|
||
<span v-if="lead.source" class="source-pill">{{ lead.source }}</span>
|
||
<span v-else class="muted-dash">—</span>
|
||
</td>
|
||
<td>
|
||
<span class="stage-pill" :data-tint="stageTintForLead(lead)">
|
||
{{ lead.stageName }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span v-if="lead.assigneeName" class="assignee">
|
||
<span class="avatar">{{ initials(lead.assigneeName) }}</span>
|
||
{{ lead.assigneeName }}
|
||
</span>
|
||
<span v-else class="muted-dash">—</span>
|
||
</td>
|
||
<td>
|
||
<span class="activity">
|
||
<span class="activity-text">
|
||
<strong>{{ lastActivity(lead).label }}</strong>
|
||
<span class="sub">{{ lastActivity(lead).when }}</span>
|
||
</span>
|
||
</span>
|
||
</td>
|
||
<td class="actions-col" @click.stop>
|
||
<div class="row-actions">
|
||
<UiDropdownMenu
|
||
:open="rowMenuId === lead.id"
|
||
align="end"
|
||
@update:open="(v) => (rowMenuId = v ? lead.id : null)"
|
||
>
|
||
<template #trigger="{ toggle }">
|
||
<button
|
||
type="button"
|
||
class="act-btn"
|
||
title="بیشتر"
|
||
@click.stop="toggle"
|
||
>
|
||
<UiIcon name="dots-v" :size="16" />
|
||
</button>
|
||
</template>
|
||
<button type="button" @click="goDetail(lead.id)">مشاهده</button>
|
||
<button type="button" @click="openEdit(lead)">ویرایش</button>
|
||
<a :href="`tel:${lead.mobile}`">تماس</a>
|
||
<button type="button" class="danger-item" @click="deleteLead(lead)">
|
||
حذف
|
||
</button>
|
||
</UiDropdownMenu>
|
||
<button
|
||
type="button"
|
||
class="act-btn call"
|
||
title="تماس"
|
||
@click="callLead(lead)"
|
||
>
|
||
<UiIcon name="phone" :size="15" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="act-btn"
|
||
title="یادداشت / جزئیات"
|
||
@click="goDetail(lead.id)"
|
||
>
|
||
<UiIcon name="chat" :size="15" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<nav class="pager" aria-label="صفحهبندی">
|
||
<div class="pager-pages">
|
||
<button
|
||
type="button"
|
||
class="page-nav"
|
||
:disabled="page <= 1"
|
||
@click="setPage(page - 1)"
|
||
>
|
||
<UiIcon name="chevron-right" :size="16" />
|
||
</button>
|
||
<button
|
||
v-for="n in pageNumbers"
|
||
:key="n"
|
||
type="button"
|
||
class="page-num"
|
||
:class="{ on: n === page }"
|
||
@click="setPage(n)"
|
||
>
|
||
{{ toFaDigits(n) }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="page-nav"
|
||
:disabled="page >= pageCount"
|
||
@click="setPage(page + 1)"
|
||
>
|
||
<UiIcon name="chevron-left" :size="16" />
|
||
</button>
|
||
</div>
|
||
<p class="pager-info">
|
||
نمایش {{ toFaDigits(rangeFrom) }} تا {{ toFaDigits(rangeTo) }} از
|
||
{{ formatCount(total) }} لید
|
||
</p>
|
||
</nav>
|
||
</template>
|
||
|
||
<UiEmptyState
|
||
v-else-if="!loading"
|
||
title="لیدی ثبت نشده است."
|
||
description="از ورود فایل استفاده کنید یا لید جدید بسازید."
|
||
>
|
||
<template #action>
|
||
<div class="empty-actions">
|
||
<UiButton variant="primary" @click="openCreate">ثبت لید جدید</UiButton>
|
||
<ExcelImportFlow
|
||
v-if="isAdmin"
|
||
preview-path="/leads/import/preview"
|
||
import-path="/leads/import/file"
|
||
:disabled="busy"
|
||
@start="onExcelImportStart"
|
||
@done="onExcelImportDone"
|
||
@fail="onExcelImportFail"
|
||
@settled="onExcelImportSettled"
|
||
>
|
||
<UiButton variant="ghost" :disabled="busy">ورود از فایل</UiButton>
|
||
</ExcelImportFlow>
|
||
</div>
|
||
</template>
|
||
</UiEmptyState>
|
||
|
||
<UiModal :open="createOpen" :title="createModalTitle" @close="createOpen = false">
|
||
<form class="create-form" @submit.prevent="submitCreate">
|
||
<label>
|
||
نام / شرکت
|
||
<input v-model="createForm.title" required placeholder="مثلاً فروشگاه آریا" />
|
||
</label>
|
||
<label>
|
||
نام مخاطب
|
||
<input v-model="createForm.contactName" placeholder="مهدی احمدی" />
|
||
</label>
|
||
<label class="mobile-field">
|
||
<span class="label-row">
|
||
موبایل
|
||
<button
|
||
type="button"
|
||
class="from-contact"
|
||
:disabled="pickingContact || createBusy"
|
||
@click="fillFromPhoneContact"
|
||
>
|
||
<UiIcon name="phone" :size="14" />
|
||
{{ pickingContact ? '…' : 'از مخاطبین' }}
|
||
</button>
|
||
</span>
|
||
<input
|
||
v-model="createForm.mobile"
|
||
required
|
||
class="ltr"
|
||
placeholder="09121234567"
|
||
inputmode="tel"
|
||
/>
|
||
</label>
|
||
<input
|
||
ref="contactFileInput"
|
||
class="hidden-file"
|
||
type="file"
|
||
accept=".vcf,text/vcard,text/x-vcard"
|
||
@change="onContactVcfFile"
|
||
/>
|
||
<div class="create-grid">
|
||
<label>
|
||
شهر
|
||
<input v-model="createForm.city" placeholder="تهران" />
|
||
</label>
|
||
<label>
|
||
منطقه
|
||
<input v-model="createForm.region" placeholder="بازار" />
|
||
</label>
|
||
</div>
|
||
<label>
|
||
منبع
|
||
<input v-model="createForm.source" placeholder="سایت، نمایشگاه…" />
|
||
</label>
|
||
<div class="create-grid">
|
||
<label>
|
||
وضعیت
|
||
<select v-model="createForm.stageId">
|
||
<option v-for="s in stages" :key="s.id" :value="s.id">{{ s.name }}</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
مسئول
|
||
<select v-model="createForm.assigneeId">
|
||
<option value="">بدون مسئول</option>
|
||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<p v-if="createError" class="error">{{ createError }}</p>
|
||
</form>
|
||
|
||
<UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
|
||
<p class="pick-lead">
|
||
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
|
||
</p>
|
||
<div class="pick-choose">
|
||
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
|
||
<UiIcon name="phone" :size="16" />
|
||
از دفترچه تلفن
|
||
</UiButton>
|
||
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
|
||
از فایل .vcf
|
||
</UiButton>
|
||
</div>
|
||
<template #footer>
|
||
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
|
||
</template>
|
||
</UiModal>
|
||
|
||
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
|
||
<p class="pick-lead">
|
||
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتبشده الفبایی).
|
||
یکی را انتخاب کنید تا موبایل (و در صورت خالی بودن، نام) پر شود.
|
||
</p>
|
||
<div class="pick-search">
|
||
<UiIcon name="search" :size="15" />
|
||
<input
|
||
v-model="pickQuery"
|
||
type="search"
|
||
placeholder="جستجو نام یا موبایل…"
|
||
autocomplete="off"
|
||
/>
|
||
</div>
|
||
<ul v-if="visiblePickDrafts.length" class="pick-list">
|
||
<li v-for="row in visiblePickDrafts" :key="row.key">
|
||
<button type="button" class="pick-row" @click="choosePick(row)">
|
||
<span class="pick-avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
|
||
<span class="pick-meta">
|
||
<strong>{{ row.fullName }}</strong>
|
||
<small>{{ draftLine(row) }}</small>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="pick-empty">مخاطبی با این جستجو پیدا نشد.</p>
|
||
<template #footer>
|
||
<UiButton variant="ghost" @click="closePick">انصراف</UiButton>
|
||
<UiButton variant="ghost" @click="contactFileInput?.click()">فایل دیگر</UiButton>
|
||
</template>
|
||
</UiModal>
|
||
|
||
<template #footer>
|
||
<UiButton variant="ghost" :disabled="createBusy" @click="createOpen = false">
|
||
انصراف
|
||
</UiButton>
|
||
<UiButton variant="primary" :disabled="createBusy" @click="submitCreate">
|
||
{{
|
||
createBusy
|
||
? editingLeadId
|
||
? 'در حال ذخیره…'
|
||
: 'در حال ثبت…'
|
||
: editingLeadId
|
||
? 'ذخیره تغییرات'
|
||
: 'ثبت لید'
|
||
}}
|
||
</UiButton>
|
||
</template>
|
||
</UiModal>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page {
|
||
display: grid;
|
||
gap: 1rem;
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
overflow-x: clip;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.btn-short {
|
||
display: none;
|
||
}
|
||
|
||
.stats {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
align-items: stretch;
|
||
gap: 0.65rem;
|
||
width: 100%;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
padding-bottom: 2px;
|
||
}
|
||
|
||
.stat-total {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 0.7rem;
|
||
flex: 1.15 1 0;
|
||
min-width: 8.5rem;
|
||
min-height: 5.25rem;
|
||
padding: 0.7rem 0.85rem;
|
||
border: 1px solid #a7f3d0;
|
||
border-radius: 12px;
|
||
background: #ecfdf5;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
text-align: start;
|
||
color: #047857;
|
||
box-shadow: none;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
|
||
.stat-total:hover,
|
||
.stat-total.on {
|
||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
|
||
border-color: #6ee7b7;
|
||
}
|
||
|
||
.stat-total-ico {
|
||
width: 2.5rem;
|
||
height: 2.5rem;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
color: #10b981;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||
order: 0;
|
||
}
|
||
|
||
.stat-total-body {
|
||
display: grid;
|
||
gap: 0.15rem;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-total .stat-label {
|
||
color: #059669;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.stat-total .stat-num {
|
||
color: #047857;
|
||
font-size: 1.4rem;
|
||
font-weight: 800;
|
||
letter-spacing: -0.02em;
|
||
font-variant-numeric: tabular-nums;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.stat-pair {
|
||
display: flex;
|
||
align-items: stretch;
|
||
flex: 2.2 1 0;
|
||
min-width: 14rem;
|
||
min-height: 5.25rem;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
isolation: isolate;
|
||
}
|
||
|
||
.stat-pair .stat-cell {
|
||
flex: 1 1 0;
|
||
border: 0 !important;
|
||
border-radius: 0 !important;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
box-shadow: none !important;
|
||
background: transparent;
|
||
}
|
||
|
||
.stat-pair .stat-cell.wide {
|
||
flex: 1.45 1 0;
|
||
}
|
||
|
||
.stat-pair .stat-cell + .stat-cell {
|
||
border-inline-start: 1px solid #e5e7eb !important;
|
||
}
|
||
|
||
.stat-pair .stat-cell:hover:not(:disabled) {
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.stat-pair .stat-cell.on {
|
||
background: color-mix(in srgb, var(--accent-soft) 70%, #fff);
|
||
}
|
||
|
||
.stat-pair:has(.stat-cell.on) {
|
||
border-color: color-mix(in srgb, var(--accent) 35%, #e5e7eb);
|
||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||
}
|
||
|
||
.stat-cell {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.12rem;
|
||
min-width: 5.5rem;
|
||
min-height: 5.25rem;
|
||
padding: 1.15rem 0.65rem 0.7rem;
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
color: #0f172a;
|
||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||
}
|
||
|
||
.stat-cell.alone {
|
||
flex: 1 1 0;
|
||
}
|
||
|
||
.stat-cell:disabled {
|
||
opacity: 0.55;
|
||
cursor: default;
|
||
}
|
||
|
||
.stat-cell.alone:hover:not(:disabled),
|
||
.stat-cell.alone.on {
|
||
border-color: color-mix(in srgb, var(--accent) 35%, #e5e7eb);
|
||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||
}
|
||
|
||
.stat-dot {
|
||
position: absolute;
|
||
top: 0.65rem;
|
||
left: 0.7rem;
|
||
width: 0.5rem;
|
||
height: 0.5rem;
|
||
border-radius: 999px;
|
||
background: #94a3b8;
|
||
box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.2);
|
||
}
|
||
|
||
.stat-dot[data-tint='mint'],
|
||
.stat-dot[data-tint='teal'] {
|
||
background: #22c55e;
|
||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.2);
|
||
}
|
||
.stat-dot[data-tint='amber'] {
|
||
background: #f59e0b;
|
||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.22);
|
||
}
|
||
.stat-dot[data-tint='peach'] {
|
||
background: #fbbf24;
|
||
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.22);
|
||
}
|
||
.stat-dot[data-tint='sky'] {
|
||
background: #0ea5e9;
|
||
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.2);
|
||
}
|
||
.stat-dot[data-tint='violet'] {
|
||
background: #8b5cf6;
|
||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.22);
|
||
}
|
||
.stat-dot[data-tint='rose'] {
|
||
background: #ef4444;
|
||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||
}
|
||
|
||
.stat-cell .stat-label {
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
color: #334155;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.stat-cell .stat-num {
|
||
font-size: 1.3rem;
|
||
font-weight: 800;
|
||
color: #0f172a;
|
||
letter-spacing: -0.02em;
|
||
font-variant-numeric: tabular-nums;
|
||
line-height: 1.15;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 0.65rem;
|
||
overflow: visible;
|
||
align-items: stretch;
|
||
}
|
||
|
||
/* جدا کردن جفت تا هر کارت یک سلول گرید شود */
|
||
.stat-pair {
|
||
display: contents;
|
||
}
|
||
|
||
.stat-total,
|
||
.stat-cell {
|
||
width: 100%;
|
||
min-width: 0;
|
||
height: 5.5rem;
|
||
min-height: 5.5rem;
|
||
max-height: 5.5rem;
|
||
flex: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.stat-total {
|
||
padding: 0.65rem 0.75rem;
|
||
gap: 0.55rem;
|
||
}
|
||
|
||
.stat-total-ico {
|
||
width: 2.15rem;
|
||
height: 2.15rem;
|
||
}
|
||
|
||
.stat-total .stat-label {
|
||
font-size: 0.72rem;
|
||
}
|
||
|
||
.stat-total .stat-num {
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
.stat-cell {
|
||
padding: 1rem 0.45rem 0.6rem;
|
||
}
|
||
|
||
.stat-cell .stat-label {
|
||
white-space: normal;
|
||
font-size: 0.72rem;
|
||
line-height: 1.2;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.stat-cell .stat-num {
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
.stat-pair .stat-cell {
|
||
border: 1px solid #e5e7eb !important;
|
||
border-radius: 12px !important;
|
||
background: #fff;
|
||
box-shadow: none !important;
|
||
}
|
||
|
||
.stat-pair .stat-cell.wide {
|
||
flex: none;
|
||
}
|
||
|
||
.stat-pair .stat-cell + .stat-cell {
|
||
border-inline-start: 1px solid #e5e7eb !important;
|
||
}
|
||
|
||
.stat-pair .stat-cell:hover:not(:disabled),
|
||
.stat-pair .stat-cell.on {
|
||
border-color: color-mix(in srgb, var(--accent) 35%, #e5e7eb) !important;
|
||
box-shadow: 0 0 0 2px var(--accent-soft) !important;
|
||
background: color-mix(in srgb, var(--accent-soft) 70%, #fff);
|
||
}
|
||
}
|
||
|
||
.filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.65rem;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.7rem 0.85rem;
|
||
background: color-mix(in srgb, var(--surface-2, #f8fafc) 90%, #fff);
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
.filters-start,
|
||
.filters-end {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.filter-selects {
|
||
display: contents;
|
||
}
|
||
|
||
.filters-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.86rem;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
padding-inline-end: 0.25rem;
|
||
}
|
||
|
||
.search {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
padding: 0 0.7rem;
|
||
min-height: 2.35rem;
|
||
min-width: 10rem;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.search:focus-within {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||
}
|
||
|
||
.search input {
|
||
border: 0;
|
||
outline: none;
|
||
background: transparent;
|
||
font: inherit;
|
||
font-size: 0.88rem;
|
||
color: var(--ink);
|
||
width: 8.5rem;
|
||
}
|
||
|
||
.filter-select,
|
||
.sort-btn {
|
||
min-height: 2.35rem;
|
||
border: 1px solid var(--line);
|
||
background: #fff;
|
||
border-radius: 10px;
|
||
padding: 0 0.7rem;
|
||
font: inherit;
|
||
font-size: 0.86rem;
|
||
color: var(--ink);
|
||
cursor: pointer;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.filters {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.filters-start {
|
||
display: grid;
|
||
grid-template-columns: auto minmax(0, 1fr);
|
||
gap: 0.5rem;
|
||
width: 100%;
|
||
}
|
||
|
||
.filters-label {
|
||
grid-column: 1;
|
||
grid-row: 1;
|
||
align-self: center;
|
||
padding-inline-end: 0;
|
||
}
|
||
|
||
.search {
|
||
grid-column: 2;
|
||
grid-row: 1;
|
||
min-width: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.search input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.filter-selects {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 0.5rem;
|
||
grid-column: 1 / -1;
|
||
grid-row: 2;
|
||
width: 100%;
|
||
}
|
||
|
||
.filter-select {
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
padding-inline: 0.45rem;
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.filters-end {
|
||
width: 100%;
|
||
flex-wrap: nowrap;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.filters-end .sort-btn {
|
||
flex: 1;
|
||
min-width: 0;
|
||
justify-content: center;
|
||
}
|
||
}
|
||
|
||
.sort-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.view-toggle {
|
||
display: inline-flex;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
background: #fff;
|
||
}
|
||
|
||
.view-btn {
|
||
width: 2.35rem;
|
||
height: 2.35rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.view-btn.on,
|
||
.view-btn:hover {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.pop {
|
||
position: relative;
|
||
}
|
||
|
||
.menu {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
inset-inline-end: 0;
|
||
z-index: 30;
|
||
margin: 0;
|
||
padding: 0.35rem;
|
||
list-style: none;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12);
|
||
min-width: 11rem;
|
||
}
|
||
|
||
.menu.panel {
|
||
display: grid;
|
||
gap: 0.5rem;
|
||
padding: 0.65rem;
|
||
min-width: 14rem;
|
||
}
|
||
|
||
.menu.row-menu {
|
||
inset-inline-end: auto;
|
||
inset-inline-start: 0;
|
||
}
|
||
|
||
.menu a,
|
||
.menu button {
|
||
display: block;
|
||
width: 100%;
|
||
border: 0;
|
||
background: none;
|
||
text-align: start;
|
||
padding: 0.45rem 0.55rem;
|
||
border-radius: 8px;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
color: var(--ink);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.menu a:hover,
|
||
.menu button:hover,
|
||
.menu button.on {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.menu select {
|
||
width: 100%;
|
||
min-height: 2.35rem;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 0 0.5rem;
|
||
font: inherit;
|
||
}
|
||
|
||
.bulk-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
padding: 0.55rem 0.75rem;
|
||
background: var(--accent-soft);
|
||
border-radius: 10px;
|
||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||
}
|
||
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
background: #fff;
|
||
}
|
||
|
||
.cards {
|
||
display: none;
|
||
gap: 0.7rem;
|
||
}
|
||
|
||
.m-card {
|
||
position: relative;
|
||
display: grid;
|
||
gap: 0.55rem;
|
||
padding: 0.85rem;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
cursor: pointer;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||
touch-action: pan-y;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
|
||
.m-card:active {
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.m-card.selected {
|
||
background: #ecfdf5;
|
||
border-color: #6ee7b7;
|
||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18);
|
||
}
|
||
|
||
.m-card.selected:active {
|
||
background: #d1fae5;
|
||
}
|
||
|
||
.m-badge {
|
||
width: 1.55rem;
|
||
height: 1.55rem;
|
||
border-radius: 999px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.68rem;
|
||
font-weight: 800;
|
||
font-variant-numeric: tabular-nums;
|
||
background: #e2e8f0;
|
||
color: #334155;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.m-badge[data-tint='mint'],
|
||
.m-badge[data-tint='teal'] {
|
||
background: #d1fae5;
|
||
color: #047857;
|
||
}
|
||
.m-badge[data-tint='amber'] {
|
||
background: #fef3c7;
|
||
color: #b45309;
|
||
}
|
||
.m-badge[data-tint='peach'] {
|
||
background: #ffedd5;
|
||
color: #c2410c;
|
||
}
|
||
.m-badge[data-tint='sky'] {
|
||
background: #e0f2fe;
|
||
color: #0369a1;
|
||
}
|
||
.m-badge[data-tint='violet'] {
|
||
background: #ede9fe;
|
||
color: #6d28d9;
|
||
}
|
||
.m-badge[data-tint='rose'] {
|
||
background: #fee2e2;
|
||
color: #b91c1c;
|
||
}
|
||
|
||
.m-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.m-title {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
display: grid;
|
||
gap: 0.05rem;
|
||
}
|
||
|
||
.m-title .name {
|
||
overflow-wrap: anywhere;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.m-title .sub {
|
||
margin-top: 0;
|
||
}
|
||
|
||
.m-meta-row {
|
||
display: flex;
|
||
direction: ltr;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.5rem;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
|
||
.m-line {
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.8rem;
|
||
color: #475569;
|
||
min-width: 0;
|
||
}
|
||
|
||
.m-meta-row .m-place {
|
||
flex: 0 1 auto;
|
||
max-width: 55%;
|
||
direction: rtl;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.m-meta-row .m-phone {
|
||
flex: 0 1 auto;
|
||
max-width: 55%;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
button.m-line {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 0;
|
||
font: inherit;
|
||
text-align: start;
|
||
cursor: pointer;
|
||
width: auto;
|
||
}
|
||
|
||
.m-phone {
|
||
color: #059669;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.m-phone:active {
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.m-line :deep(svg) {
|
||
flex-shrink: 0;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.m-phone :deep(svg) {
|
||
color: #10b981;
|
||
}
|
||
|
||
.m-line span {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.m-pills {
|
||
display: flex;
|
||
direction: ltr;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.5rem;
|
||
width: 100%;
|
||
min-width: 0;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
.m-pills .stage-pill {
|
||
flex: 0 1 auto;
|
||
max-width: 55%;
|
||
}
|
||
|
||
.m-pills .source-pill {
|
||
flex: 0 1 auto;
|
||
max-width: 55%;
|
||
margin-left: auto;
|
||
direction: rtl;
|
||
}
|
||
|
||
.m-foot {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.45rem;
|
||
padding-top: 0.1rem;
|
||
}
|
||
|
||
.m-activity {
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
color: #64748b;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.data-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.88rem;
|
||
}
|
||
|
||
.data-table th,
|
||
.data-table td {
|
||
padding: 0.85rem 0.75rem;
|
||
text-align: start;
|
||
border-bottom: 1px solid var(--line);
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.data-table th {
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
background: #fafbfc;
|
||
white-space: nowrap;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.row-click {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.row-click:hover {
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.icon-col {
|
||
width: 2.75rem;
|
||
}
|
||
|
||
.num-col {
|
||
width: 2.5rem;
|
||
text-align: center;
|
||
color: var(--muted);
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.check-col {
|
||
width: 2.5rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.actions-col {
|
||
width: 7.5rem;
|
||
}
|
||
|
||
.row-ico {
|
||
width: 2.35rem;
|
||
height: 2.35rem;
|
||
border-radius: 10px;
|
||
border: 0;
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #ecfdf5;
|
||
color: #059669;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.row-ico.busy {
|
||
opacity: 0.55;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.row-logo {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.row-ico:has(.row-logo) {
|
||
background: #fff;
|
||
border: 1px solid var(--line, #e5e7eb);
|
||
}
|
||
|
||
.row-ico[data-tint='amber'] {
|
||
background: #fef3c7;
|
||
color: #d97706;
|
||
}
|
||
.row-ico[data-tint='peach'] {
|
||
background: #ffedd5;
|
||
color: #ea580c;
|
||
}
|
||
.row-ico[data-tint='sky'],
|
||
.row-ico[data-tint='teal'] {
|
||
background: #e0f2fe;
|
||
color: #0284c7;
|
||
}
|
||
.row-ico[data-tint='violet'] {
|
||
background: #ede9fe;
|
||
color: #7c3aed;
|
||
}
|
||
.row-ico[data-tint='rose'] {
|
||
background: #fee2e2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.name {
|
||
display: block;
|
||
font-weight: 700;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.sub {
|
||
display: block;
|
||
color: var(--muted);
|
||
font-size: 0.78rem;
|
||
font-weight: 400;
|
||
margin-top: 0.15rem;
|
||
}
|
||
|
||
.phone {
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.city {
|
||
display: block;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.source-pill {
|
||
display: inline-block;
|
||
padding: 0.2rem 0.65rem;
|
||
border-radius: 999px;
|
||
background: #f1f5f9;
|
||
color: #475569;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stage-pill {
|
||
display: inline-block;
|
||
padding: 0.22rem 0.7rem;
|
||
border-radius: 999px;
|
||
font-size: 0.78rem;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
background: #d1fae5;
|
||
color: #047857;
|
||
}
|
||
|
||
.stage-pill[data-tint='amber'] {
|
||
background: #fef3c7;
|
||
color: #b45309;
|
||
}
|
||
.stage-pill[data-tint='peach'] {
|
||
background: #ffedd5;
|
||
color: #c2410c;
|
||
}
|
||
.stage-pill[data-tint='sky'],
|
||
.stage-pill[data-tint='teal'] {
|
||
background: #e0f2fe;
|
||
color: #0369a1;
|
||
}
|
||
.stage-pill[data-tint='violet'] {
|
||
background: #ede9fe;
|
||
color: #6d28d9;
|
||
}
|
||
.stage-pill[data-tint='rose'] {
|
||
background: #fee2e2;
|
||
color: #b91c1c;
|
||
}
|
||
.stage-pill[data-tint='mint'] {
|
||
background: #d1fae5;
|
||
color: #047857;
|
||
}
|
||
|
||
.assignee {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.45rem;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.avatar {
|
||
width: 1.7rem;
|
||
height: 1.7rem;
|
||
border-radius: 999px;
|
||
background: linear-gradient(135deg, #5eead4, #34d399);
|
||
color: #064e3b;
|
||
font-size: 0.68rem;
|
||
font-weight: 800;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.activity {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
min-width: 8rem;
|
||
}
|
||
|
||
.activity-text strong {
|
||
display: block;
|
||
font-size: 0.84rem;
|
||
}
|
||
|
||
.row-actions {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.act-btn {
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.act-btn:hover {
|
||
background: #f1f5f9;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.act-btn.call {
|
||
background: #ecfdf5;
|
||
color: #059669;
|
||
}
|
||
|
||
.act-btn.call:hover {
|
||
background: #d1fae5;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.table-wrap {
|
||
display: none;
|
||
}
|
||
|
||
.cards {
|
||
display: grid;
|
||
}
|
||
}
|
||
|
||
:deep(.ui-dd-panel) .danger-item,
|
||
.danger-item {
|
||
color: #b91c1c !important;
|
||
}
|
||
|
||
.pager {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
padding-top: 0.25rem;
|
||
}
|
||
|
||
.pager-pages {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.page-nav,
|
||
.page-num {
|
||
width: 2.1rem;
|
||
height: 2.1rem;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font: inherit;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.page-num.on {
|
||
background: var(--accent, #0d9488);
|
||
color: #fff;
|
||
}
|
||
|
||
.page-nav:disabled {
|
||
opacity: 0.35;
|
||
cursor: default;
|
||
}
|
||
|
||
.pager-info {
|
||
margin: 0;
|
||
color: var(--muted);
|
||
font-size: 0.86rem;
|
||
}
|
||
|
||
.muted,
|
||
.muted-dash {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.ok {
|
||
color: #0f766e;
|
||
margin: 0;
|
||
}
|
||
|
||
.error {
|
||
color: #b91c1c;
|
||
margin: 0;
|
||
}
|
||
|
||
.create-form {
|
||
display: grid;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.create-form label {
|
||
display: grid;
|
||
gap: 0.35rem;
|
||
font-size: 0.86rem;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
}
|
||
|
||
.create-form .label-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.from-contact {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--accent-deep, var(--accent));
|
||
font: inherit;
|
||
font-size: 0.75rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
.from-contact:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.hidden-file {
|
||
display: none;
|
||
}
|
||
|
||
.pick-lead {
|
||
margin: 0 0 0.75rem;
|
||
color: var(--muted);
|
||
font-size: 0.88rem;
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.pick-choose {
|
||
display: grid;
|
||
gap: 0.55rem;
|
||
}
|
||
|
||
.pick-search {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.65rem;
|
||
padding: 0.45rem 0.65rem;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
}
|
||
|
||
.pick-search input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: 0;
|
||
outline: 0;
|
||
background: transparent;
|
||
font: inherit;
|
||
font-size: 0.88rem;
|
||
color: var(--ink);
|
||
min-height: auto;
|
||
padding: 0;
|
||
}
|
||
|
||
.pick-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
gap: 0.45rem;
|
||
max-height: min(48vh, 360px);
|
||
overflow: auto;
|
||
}
|
||
|
||
.pick-row {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.65rem;
|
||
padding: 0.55rem 0.65rem;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font: inherit;
|
||
text-align: start;
|
||
color: inherit;
|
||
}
|
||
|
||
.pick-row:hover {
|
||
border-color: var(--accent);
|
||
background: var(--accent-soft);
|
||
}
|
||
|
||
.pick-avatar {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 10px;
|
||
background: var(--accent-soft);
|
||
color: var(--accent-deep, var(--accent));
|
||
display: grid;
|
||
place-items: center;
|
||
font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.pick-meta {
|
||
display: grid;
|
||
gap: 0.1rem;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pick-meta strong {
|
||
font-size: 0.88rem;
|
||
}
|
||
|
||
.pick-meta small {
|
||
color: var(--muted);
|
||
font-size: 0.75rem;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pick-empty {
|
||
margin: 0.75rem 0 0;
|
||
color: var(--muted);
|
||
font-size: 0.88rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.create-form input,
|
||
.create-form select {
|
||
min-height: 2.45rem;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
padding: 0 0.75rem;
|
||
font: inherit;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.create-form .ltr {
|
||
direction: ltr;
|
||
text-align: left;
|
||
}
|
||
|
||
.create-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.empty-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
justify-content: center;
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.create-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
:deep(.actions) .btn-full {
|
||
display: none;
|
||
}
|
||
|
||
:deep(.actions) .btn-short {
|
||
display: inline;
|
||
}
|
||
}
|
||
</style>
|