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
This commit is contained in:
s.esmaeili
2026-09-12 01:33:07 +03:30
parent 9a991fc1f1
commit 1ae87c97b7
87 changed files with 7844 additions and 5718 deletions
+70 -61
View File
@@ -5,8 +5,13 @@ import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import UiButton from '@/components/ui/UiButton.vue';
import UiModal from '@/components/ui/UiModal.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';
@@ -57,18 +62,6 @@ 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';
@@ -249,20 +242,19 @@ onMounted(load);
<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">
<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>
</button>
<button class="btn btn-ghost excel-btn" type="button" :disabled="busy" @click.stop="pickImportFile">
</UiButton>
<UiButton variant="ghost" size="sm" :disabled="busy" @click.stop="pickImportFile">
<span class="btn-full">ورود از اکسل</span>
<span class="btn-short">ورود</span>
</button>
</UiButton>
<input
ref="fileInput"
type="file"
@@ -271,14 +263,14 @@ onMounted(load);
@change="onImportFile"
/>
<UiButton
type="button"
variant="primary"
@click="contactModal = tab === 'companies' ? 'company' : 'person'"
>
<UiIcon name="plus" :size="16" />
{{ tab === 'companies' ? 'شرکت جدید' : 'شخص جدید' }}
</UiButton>
</div>
</header>
</template>
</UiPageHeader>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'people' }" @click.stop="setTab('people')">
@@ -442,7 +434,11 @@ onMounted(load);
</tbody>
</table>
</div>
<p v-else class="empty">شخصی ثبت نشده. اولین نفر را اضافه کنید.</p>
<UiEmptyState
v-else
title="شخصی ثبت نشده است."
description="اولین مخاطب یا مشتری را اضافه کنید."
/>
</template>
<template v-else>
@@ -491,24 +487,20 @@ onMounted(load);
</tbody>
</table>
</div>
<p v-else class="empty">شرکتی ثبت نشده. اولین شرکت را اضافه کنید.</p>
<UiEmptyState
v-else
title="شرکتی ثبت نشده است."
description="اولین شرکت یا سازمان را اضافه کنید."
/>
</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>
<UiPagination
v-if="total"
:page="page"
:total-pages="pageCount"
:total-items="total"
@update:page="setPage"
/>
<UiModal
:open="Boolean(contactModal)"
@@ -633,7 +625,7 @@ h1 {
.tabs .on {
color: #0f766e;
font-weight: 800;
font-weight: 600;
box-shadow: inset 0 -2px 0 #14b8a6;
}
@@ -658,34 +650,44 @@ h1 {
}
.search {
flex: 1 1 12rem;
min-width: 0;
flex: 1 1 14rem;
min-width: 180px;
max-width: 400px;
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 0.75rem;
min-height: var(--control-h);
height: var(--control-h);
color: #94a3b8;
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;
color: #0f172a;
font-size: var(--fs-sm);
color: var(--ink);
padding: 0;
}
.tool {
min-height: var(--control-h);
height: var(--control-h);
border: 1px solid #e2e8f0;
border: 1px solid var(--line);
background: #fff;
border-radius: var(--radius);
padding: 0 0.75rem;
@@ -694,9 +696,16 @@ h1 {
align-items: center;
gap: 0.35rem;
font: inherit;
color: #475569;
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 {
@@ -712,7 +721,7 @@ h1 {
padding: 0.35rem;
list-style: none;
background: #fff;
border-radius: 12px;
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);
@@ -725,7 +734,7 @@ h1 {
border: 0;
background: none;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border-radius: var(--radius);
font: inherit;
text-align: start;
color: inherit;
@@ -751,7 +760,7 @@ h1 {
.table-wrap {
background: #fff;
border-radius: 18px;
border-radius: var(--radius);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
overflow: auto;
-webkit-overflow-scrolling: touch;
@@ -789,7 +798,7 @@ th {
.name {
color: #0f766e;
font-weight: 800;
font-weight: 600;
min-width: 0;
overflow-wrap: anywhere;
}
@@ -814,7 +823,7 @@ th {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 8px;
border-radius: var(--radius);
}
.dot {
@@ -869,7 +878,7 @@ strong.newcomer {
gap: 0.7rem;
align-items: start;
background: #fff;
border-radius: 18px;
border-radius: var(--radius);
padding: 0.85rem;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
min-width: 0;
@@ -922,7 +931,7 @@ strong.newcomer {
border-radius: 999px;
padding: 0.28rem 0.6rem;
font-size: 0.72rem;
font-weight: 800;
font-weight: 600;
margin-top: 0.25rem;
max-width: 100%;
overflow: hidden;
@@ -972,7 +981,7 @@ strong.newcomer {
padding: 1.4rem;
color: #64748b;
background: #fff;
border-radius: 16px;
border-radius: var(--radius);
}
.pager {
@@ -997,7 +1006,7 @@ strong.newcomer {
height: 32px;
border: 0;
background: #fff;
border-radius: 8px;
border-radius: var(--radius);
cursor: pointer;
}