Files
crm/apps/web/src/views/ContactsView.vue
T

1078 lines
25 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.
This file contains Unicode characters that might be confused with other characters. 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 UiIcon from '@/components/UiIcon.vue';
import type {
CompanyDto,
ImportContactsResult,
PaginatedResponse,
PersonDto,
PersonMood,
} 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 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)));
const pageFrom = computed(() => (total.value ? (page.value - 1) * PAGE_SIZE + 1 : 0));
const pageTo = computed(() => Math.min(page.value * PAGE_SIZE, total.value));
const pageList = computed(() => {
const count = pageCount.value;
const current = page.value;
const pages: number[] = [];
for (let i = 1; i <= count; i += 1) {
if (i === 1 || i === count || Math.abs(i - current) <= 2) pages.push(i);
else if (pages[pages.length - 1] !== -1) pages.push(-1);
}
return pages;
});
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' ? 'mento-people.xlsx' : 'mento-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">
<header class="page-head">
<div class="head-copy">
<h1>مخاطبین</h1>
<p class="lead">مدیریت و مشاهده اشخاص و شرکت‌ها</p>
</div>
<div class="actions">
<button class="btn btn-ghost excel-btn" type="button" :disabled="busy" @click.stop="exportExcel">
<span class="btn-full">خروجی اکسل</span>
<span class="btn-short">خروجی</span>
</button>
<button class="btn btn-ghost excel-btn" type="button" :disabled="busy" @click.stop="pickImportFile">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</button>
<input
ref="fileInput"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
@change="onImportFile"
/>
<RouterLink
class="add-btn"
:to="tab === 'companies' ? { name: 'company-new' } : { name: 'person-new' }"
:title="tab === 'companies' ? 'شرکت جدید' : 'شخص جدید'"
>
<UiIcon name="plus" :size="22" />
</RouterLink>
</div>
</header>
<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>
<p v-else class="empty">شخصی ثبت نشده. اولین نفر را اضافه کنید.</p>
</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>
<p v-else class="empty">شرکتی ثبت نشده. اولین شرکت را اضافه کنید.</p>
</template>
<footer v-if="total" class="pager">
<span>
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از {{ toFaDigits(total) }} مورد
</span>
<div class="pages">
<button type="button" :disabled="page <= 1" @click="setPage(page - 1)">‹</button>
<template v-for="(n, idx) in pageList" :key="`${n}-${idx}`">
<span v-if="n < 0">…</span>
<button v-else type="button" :class="{ on: n === page }" @click="setPage(n)">
{{ toFaDigits(n) }}
</button>
</template>
<button type="button" :disabled="page >= pageCount" @click="setPage(page + 1)">›</button>
</div>
</footer>
</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: 46px;
height: 46px;
border-radius: 50%;
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: 800;
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 12rem;
min-width: 0;
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0 0.75rem;
min-height: 44px;
color: #94a3b8;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font: inherit;
color: #0f172a;
}
.tool {
min-height: 44px;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 12px;
padding: 0 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
color: #475569;
cursor: pointer;
white-space: nowrap;
}
.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: 12px;
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: 8px;
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: 18px;
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: 800;
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: 8px;
}
.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: 18px;
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: 800;
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: 16px;
}
.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: 8px;
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: 40px;
height: 40px;
}
.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>