Files
CRM_Montzar/apps/web/src/views/ContactsView.vue
T
s.esmaeili 1ae87c97b7 feat(ui): standardize design system, modals, sidebar, and form inputs
- Implement unified design system tokens, 10px radius, and IRANSans typography
- Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout
- Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions
- Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components
- Add draggable bottom-left QuickCreate floating action button
2026-09-12 01:33:07 +03:30

1116 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import {
UiButton,
UiModal,
UiPageHeader,
UiPagination,
UiEmptyState,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import PersonFormView from '@/views/PersonFormView.vue';
import CompanyFormView from '@/views/CompanyFormView.vue';
import type {
CompanyDto,
ImportContactsResult,
PaginatedResponse,
PersonDto,
PersonMood,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
type Tab = 'people' | 'companies';
type SortKey = 'name' | 'newest';
const PAGE_SIZE = 10;
const route = useRoute();
const router = useRouter();
const tab = computed<Tab>(() =>
route.query.tab === 'companies' ? 'companies' : 'people',
);
const page = computed(() => {
const raw = Number(route.query.page);
return Number.isFinite(raw) && raw > 0 ? raw : 1;
});
const sort = computed<SortKey>(() => (route.query.sort === 'newest' ? 'newest' : 'name'));
const moodFilter = computed<PersonMood | 'all'>(() => {
const value = typeof route.query.mood === 'string' ? route.query.mood : 'all';
return PERSON_MOODS.includes(value as PersonMood) ? (value as PersonMood) : 'all';
});
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const importMsg = ref('');
const people = ref<PaginatedResponse<PersonDto> | null>(null);
const companies = ref<PaginatedResponse<CompanyDto> | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const menuId = ref<string | null>(null);
const sortOpen = ref(false);
const filterOpen = ref(false);
const contactModal = ref<'person' | 'company' | null>(null);
const total = computed(() =>
tab.value === 'people' ? people.value?.total ?? 0 : companies.value?.total ?? 0,
);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
function moodOf(person: PersonDto): PersonMood {
return person.mood || 'newcomer';
}
function moodLabel(mood: PersonMood) {
return PERSON_MOOD_LABELS[mood];
}
function formatDate(value: string) {
try {
return formatJalaliNumeric(new Date(value));
} catch {
return value;
}
}
function closePopups() {
menuId.value = null;
sortOpen.value = false;
filterOpen.value = false;
}
async function load() {
loading.value = true;
error.value = '';
closePopups();
try {
if (tab.value === 'people') {
const { data } = await api.get<PaginatedResponse<PersonDto>>('/people', {
params: {
q: q.value || undefined,
page: page.value,
limit: PAGE_SIZE,
sort: sort.value,
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
},
});
people.value = data;
} else {
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', {
params: { q: q.value || undefined, page: page.value, limit: PAGE_SIZE },
});
companies.value = data;
}
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری مخاطبین ناموفق بود.';
}
} finally {
loading.value = false;
}
}
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: 'contacts', query });
}
function setTab(next: Tab) {
patchQuery({
tab: next === 'people' ? undefined : next,
page: undefined,
mood: next === 'people' ? (moodFilter.value === 'all' ? undefined : moodFilter.value) : undefined,
});
}
function onSearch() {
patchQuery({ q: q.value.trim() || undefined, page: undefined });
}
function setSort(next: SortKey) {
sortOpen.value = false;
patchQuery({ sort: next === 'name' ? undefined : next, page: undefined });
}
function setMood(next: PersonMood | 'all') {
filterOpen.value = false;
patchQuery({ mood: next === 'all' ? undefined : next, page: undefined });
}
function setPage(next: number) {
if (next < 1 || next > pageCount.value) return;
patchQuery({ page: next === 1 ? undefined : String(next) });
}
async function setMoodFor(person: PersonDto, mood: PersonMood) {
menuId.value = null;
try {
await api.patch(`/people/${person.id}`, { mood });
await load();
} catch {
error.value = 'تغییر وضعیت مشتری ناموفق بود.';
}
}
async function exportExcel() {
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const path = tab.value === 'people' ? '/people/export' : '/companies/export';
const { data } = await api.get<Blob>(path, {
params: { q: q.value || undefined },
responseType: 'blob',
});
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download =
tab.value === 'people' ? `${brand.filePrefix}-people.xlsx` : `${brand.filePrefix}-companies.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
error.value = 'خروجی اکسل ناموفق بود.';
} finally {
busy.value = false;
}
}
function pickImportFile() {
fileInput.value?.click();
}
async function onImportFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
busy.value = true;
importMsg.value = '';
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const path = tab.value === 'people' ? '/people/import' : '/companies/import';
const { data } = await api.post<ImportContactsResult>(path, form, {
headers: { 'Content-Type': undefined },
});
importMsg.value = `وارد شد: ${data.created} · تکراری رد شد: ${data.skipped}${
data.errors.length ? ` · خطا: ${data.errors.length}` : ''
}`;
if (data.errors.length) {
const sample = data.errors
.slice(0, 3)
.map((e) => `ردیف ${e.row}: ${e.message}`)
.join(' | ');
error.value = sample;
}
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ایمپورت ناموفق بود');
} else {
error.value = 'ایمپورت ناموفق بود';
}
} finally {
busy.value = false;
}
}
watch(
() => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood],
() => {
q.value = typeof route.query.q === 'string' ? route.query.q : '';
void load();
},
);
onMounted(load);
</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>
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="pickImportFile">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</UiButton>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<UiButton
variant="primary"
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
>
<UiIcon name="plus" :size="16" />
{{ tab === 'companies' ? 'شرکت جدید' : 'شخص جدید' }}
</UiButton>
</template>
</UiPageHeader>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'people' }" @click.stop="setTab('people')">
<UiIcon name="user" :size="16" />
اشخاص
<em v-if="people">{{ toFaDigits(people.total) }}</em>
</button>
<button type="button" :class="{ on: tab === 'companies' }" @click.stop="setTab('companies')">
<UiIcon name="building" :size="16" />
شرکت‌ها
<em v-if="companies">{{ toFaDigits(companies.total) }}</em>
</button>
</nav>
<div class="toolbar">
<form class="search" @submit.prevent="onSearch">
<UiIcon name="search" :size="16" />
<input
v-model="q"
type="search"
:placeholder="tab === 'people' ? 'جستجو در اشخاص…' : 'جستجو در شرکت‌ها…'"
/>
</form>
<div class="toolbar-tools">
<div class="pop">
<button class="tool" type="button" @click.stop="sortOpen = !sortOpen; filterOpen = false">
<UiIcon name="trend" :size="16" />
<span class="tool-label">مرتب‌سازی</span>
</button>
<ul v-if="sortOpen" class="menu">
<li><button type="button" :class="{ on: sort === 'name' }" @click.stop="setSort('name')">نام</button></li>
<li><button type="button" :class="{ on: sort === 'newest' }" @click.stop="setSort('newest')">جدیدترین</button></li>
</ul>
</div>
<div v-if="tab === 'people'" class="pop">
<button class="tool" type="button" @click.stop="filterOpen = !filterOpen; sortOpen = false">
<UiIcon name="filter" :size="16" />
<span class="tool-label">فیلتر</span>
</button>
<ul v-if="filterOpen" class="menu">
<li>
<button type="button" :class="{ on: moodFilter === 'all' }" @click.stop="setMood('all')">همه</button>
</li>
<li v-for="mood in PERSON_MOODS" :key="mood">
<button type="button" :class="{ on: moodFilter === mood }" @click.stop="setMood(mood)">
{{ moodLabel(mood) }}
</button>
</li>
</ul>
</div>
</div>
</div>
<p v-if="importMsg" class="ok">{{ importMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else-if="tab === 'people'">
<div v-if="people?.items.length" class="cards">
<article v-for="p in people.items" :key="`m-${p.id}`" class="m-card">
<CustomerMoodFace :mood="moodOf(p)" :size="48" />
<div class="m-body">
<div class="m-top">
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
<button class="more" type="button" @click.stop="menuId = menuId === p.id ? null : p.id">
<UiIcon name="dots-v" :size="16" />
</button>
</div>
<p class="role">
{{ [p.position, p.company?.name].filter(Boolean).join(' • ') || 'بدون سمت' }}
</p>
<p v-if="p.mobile || p.phone" class="line" dir="ltr">
<UiIcon name="phone" :size="13" />
<span>{{ p.mobile || p.phone }}</span>
</p>
<p v-if="p.email" class="line" dir="ltr">
<UiIcon name="mail" :size="13" />
<span>{{ p.email }}</span>
</p>
<span class="badge" :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</span>
</div>
<ul v-if="menuId === p.id" class="menu row-menu">
<li>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
</li>
<li>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
</li>
<li v-for="mood in PERSON_MOODS" :key="mood">
<button type="button" @click.stop="setMoodFor(p, mood)">{{ moodLabel(mood) }}</button>
</li>
</ul>
</article>
</div>
<div v-if="people?.items.length" class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>وضعیت</th>
<th>نام شخص</th>
<th>سمت</th>
<th>شرکت</th>
<th>تلفن</th>
<th>ایمیل</th>
<th>تاریخ ثبت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in people.items" :key="p.id">
<td>
<div class="status">
<CustomerMoodFace :mood="moodOf(p)" :size="40" />
<strong :class="moodOf(p)">{{ moodLabel(moodOf(p)) }}</strong>
<i class="dot" :class="moodOf(p)" />
</div>
</td>
<td>
<RouterLink class="name" :to="{ name: 'person-detail', params: { id: p.id } }">
{{ p.fullName }}
</RouterLink>
</td>
<td>{{ p.position || '—' }}</td>
<td>
<RouterLink
v-if="p.company"
:to="{ name: 'company-detail', params: { id: p.company.id } }"
>
{{ p.company.name }}
</RouterLink>
<span v-else class="muted">—</span>
</td>
<td>{{ p.mobile || p.phone || '—' }}</td>
<td>
<span class="mail">
{{ p.email || '—' }}
<UiIcon v-if="p.email" name="mail" :size="14" />
</span>
</td>
<td>{{ formatDate(p.createdAt) }}</td>
<td class="ops">
<button type="button" class="more" @click.stop="menuId = menuId === p.id ? null : p.id">
<UiIcon name="dots-v" :size="16" />
</button>
<ul v-if="menuId === p.id" class="menu row-menu">
<li>
<RouterLink :to="{ name: 'person-detail', params: { id: p.id } }">پرونده</RouterLink>
</li>
<li>
<RouterLink :to="{ name: 'person-edit', params: { id: p.id } }">ویرایش</RouterLink>
</li>
<li v-for="mood in PERSON_MOODS" :key="mood">
<button type="button" @click.stop="setMoodFor(p, mood)">{{ moodLabel(mood) }}</button>
</li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
<UiEmptyState
v-else
title="شخصی ثبت نشده است."
description="اولین مخاطب یا مشتری را اضافه کنید."
/>
</template>
<template v-else>
<div v-if="companies?.items.length" class="cards">
<article v-for="c in companies.items" :key="`mc-${c.id}`" class="m-card">
<span class="co-ico"><UiIcon name="building" :size="22" /></span>
<div class="m-body">
<div class="m-top">
<RouterLink class="name" :to="{ name: 'company-detail', params: { id: c.id } }">
{{ c.name }}
</RouterLink>
</div>
<p v-if="c.phone" class="line" dir="ltr">
<UiIcon name="phone" :size="13" />
<span>{{ c.phone }}</span>
</p>
<p v-if="c.email" class="line" dir="ltr">
<UiIcon name="mail" :size="13" />
<span>{{ c.email }}</span>
</p>
<p class="role">افراد: {{ toFaDigits(c.peopleCount) }}</p>
</div>
</article>
</div>
<div v-if="companies?.items.length" class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>نام شرکت</th>
<th>تلفن</th>
<th>ایمیل</th>
<th>افراد</th>
</tr>
</thead>
<tbody>
<tr v-for="c in companies.items" :key="c.id">
<td>
<RouterLink class="name" :to="{ name: 'company-detail', params: { id: c.id } }">
{{ c.name }}
</RouterLink>
</td>
<td>{{ c.phone || '—' }}</td>
<td>{{ c.email || '—' }}</td>
<td>{{ toFaDigits(c.peopleCount) }}</td>
</tr>
</tbody>
</table>
</div>
<UiEmptyState
v-else
title="شرکتی ثبت نشده است."
description="اولین شرکت یا سازمان را اضافه کنید."
/>
</template>
<UiPagination
v-if="total"
:page="page"
:total-pages="pageCount"
:total-items="total"
@update:page="setPage"
/>
<UiModal
:open="Boolean(contactModal)"
:title="contactModal === 'company' ? 'شرکت جدید' : 'شخص جدید'"
@close="contactModal = null"
>
<PersonFormView
v-if="contactModal === 'person'"
embedded
@saved="contactModal = null; void load()"
@cancel="contactModal = null"
/>
<CompanyFormView
v-else-if="contactModal === 'company'"
embedded
@saved="contactModal = null; void load()"
@cancel="contactModal = null"
/>
</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;
}
.page > * {
min-width: 0;
max-width: 100%;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 0.75rem 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.head-copy {
min-width: 0;
flex: 1 1 12rem;
}
h1 {
margin: 0;
font-size: clamp(1.3rem, 4vw, 1.7rem);
}
.lead,
.muted,
.role,
.line {
color: #64748b;
overflow-wrap: anywhere;
word-break: break-word;
}
.lead {
margin: 0.25rem 0 0;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
flex: 0 1 auto;
min-width: 0;
}
.btn-short {
display: none;
}
.add-btn {
width: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
flex-shrink: 0;
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.28);
}
.tabs {
display: flex;
gap: 0.35rem 1.2rem;
border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tabs button {
border: 0;
background: none;
padding: 0.55rem 0.1rem;
font: inherit;
color: #94a3b8;
display: inline-flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.tabs .on {
color: #0f766e;
font-weight: 600;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.tabs em {
font-style: normal;
font-size: 0.75rem;
color: #94a3b8;
}
.toolbar {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.toolbar-tools {
display: flex;
gap: 0.45rem;
align-items: center;
flex-shrink: 0;
}
.search {
flex: 1 1 14rem;
min-width: 180px;
max-width: 400px;
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
min-height: var(--control-h);
height: var(--control-h);
color: var(--muted);
box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
padding: 0;
}
.tool {
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
padding: 0 0.75rem;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink-secondary);
cursor: pointer;
white-space: nowrap;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.tool:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.pop {
position: relative;
}
.menu {
position: absolute;
inset-inline-start: 0;
top: calc(100% + 6px);
z-index: 8;
margin: 0;
padding: 0.35rem;
list-style: none;
background: #fff;
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
min-width: min(12rem, calc(100vw - 2rem));
max-width: calc(100vw - 1.5rem);
}
.menu button,
.menu a {
width: 100%;
display: block;
border: 0;
background: none;
padding: 0.5rem 0.65rem;
border-radius: var(--radius);
font: inherit;
text-align: start;
color: inherit;
cursor: pointer;
}
.menu .on,
.menu button:hover,
.menu a:hover {
background: #f0fdfa;
color: #0f766e;
}
.ok {
margin: 0;
color: #0f766e;
}
.error {
margin: 0;
color: #b91c1c;
}
.table-wrap {
background: #fff;
border-radius: var(--radius);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.data-table {
width: 100%;
border-collapse: collapse;
min-width: 640px;
}
th,
td {
text-align: start;
padding: 0.85rem 1rem;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
th {
font-size: 0.8rem;
color: #94a3b8;
background: #f8fafc;
}
.status {
display: flex;
align-items: center;
gap: 0.55rem;
}
.status strong {
font-size: 0.82rem;
}
.name {
color: #0f766e;
font-weight: 600;
min-width: 0;
overflow-wrap: anywhere;
}
.mail {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.ops {
position: relative;
}
.more {
border: 0;
background: none;
color: #94a3b8;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--radius);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
}
.dot.happy,
strong.happy {
color: #7c3aed;
}
.dot.happy {
background: #8b5cf6;
}
.dot.unhappy,
strong.unhappy {
color: #e11d48;
}
.dot.unhappy {
background: #fb7185;
}
.dot.special,
strong.special {
color: #d97706;
}
.dot.special {
background: #f59e0b;
}
.dot.newcomer,
strong.newcomer {
color: #0f766e;
}
.cards {
display: none;
gap: 0.7rem;
}
.m-card {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.7rem;
align-items: start;
background: #fff;
border-radius: var(--radius);
padding: 0.85rem;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
min-width: 0;
overflow: hidden;
}
.m-top {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.m-top .name {
flex: 1 1 auto;
min-width: 0;
line-height: 1.35;
}
.m-body {
min-width: 0;
display: grid;
gap: 0.28rem;
}
.role,
.line {
margin: 0;
font-size: 0.8rem;
display: flex;
align-items: flex-start;
gap: 0.3rem;
min-width: 0;
}
.line :deep(svg),
.role :deep(svg) {
flex-shrink: 0;
margin-top: 0.15rem;
}
.line span {
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.badge {
justify-self: start;
border-radius: 999px;
padding: 0.28rem 0.6rem;
font-size: 0.72rem;
font-weight: 600;
margin-top: 0.25rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge.newcomer {
background: #ecfdf5;
color: #0f766e;
}
.badge.happy {
background: #f3e8ff;
color: #7c3aed;
}
.badge.unhappy {
background: #ffe4e6;
color: #e11d48;
}
.badge.special {
background: #fef3c7;
color: #d97706;
}
.co-ico {
width: 48px;
height: 48px;
border-radius: 50%;
display: grid;
place-items: center;
background: #ecfdf5;
color: #0f766e;
flex-shrink: 0;
}
.row-menu {
inset-inline-start: auto;
inset-inline-end: 0;
top: 2.4rem;
}
.empty {
margin: 0;
padding: 1.4rem;
color: #64748b;
background: #fff;
border-radius: var(--radius);
}
.pager {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
color: #64748b;
font-size: 0.8rem;
}
.pages {
display: flex;
gap: 0.3rem;
align-items: center;
flex-wrap: wrap;
}
.pages button {
min-width: 32px;
height: 32px;
border: 0;
background: #fff;
border-radius: var(--radius);
cursor: pointer;
}
.pages button.on {
background: #14b8a6;
color: #fff;
}
.pages button:disabled {
opacity: 0.4;
}
@media (max-width: 900px) {
.page {
gap: 0.85rem;
}
.page-head {
align-items: center;
}
.actions .excel-btn .btn-full {
display: none;
}
.actions .excel-btn .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);
}
.toolbar {
flex-direction: column;
align-items: stretch;
}
.search {
flex: 0 0 auto;
width: 100%;
}
.toolbar-tools {
width: 100%;
}
.toolbar-tools .pop {
flex: 1 1 auto;
}
.toolbar-tools .tool {
width: 100%;
justify-content: center;
}
.table-wrap {
display: none;
}
.cards {
display: grid;
}
.pager {
flex-direction: column;
align-items: stretch;
text-align: center;
gap: 0.65rem;
}
.pages {
justify-content: center;
}
}
@media (max-width: 520px) {
.actions .excel-btn {
display: none;
}
.tool-label {
display: none;
}
.toolbar-tools .tool {
width: auto;
flex: 1 1 auto;
min-width: 44px;
justify-content: center;
padding-inline: 0.65rem;
}
.m-card {
padding: 0.75rem;
gap: 0.6rem;
}
}
</style>