feat(web): migrate CRM screens to Ant Design Vue with central theme

Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 19:39:59 +03:30
co-authored by Cursor
parent ab538b8304
commit 4abab55a6b
40 changed files with 7529 additions and 11795 deletions
+370 -520
View File
@@ -2,6 +2,37 @@
import { computed, nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Col as ACol,
Divider as ADivider,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
Modal,
Modal as AModal,
Row as ARow,
Select as ASelect,
Space,
Space as ASpace,
Spin as ASpin,
Textarea as ATextarea,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
import {
ArrowLeftOutlined,
CheckOutlined,
DeleteOutlined,
PhoneOutlined,
PlusOutlined,
SearchOutlined,
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
@@ -19,17 +50,12 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiModal,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
const ASpaceCompact = Space.Compact;
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItem = AList.Item;
const AListItemMeta = AList.Item.Meta;
const props = withDefaults(
defineProps<{
@@ -77,16 +103,21 @@ const loyaltyTierName = ref('');
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('');
const vcfInputKey = ref(0);
const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value),
);
const companyOptions = computed(() => [
{ value: '', label: 'بدون شرکت' },
...companies.value.map((c) => ({ value: c.id, label: c.name })),
]);
const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
);
@@ -147,9 +178,38 @@ function fillFromPhoneContact() {
sourceOpen.value = true;
return;
}
contactFileInput.value?.click();
vcfInputKey.value += 1;
void nextTick(() => triggerVcfPick());
}
const vcfUploadRef = ref<{ $el?: HTMLElement } | null>(null);
function triggerVcfPick() {
const root = vcfUploadRef.value?.$el as HTMLElement | undefined;
const input = root?.querySelector('input[type="file"]') as HTMLInputElement | null;
input?.click();
}
const onVcfBeforeUpload: UploadProps['beforeUpload'] = (file) => {
void (async () => {
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file as File);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
})();
return false;
};
async function pickFromDevice() {
if (pickingContact.value || saving.value) return;
pickingContact.value = true;
@@ -167,7 +227,7 @@ async function pickFromDevice() {
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
contactFileInput.value?.click();
triggerVcfPick();
return;
}
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
@@ -178,28 +238,7 @@ async function pickFromDevice() {
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;
error.value = '';
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
triggerVcfPick();
}
function fill(p: PersonDto) {
@@ -334,14 +373,24 @@ async function submit() {
}
}
async function remove() {
if (!id.value || !confirm('این شخص حذف شود؟')) return;
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
}
function remove() {
if (!id.value) return;
Modal.confirm({
title: 'حذف شخص',
content: 'این شخص حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
async onOk() {
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
return Promise.reject();
}
},
});
}
function cancel() {
@@ -356,498 +405,299 @@ onMounted(load);
</script>
<template>
<section class="crm-page" :class="{ embedded, narrow: !embedded }">
<!-- Desktop Header -->
<UiPageHeader
<div>
<a-flex
v-if="!embedded"
:title="pageTitle"
:back-to="backTo"
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
:style="{ marginBottom: '16px' }"
>
<template #lead>
<span class="crumb">
<div>
<a-typography-text type="secondary">
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</span>
</template>
<template #actions>
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
· {{ pageTitle }}
</a-typography-text>
<a-typography-title :level="3" :style="{ margin: '4px 0 0' }">{{ pageTitle }}</a-typography-title>
</div>
<a-space :size="8" wrap>
<a-button @click="cancel">
<template #icon><ArrowLeftOutlined /></template>
انصراف
</a-button>
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">
<template #icon><DeleteOutlined /></template>
حذف
</UiButton>
</template>
</UiPageHeader>
</a-button>
<a-button type="primary" :loading="saving" @click="submit">
<template #icon><CheckOutlined /></template>
ذخیره
</a-button>
</a-space>
</a-flex>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="crm-form" @submit.prevent="submit">
<UiCard>
<div class="crm-form-grid-2">
<UiField label="نام" for-id="firstName">
<UiInput id="firstName" v-model="firstName" />
</UiField>
<a-spin :spinning="loading">
<a-form v-if="!loading" layout="vertical" @finish="submit">
<a-card :bordered="false">
<a-divider orientation="right">اطلاعات اصلی</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام">
<a-input v-model:value="firstName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="نام خانوادگی">
<a-input v-model:value="lastName" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="موبایل">
<a-space-compact block style="width: 100%">
<a-input v-model:value="mobile" type="tel" style="width: 100%" />
<a-button
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<template #icon><PhoneOutlined /></template>
{{ pickingContact ? '…' : 'از مخاطبین' }}
</a-button>
</a-space-compact>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تلفن">
<a-input v-model:value="phone" type="tel" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ایمیل">
<a-input v-model:value="email" type="email" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="سمت">
<a-input v-model:value="position" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="آدرس وب‌سایت">
<a-input v-model:value="website" dir="ltr" placeholder="https://example.com" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="پیج اینستاگرام">
<a-input v-model:value="instagram" dir="ltr" placeholder="@username یا لینک" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="تاریخ تولد">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="معرف">
<a-input v-model:value="referrer" placeholder="نام معرف" />
</a-form-item>
</a-col>
</a-row>
<UiField label="نام خانوادگی" for-id="lastName">
<UiInput id="lastName" v-model="lastName" />
</UiField>
<a-divider orientation="right">آدرس‌ها</a-divider>
<a-flex vertical :gap="8" :style="{ marginBottom: '8px' }">
<a-flex justify="flex-end">
<a-button type="link" @click="addAddress">
<template #icon><PlusOutlined /></template>
افزودن آدرس
</a-button>
</a-flex>
<a-row v-for="(_, idx) in addresses" :key="idx" :gutter="[8, 0]">
<a-col :flex="'auto'">
<a-input
v-model:value="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
</a-col>
<a-col>
<a-button danger @click="removeAddress(idx)">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-col>
</a-row>
</a-flex>
<UiField label="موبایل" for-id="mobile">
<template #label-extra>
<button
type="button"
class="from-contact"
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<UiIcon name="phone" :size="14" />
{{ pickingContact ? '…' : 'از مخاطبین' }}
</button>
</template>
<UiInput id="mobile" v-model="mobile" type="tel" />
</UiField>
<input
ref="contactFileInput"
class="hidden-file"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onContactVcfFile"
/>
<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>
<UiField label="تلفن" for-id="phone">
<UiInput id="phone" v-model="phone" type="tel" />
</UiField>
<UiField label="ایمیل" for-id="email">
<UiInput id="email" v-model="email" type="email" />
</UiField>
<UiField label="سمت" for-id="position">
<UiInput id="position" v-model="position" />
</UiField>
<UiField label="آدرس وب‌سایت" for-id="website">
<UiInput id="website" v-model="website" dir="ltr" placeholder="https://example.com" />
</UiField>
<UiField label="پیج اینستاگرام" for-id="instagram">
<UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" />
</UiField>
<UiField label="تاریخ تولد" for-id="birth-date">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</UiField>
<UiField label="معرف" for-id="referrer">
<UiInput id="referrer" v-model="referrer" placeholder="نام معرف" />
</UiField>
<div class="crm-col-span-full addresses">
<div class="addresses-head">
<span>آدرس‌ها</span>
<button type="button" class="add-addr" @click="addAddress">+ افزودن آدرس</button>
</div>
<div v-for="(_, idx) in addresses" :key="idx" class="addr-row">
<UiInput
:id="`address-${idx}`"
v-model="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
<button type="button" class="rm-addr" aria-label="حذف آدرس" @click="removeAddress(idx)">
×
</button>
</div>
</div>
<div class="crm-col-span-full">
<UiField label="وضعیت مشتری">
<div class="moods">
<button
v-for="item in PERSON_MOODS"
:key="item"
class="mood-btn"
type="button"
:class="{ on: mood === item }"
<a-divider orientation="right">وضعیت و شرکت</a-divider>
<a-form-item label="وضعیت مشتری">
<a-row :gutter="[8, 8]">
<a-col v-for="item in PERSON_MOODS" :key="item" :xs="12" :sm="6">
<a-button
block
:type="mood === item ? 'primary' : 'default'"
:ghost="mood === item"
:style="{ height: 'auto', paddingBlock: '12px' }"
@click="mood = item"
>
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</button>
<a-flex vertical align="center" :gap="6">
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</a-flex>
</a-button>
</a-col>
</a-row>
</a-form-item>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="شرکت">
<a-select
v-model:value="companyId"
show-search
option-filter-prop="label"
:options="companyOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="برچسب‌ها">
<a-input
v-model:value="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea v-model:value="notes" :rows="3" />
</a-form-item>
</a-col>
</a-row>
<a-card
v-if="!isNew"
:bordered="true"
:style="{ marginTop: '8px' }"
type="inner"
>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8">
<div>
<a-typography-text type="secondary">باشگاه مشتریان</a-typography-text>
<div>
<a-typography-text strong>
{{ toFaDigits(loyaltyPoints) }} امتیاز
<template v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</template>
</a-typography-text>
</div>
</div>
</UiField>
</div>
<RouterLink :to="{ name: 'loyalty' }">
<a-button type="link">مدیریت باشگاه</a-button>
</RouterLink>
</a-flex>
</a-card>
<div class="crm-col-span-full">
<UiField label="شرکت" for-id="companyId">
<UiSelect id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
</div>
<CustomFieldsSection entity-type="person" v-model="customFields" />
</a-card>
<div class="crm-col-span-full">
<UiField label="برچسب‌ها" for-id="tags">
<UiInput
id="tags"
v-model="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</UiField>
</div>
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<div class="crm-col-span-full">
<UiField label="یادداشت" for-id="notes">
<UiTextarea id="notes" v-model="notes" :rows="3" />
</UiField>
</div>
<a-alert
v-if="error"
type="error"
show-icon
:message="error"
:style="{ marginTop: '16px' }"
/>
<div v-if="!isNew" class="crm-col-span-full loyalty-card">
<div class="loyalty-info">
<span class="muted">باشگاه مشتریان</span>
<strong>{{ loyaltyPoints }} امتیاز<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span></strong>
</div>
<RouterLink class="loyalty-link" :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
</div>
</div>
</UiCard>
<a-space
v-if="embedded"
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" :loading="saving" @click="submit">ذخیره</a-button>
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
</a-space>
<CustomFieldsSection entity-type="person" v-model="customFields" />
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<a-space
v-else
wrap
:size="8"
:style="{ marginTop: '16px' }"
>
<a-button type="primary" html-type="submit" :loading="saving">ذخیره</a-button>
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">حذف</a-button>
</a-space>
</a-form>
</a-spin>
<p v-if="error" class="crm-field-error">{{ error }}</p>
<a-upload
ref="vcfUploadRef"
:key="vcfInputKey"
:show-upload-list="false"
accept=".vcf,text/vcard,text/x-vcard"
:before-upload="onVcfBeforeUpload"
:style="{ display: 'none' }"
>
<span />
</a-upload>
<div class="mobile-actions mob-only">
<UiButton variant="primary" block type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton v-if="!isNew" variant="danger" block type="button" :disabled="saving" @click="remove">
حذف
</UiButton>
</div>
</form>
<a-modal
:open="sourceOpen"
title="از مخاطبین"
:footer="null"
destroy-on-close
@cancel="closeSource"
>
<a-typography-paragraph type="secondary">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</a-typography-paragraph>
<a-flex vertical :gap="8">
<a-button type="primary" block :loading="pickingContact" @click="pickFromDevice">
<template #icon><PhoneOutlined /></template>
از دفترچه تلفن
</a-button>
<a-button block :disabled="pickingContact" @click="pickFromVcf">از فایل .vcf</a-button>
<a-button type="link" @click="closeSource">انصراف</a-button>
</a-flex>
</a-modal>
<!-- Embedded Modal Actions (Fixed at bottom) -->
<div v-if="embedded" class="embed-actions">
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
</div>
</section>
<a-modal
:open="pickOpen"
title="انتخاب مخاطب"
destroy-on-close
@cancel="closePick"
>
<a-typography-paragraph type="secondary">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</a-typography-paragraph>
<a-input
v-model:value="pickQuery"
allow-clear
placeholder="جستجو نام یا موبایل…"
:style="{ marginBottom: '12px' }"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-list
v-if="visiblePickDrafts.length"
:data-source="visiblePickDrafts"
:style="{ maxHeight: '360px', overflow: 'auto' }"
>
<template #renderItem="{ item }">
<a-list-item>
<a-button type="text" block style="text-align: start; height: auto" @click="choosePick(item)">
<a-list-item-meta :title="item.fullName" :description="draftLine(item)" />
</a-button>
</a-list-item>
</template>
</a-list>
<a-typography-text v-else type="secondary">مخاطبی با این جستجو پیدا نشد.</a-typography-text>
<template #footer>
<a-button @click="closePick">انصراف</a-button>
<a-button @click="triggerVcfPick">فایل دیگر</a-button>
</template>
</a-modal>
</div>
</template>
<style scoped>
.crumb {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--muted);
font-size: var(--fs-xs);
}
.crumb a {
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.embed-actions {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.addresses {
display: grid;
gap: var(--sp-2);
}
.addresses-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.add-addr {
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
}
.addr-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
}
.rm-addr {
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.rm-addr:hover {
color: #b91c1c;
border-color: #fecaca;
}
.moods {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.mood-btn {
display: grid;
justify-items: center;
gap: 4px;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
font: inherit;
color: var(--muted);
font-size: var(--fs-xs);
transition: all var(--transition-fast);
}
.mood-btn.on {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.loyalty-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
background: var(--surface-subtle);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.loyalty-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.loyalty-link {
color: var(--accent-deep);
font-weight: var(--fw-semibold);
font-size: var(--fs-xs);
}
.mobile-actions {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.from-contact {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
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: var(--fs-sm);
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: var(--radius);
background: var(--surface);
}
.pick-search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.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: var(--radius);
background: var(--surface);
cursor: pointer;
font: inherit;
text-align: start;
color: inherit;
}
.pick-row:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.pick-avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
display: grid;
place-items: center;
font-weight: var(--fw-bold);
flex-shrink: 0;
}
.pick-meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.pick-meta strong {
font-size: var(--fs-sm);
}
.pick-meta small {
color: var(--muted);
font-size: var(--fs-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pick-empty {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
}
@media (min-width: 901px) {
.mob-only {
display: none !important;
}
}
</style>