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

3000 lines
75 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, 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>