feat(contacts): compact layout, mobile filter sheet and redesigned cards

This commit is contained in:
2026-10-21 20:01:16 +03:30
parent 1c2da34983
commit 23ae273232
2 changed files with 815 additions and 412 deletions
+137 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import type { CustomerGroupDto, PersonImportSourceStatDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { groupSwatch } from '@/lib/customer-groups';
@@ -21,15 +21,27 @@ const props = defineProps<{
groups: CustomerGroupDto[];
sourceStats: PersonImportSourceStatDto[];
canBehavior: boolean;
/** اکشن‌شیت پایین صفحه (موبایل) به‌جای پاپ‌اور */
sheet?: boolean;
/** اگر داده شود بخش مرتب‌سازی داخل همین پنل نمایش داده می‌شود */
sort?: string;
sortOptions?: Array<{ value: string; label: string }>;
}>();
const emit = defineEmits<{ apply: [value: ContactsFilterValue]; close: [] }>();
const emit = defineEmits<{
apply: [value: ContactsFilterValue, sort?: string];
close: [];
}>();
const draft = reactive<ContactsFilterValue>({ groups: [], sources: [], moods: [], disc: '' });
const draftSort = ref('');
const groupQuery = ref('');
const GROUP_SEARCH_MIN = 8;
const DISC_TYPES = ['D', 'I', 'S', 'C'];
const hasSort = computed(() => Boolean(props.sortOptions?.length));
const defaultSort = computed(() => props.sortOptions?.[0]?.value ?? '');
watch(
() => props.open,
(open) => {
@@ -38,11 +50,18 @@ watch(
draft.sources = [...props.value.sources];
draft.moods = [...props.value.moods];
draft.disc = props.value.disc;
draftSort.value = props.sort ?? defaultSort.value;
groupQuery.value = '';
},
{ immediate: true },
);
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape' && props.open && props.sheet) emit('close');
}
onMounted(() => document.addEventListener('keydown', onKey));
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
const isPeople = computed(() => props.tab === 'people');
function groupCount(g: CustomerGroupDto) {
@@ -58,7 +77,8 @@ const filteredGroups = computed(() => {
const draftCount = computed(
() =>
draft.groups.length +
(isPeople.value ? draft.sources.length + draft.moods.length + (draft.disc ? 1 : 0) : 0),
(isPeople.value ? draft.sources.length + draft.moods.length + (draft.disc ? 1 : 0) : 0) +
(hasSort.value && draftSort.value !== defaultSort.value ? 1 : 0),
);
function toggle<T extends string>(list: T[], value: T) {
@@ -72,26 +92,58 @@ function clearAll() {
draft.sources = [];
draft.moods = [];
draft.disc = '';
if (hasSort.value) draftSort.value = defaultSort.value;
}
function apply() {
emit('apply', {
groups: [...draft.groups],
sources: [...draft.sources],
moods: [...draft.moods],
disc: draft.disc,
});
emit(
'apply',
{
groups: [...draft.groups],
sources: [...draft.sources],
moods: [...draft.moods],
disc: draft.disc,
},
hasSort.value ? draftSort.value : undefined,
);
}
</script>
<template>
<div v-if="open" class="panel" role="dialog" aria-label="فیلترها" @click.stop>
<Teleport to="body" :disabled="!sheet">
<div v-if="open && sheet" class="sheet-dim" @click="emit('close')" />
<div
v-if="open"
class="panel"
:class="{ sheet }"
role="dialog"
:aria-modal="sheet ? 'true' : undefined"
aria-label="فیلترها"
@click.stop
>
<span v-if="sheet" class="grip" aria-hidden="true" />
<header class="panel-head">
<strong>فیلترها</strong>
<strong>{{ hasSort ? 'فیلتر و مرتب‌سازی' : 'فیلترها' }}</strong>
<button type="button" class="link" :disabled="!draftCount" @click="clearAll">پاک کردن همه</button>
</header>
<div class="panel-body">
<section v-if="hasSort" class="sec">
<h4>مرتب‌سازی</h4>
<div class="pills">
<button
v-for="o in sortOptions"
:key="o.value"
type="button"
class="pill"
:class="{ on: draftSort === o.value }"
@click="draftSort = o.value"
>
{{ o.label }}
</button>
</div>
</section>
<section class="sec">
<h4>
گروه
@@ -176,10 +228,11 @@ function apply() {
<footer class="panel-foot">
<UiButton variant="ghost" size="sm" @click="emit('close')">انصراف</UiButton>
<UiButton variant="primary" size="sm" @click="apply">
اعمال فیلتر<template v-if="draftCount"> ({{ toFaDigits(draftCount) }})</template>
اعمال<template v-if="draftCount"> ({{ toFaDigits(draftCount) }})</template>
</UiButton>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
@@ -361,4 +414,75 @@ function apply() {
color: #0f766e;
font-weight: 600;
}
/* ===== اکشن‌شیت موبایل ===== */
.sheet-dim {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(15, 23, 42, 0.4);
animation: dim-in 0.18s ease-out;
}
.panel.sheet {
position: fixed;
inset: auto 0 0 0;
top: auto;
z-index: 1001;
width: 100%;
max-height: 85vh;
border: 0;
border-radius: 16px 16px 0 0;
box-shadow: 0 -10px 30px rgba(15, 23, 42, 0.18);
padding-bottom: env(safe-area-inset-bottom, 0);
animation: sheet-up 0.22s ease-out;
}
.grip {
display: block;
width: 2.5rem;
height: 4px;
margin: 0.5rem auto 0;
border-radius: 999px;
background: #cbd5e1;
}
.panel.sheet .panel-head {
padding-top: 0.5rem;
}
.panel.sheet .checks {
max-height: none;
}
.panel.sheet .check {
padding-block: 0.5rem;
}
.panel.sheet .pill {
padding: 0.4rem 0.85rem;
}
.panel.sheet .panel-foot > * {
flex: 1 1 0;
justify-content: center;
}
@keyframes sheet-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
@keyframes dim-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>