Adding contacts from the phone to the customer list
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { CreatePersonRequest, PersonDto } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
isContactPickerSupported,
|
||||
parseVCardFile,
|
||||
pickPhoneContacts,
|
||||
type PhoneContactDraft,
|
||||
} from '@/lib/phone-contacts';
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
start: [];
|
||||
done: [result: { created: number; skipped: number; failed: number }];
|
||||
fail: [message: string];
|
||||
settled: [];
|
||||
}>();
|
||||
|
||||
const pickerSupported = isContactPickerSupported();
|
||||
const open = ref(false);
|
||||
const fallbackOpen = ref(false);
|
||||
const importing = ref(false);
|
||||
const drafts = ref<PhoneContactDraft[]>([]);
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedCount = computed(
|
||||
() => drafts.value.filter((d) => selected.value.has(d.key)).length,
|
||||
);
|
||||
|
||||
function apiMessage(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;
|
||||
}
|
||||
|
||||
function setDrafts(rows: PhoneContactDraft[]) {
|
||||
drafts.value = rows;
|
||||
selected.value = new Set(rows.map((r) => r.key));
|
||||
open.value = rows.length > 0;
|
||||
fallbackOpen.value = false;
|
||||
if (!rows.length) {
|
||||
emit('fail', 'مخاطبی برای ورود پیدا نشد.');
|
||||
emit('settled');
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(key: string) {
|
||||
const next = new Set(selected.value);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
selected.value = next;
|
||||
}
|
||||
|
||||
function selectAll(on: boolean) {
|
||||
selected.value = on ? new Set(drafts.value.map((d) => d.key)) : new Set();
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
open.value = false;
|
||||
fallbackOpen.value = false;
|
||||
drafts.value = [];
|
||||
selected.value = new Set();
|
||||
importing.value = false;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
closeAll();
|
||||
emit('settled');
|
||||
}
|
||||
|
||||
function openFallback() {
|
||||
fallbackOpen.value = true;
|
||||
}
|
||||
|
||||
async function startPicker() {
|
||||
emit('start');
|
||||
try {
|
||||
if (!pickerSupported) {
|
||||
openFallback();
|
||||
return;
|
||||
}
|
||||
const rows = await pickPhoneContacts();
|
||||
setDrafts(rows);
|
||||
} catch (e) {
|
||||
const name = e instanceof DOMException ? e.name : '';
|
||||
const message = e instanceof Error ? e.message : '';
|
||||
if (name === 'AbortError') {
|
||||
emit('settled');
|
||||
return;
|
||||
}
|
||||
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
|
||||
openFallback();
|
||||
return;
|
||||
}
|
||||
emit('fail', 'انتخاب مخاطب از گوشی ناموفق بود.');
|
||||
emit('settled');
|
||||
}
|
||||
}
|
||||
|
||||
async function onVcfFile(ev: Event) {
|
||||
const el = ev.target as HTMLInputElement;
|
||||
const file = el.files?.[0];
|
||||
el.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const rows = await parseVCardFile(file);
|
||||
setDrafts(rows);
|
||||
} catch {
|
||||
emit('fail', 'خواندن فایل مخاطبین (.vcf) ناموفق بود.');
|
||||
emit('settled');
|
||||
fallbackOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function draftLine(row: PhoneContactDraft) {
|
||||
return [row.mobile, row.phone, row.email].filter(Boolean).join(' · ') || 'بدون شماره/ایمیل';
|
||||
}
|
||||
|
||||
async function importSelected() {
|
||||
const rows = drafts.value.filter((d) => selected.value.has(d.key));
|
||||
if (!rows.length || importing.value) return;
|
||||
importing.value = true;
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
try {
|
||||
for (const row of rows) {
|
||||
const body: CreatePersonRequest = {
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName || undefined,
|
||||
mobile: row.mobile,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
};
|
||||
try {
|
||||
await api.post<PersonDto>('/people', body);
|
||||
created += 1;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 409) {
|
||||
skipped += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
if (failed === 1) {
|
||||
emit('fail', apiMessage(e, `ورود «${row.fullName}» ناموفق بود`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
emit('done', { created, skipped, failed });
|
||||
closeAll();
|
||||
} finally {
|
||||
importing.value = false;
|
||||
emit('settled');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="wrap" @click.stop>
|
||||
<span class="trigger" @click="startPicker">
|
||||
<slot />
|
||||
</span>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".vcf,text/vcard,text/x-vcard"
|
||||
@change="onVcfFile"
|
||||
/>
|
||||
|
||||
<UiModal :open="fallbackOpen" title="ورود از مخاطبین گوشی" @close="cancel">
|
||||
<p class="lead">
|
||||
در این مرورگر انتخاب مستقیم از دفترچه تلفن پشتیبانی نمیشود.
|
||||
از Contacts گوشی یک فایل <strong>.vcf</strong> خروجی بگیرید و اینجا بارگذاری کنید.
|
||||
</p>
|
||||
<ul class="tips">
|
||||
<li>اندروید Chrome معمولاً انتخاب مستقیم مخاطب را دارد.</li>
|
||||
<li>آیفون: Contacts → Share Contact / Export → فایل vCard.</li>
|
||||
</ul>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
|
||||
<UiButton variant="primary" :disabled="importing || disabled" @click="fileInput?.click()">
|
||||
انتخاب فایل .vcf
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="open" title="تأیید مخاطبین" @close="cancel">
|
||||
<p class="lead">
|
||||
{{ toFaDigits(drafts.length) }} مخاطب آماده ورود است. موارد دلخواه را انتخاب کنید.
|
||||
</p>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="link" @click="selectAll(true)">انتخاب همه</button>
|
||||
<button type="button" class="link" @click="selectAll(false)">هیچکدام</button>
|
||||
<span class="count">{{ toFaDigits(selectedCount) }} انتخابشده</span>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li v-for="row in drafts" :key="row.key">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selected.has(row.key)"
|
||||
:disabled="importing"
|
||||
@change="toggle(row.key)"
|
||||
/>
|
||||
<span class="avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
|
||||
<span class="meta">
|
||||
<strong>{{ row.fullName }}</strong>
|
||||
<small>{{ draftLine(row) }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="importing" @click="cancel">انصراف</UiButton>
|
||||
<UiButton
|
||||
v-if="!pickerSupported"
|
||||
variant="ghost"
|
||||
:disabled="importing"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
فایل دیگر
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="importing || !selectedCount"
|
||||
:loading="importing"
|
||||
@click="importSelected"
|
||||
>
|
||||
<UiIcon name="user-plus" :size="16" />
|
||||
ورود {{ toFaDigits(selectedCount) }} نفر
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 0.85rem;
|
||||
color: #64748b;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin: 0;
|
||||
padding: 0 1.1rem 0 0;
|
||||
color: #475569;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-inline-start: auto;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-height: min(48vh, 360px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta strong {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.meta small {
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
/** مخاطب خامِ آمادهی ورود به CRM */
|
||||
|
||||
export type PhoneContactDraft = {
|
||||
key: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
mobile?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
type ContactInfoLike = {
|
||||
name?: string[];
|
||||
email?: string[];
|
||||
tel?: string[];
|
||||
};
|
||||
|
||||
type ContactsManagerLike = {
|
||||
select: (
|
||||
properties: string[],
|
||||
options?: { multiple?: boolean },
|
||||
) => Promise<ContactInfoLike[]>;
|
||||
};
|
||||
|
||||
function contactsApi(): ContactsManagerLike | null {
|
||||
const nav = navigator as Navigator & { contacts?: ContactsManagerLike };
|
||||
if (!nav.contacts || typeof nav.contacts.select !== 'function') return null;
|
||||
return nav.contacts;
|
||||
}
|
||||
|
||||
export function isContactPickerSupported() {
|
||||
return Boolean(contactsApi());
|
||||
}
|
||||
|
||||
function splitName(full: string): { firstName: string; lastName: string } {
|
||||
const parts = full.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return { firstName: 'بدوننام', lastName: '' };
|
||||
if (parts.length === 1) return { firstName: parts[0]!, lastName: '' };
|
||||
return { firstName: parts[0]!, lastName: parts.slice(1).join(' ') };
|
||||
}
|
||||
|
||||
function cleanPhone(raw: string): string {
|
||||
return raw.replace(/[^\d+]/g, '').trim();
|
||||
}
|
||||
|
||||
function looksMobile(raw: string): boolean {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
if (/^09\d{9}$/.test(digits)) return true;
|
||||
if (/^989\d{9}$/.test(digits)) return true;
|
||||
if (/^9\d{9}$/.test(digits)) return true;
|
||||
return /mobile|cell|iphone|موبایل|همراه/i.test(raw);
|
||||
}
|
||||
|
||||
function pickPhones(tels: string[]): { mobile?: string; phone?: string } {
|
||||
const cleaned = tels.map((t) => cleanPhone(t)).filter(Boolean);
|
||||
if (!cleaned.length) return {};
|
||||
const mobile = cleaned.find((t) => looksMobile(t));
|
||||
if (mobile) {
|
||||
const phone = cleaned.find((t) => t !== mobile);
|
||||
return { mobile, phone };
|
||||
}
|
||||
return { mobile: cleaned[0], phone: cleaned[1] };
|
||||
}
|
||||
|
||||
function draftFromParts(input: {
|
||||
fullName: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
tels?: string[];
|
||||
emails?: string[];
|
||||
}): PhoneContactDraft | null {
|
||||
let firstName = (input.firstName ?? '').trim();
|
||||
let lastName = (input.lastName ?? '').trim();
|
||||
const fullName = (input.fullName || `${firstName} ${lastName}`).trim();
|
||||
if (!firstName && !lastName) {
|
||||
const split = splitName(fullName || 'بدوننام');
|
||||
firstName = split.firstName;
|
||||
lastName = split.lastName;
|
||||
}
|
||||
const phones = pickPhones(input.tels ?? []);
|
||||
const email = (input.emails ?? []).map((e) => e.trim()).find(Boolean);
|
||||
if (!phones.mobile && !phones.phone && !email && !fullName) return null;
|
||||
const key = [
|
||||
firstName,
|
||||
lastName,
|
||||
phones.mobile ?? '',
|
||||
phones.phone ?? '',
|
||||
email ?? '',
|
||||
].join('|');
|
||||
return {
|
||||
key,
|
||||
firstName: firstName || 'بدوننام',
|
||||
lastName,
|
||||
fullName: fullName || `${firstName} ${lastName}`.trim(),
|
||||
mobile: phones.mobile,
|
||||
phone: phones.phone,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pickPhoneContacts(): Promise<PhoneContactDraft[]> {
|
||||
const api = contactsApi();
|
||||
if (!api) {
|
||||
throw new Error('CONTACT_PICKER_UNSUPPORTED');
|
||||
}
|
||||
const rows = await api.select(['name', 'email', 'tel'], { multiple: true });
|
||||
const drafts: PhoneContactDraft[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const fullName = (row.name ?? []).map((n) => n.trim()).filter(Boolean).join(' ');
|
||||
const draft = draftFromParts({
|
||||
fullName,
|
||||
tels: row.tel ?? [],
|
||||
emails: row.email ?? [],
|
||||
});
|
||||
if (!draft || seen.has(draft.key)) continue;
|
||||
seen.add(draft.key);
|
||||
drafts.push(draft);
|
||||
}
|
||||
return drafts;
|
||||
}
|
||||
|
||||
function unfoldVcard(text: string): string[] {
|
||||
const raw = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
||||
const lines: string[] = [];
|
||||
for (const line of raw) {
|
||||
if (!line) continue;
|
||||
if (/^[ \t]/.test(line) && lines.length) {
|
||||
lines[lines.length - 1] += line.slice(1);
|
||||
} else {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function parseVcardProp(line: string): { name: string; params: string; value: string } | null {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon < 0) return null;
|
||||
const left = line.slice(0, colon);
|
||||
const value = line.slice(colon + 1);
|
||||
const semi = left.indexOf(';');
|
||||
const name = (semi >= 0 ? left.slice(0, semi) : left).toUpperCase();
|
||||
const params = semi >= 0 ? left.slice(semi + 1) : '';
|
||||
return { name, params, value };
|
||||
}
|
||||
|
||||
/** پارس سادهٔ فایلهای `.vcf` / vCard 2.1–4.0 */
|
||||
export function parseVCard(text: string): PhoneContactDraft[] {
|
||||
const lines = unfoldVcard(text);
|
||||
const drafts: PhoneContactDraft[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
let inCard = false;
|
||||
let fn = '';
|
||||
let firstName = '';
|
||||
let lastName = '';
|
||||
const tels: string[] = [];
|
||||
const emails: string[] = [];
|
||||
|
||||
function flush() {
|
||||
const draft = draftFromParts({
|
||||
fullName: fn,
|
||||
firstName,
|
||||
lastName,
|
||||
tels,
|
||||
emails,
|
||||
});
|
||||
if (draft && !seen.has(draft.key)) {
|
||||
seen.add(draft.key);
|
||||
drafts.push(draft);
|
||||
}
|
||||
fn = '';
|
||||
firstName = '';
|
||||
lastName = '';
|
||||
tels.length = 0;
|
||||
emails.length = 0;
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const upper = line.toUpperCase();
|
||||
if (upper === 'BEGIN:VCARD') {
|
||||
if (inCard) flush();
|
||||
inCard = true;
|
||||
continue;
|
||||
}
|
||||
if (upper === 'END:VCARD') {
|
||||
if (inCard) flush();
|
||||
inCard = false;
|
||||
continue;
|
||||
}
|
||||
if (!inCard) continue;
|
||||
|
||||
const prop = parseVcardProp(line);
|
||||
if (!prop) continue;
|
||||
|
||||
if (prop.name === 'FN') {
|
||||
fn = prop.value.trim();
|
||||
} else if (prop.name === 'N') {
|
||||
const parts = prop.value.split(';');
|
||||
lastName = (parts[0] ?? '').trim();
|
||||
firstName = (parts[1] ?? '').trim();
|
||||
} else if (prop.name === 'TEL') {
|
||||
const tel = cleanPhone(prop.value);
|
||||
if (!tel) continue;
|
||||
if (/CELL|MOBILE|IPHONE|VOICE/i.test(prop.params) || looksMobile(prop.value)) {
|
||||
tels.unshift(tel);
|
||||
} else {
|
||||
tels.push(tel);
|
||||
}
|
||||
} else if (prop.name === 'EMAIL') {
|
||||
const email = prop.value.trim();
|
||||
if (email) emails.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
if (inCard) flush();
|
||||
return drafts;
|
||||
}
|
||||
|
||||
export async function parseVCardFile(file: File): Promise<PhoneContactDraft[]> {
|
||||
const text = await file.text();
|
||||
return parseVCard(text);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiModal,
|
||||
@@ -213,6 +214,31 @@ async function onImportDone(data: ImportContactsResult) {
|
||||
await load();
|
||||
}
|
||||
|
||||
function onPhoneImportStart() {
|
||||
busy.value = true;
|
||||
importMsg.value = '';
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
async function onPhoneImportDone(data: {
|
||||
created: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
}) {
|
||||
importMsg.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 onImportFail(message: string) {
|
||||
error.value = message;
|
||||
}
|
||||
@@ -257,6 +283,20 @@ onMounted(load);
|
||||
<span class="btn-short">ورود</span>
|
||||
</UiButton>
|
||||
</ExcelImportFlow>
|
||||
<PhoneContactsImport
|
||||
v-if="tab === 'people'"
|
||||
: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="contactModal = tab === 'companies' ? 'company' : 'person'"
|
||||
@@ -1023,19 +1063,14 @@ strong.newcomer {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions .excel-btn .btn-full {
|
||||
:deep(.actions) .btn-full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.actions .excel-btn .btn-short {
|
||||
:deep(.actions) .btn-short {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.actions .excel-btn {
|
||||
padding-inline: 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
|
||||
Reference in New Issue
Block a user