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

3000 lines
75 KiB
Vue
Raw Normal View History

<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>