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
+76 -86
View File
@@ -9,8 +9,13 @@ import {
toFaDigits,
} from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.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 InvoiceFormView from '@/views/InvoiceFormView.vue';
import type {
@@ -41,7 +46,6 @@ type BillingRow = {
href: { name: string; params: { id: string } };
};
const PAGE_SIZES = [10, 20, 50];
const route = useRoute();
const router = useRouter();
@@ -216,12 +220,6 @@ const pageItems = computed(() => {
const start = (page.value - 1) * pageSize.value;
return filtered.value.slice(start, start + pageSize.value);
});
const pageFrom = computed(() =>
filtered.value.length ? (page.value - 1) * pageSize.value + 1 : 0,
);
const pageTo = computed(() =>
Math.min(page.value * pageSize.value, filtered.value.length),
);
function docLabel(doc: BillingRow['doc']) {
if (doc === 'quote') return 'پیش فاکتور';
@@ -343,35 +341,33 @@ onMounted(load);
<template>
<section class="page" @click="closeMenus">
<header class="page-head">
<div class="title-row">
<span class="title-ico"><UiIcon name="document" :size="22" /></span>
<div>
<h1>فاکتور ها</h1>
<p class="lead">مدیریت فاکتورهای فروش، پیش فاکتور و خدمات</p>
</div>
</div>
<div class="head-actions" @click.stop>
<div class="add-split desk-only">
<UiButton type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">
<UiIcon name="plus" :size="16" />
افزودن فاکتور
</UiButton>
<button type="button" class="btn primary split" aria-label="گزینه‌های افزودن" @click="addOpen = !addOpen">
<UiIcon name="chevron" :size="14" />
</button>
<div v-if="addOpen" class="menu">
<button type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">فاکتور جدید</button>
<button type="button" @click="invoiceModal = { kind: 'quote' }; addOpen = false">پیش فاکتور جدید</button>
<button type="button" @click="invoiceModal = { kind: 'credit' }; addOpen = false">فاکتور برگشتی</button>
<UiPageHeader
title="فاکتور ها"
lead="مدیریت فاکتورهای فروش، پیش فاکتور و خدمات"
>
<template #actions>
<div class="head-actions" @click.stop>
<div class="add-split desk-only">
<UiButton type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">
<UiIcon name="plus" :size="16" />
افزودن فاکتور
</UiButton>
<button type="button" class="btn primary split" aria-label="گزینه‌های افزودن" @click="addOpen = !addOpen">
<UiIcon name="chevron" :size="14" />
</button>
<div v-if="addOpen" class="menu">
<button type="button" @click="invoiceModal = { kind: 'invoice' }; addOpen = false">فاکتور جدید</button>
<button type="button" @click="invoiceModal = { kind: 'quote' }; addOpen = false">پیش فاکتور جدید</button>
<button type="button" @click="invoiceModal = { kind: 'credit' }; addOpen = false">فاکتور برگشتی</button>
</div>
</div>
<UiButton class="mob-only" type="button" @click="invoiceModal = { kind: 'invoice' }">
<UiIcon name="plus" :size="16" />
افزودن
</UiButton>
</div>
<UiButton class="mob-only" type="button" @click="invoiceModal = { kind: 'invoice' }">
<UiIcon name="plus" :size="16" />
افزودن
</UiButton>
</div>
</header>
</template>
</UiPageHeader>
<!-- Desktop underline tabs -->
<div class="tabs desk-only">
@@ -565,37 +561,21 @@ onMounted(load);
<em :class="{ neg: r.amount < 0 }">{{ money(r.amount, r.currency) }}</em>
</div>
</article>
<p v-if="!pageItems.length" class="empty">سندی یافت نشد.</p>
<UiEmptyState
v-if="!pageItems.length"
title="سندی یافت نشد."
description="فاکتور، پیش‌فاکتور یا سند جدیدی ثبت کنید."
/>
</div>
<footer v-if="filtered.length" class="pager">
<div class="pager-meta">
<p class="muted">
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از
{{ toFaDigits(filtered.length) }} مورد
</p>
<label class="page-size desk-only">
نمایش در هر صفحه:
<select v-model.number="pageSize">
<option v-for="n in PAGE_SIZES" :key="n" :value="n">{{ toFaDigits(n) }}</option>
</select>
</label>
</div>
<div class="pages">
<button type="button" class="page-btn" :disabled="page <= 1" @click="page -= 1">‹</button>
<button
v-for="n in pageCount"
:key="n"
type="button"
class="page-btn"
:class="{ on: page === n }"
@click="page = n"
>
{{ toFaDigits(n) }}
</button>
<button type="button" class="page-btn" :disabled="page >= pageCount" @click="page += 1">›</button>
</div>
</footer>
<UiPagination
v-if="filtered.length"
:page="page"
:total-pages="pageCount"
:total-items="filtered.length"
:page-size="pageSize"
@update:page="page = $event"
/>
</template>
<UiModal
@@ -682,7 +662,8 @@ onMounted(load);
display: grid;
gap: 0.9rem;
position: relative;
padding-bottom: 5rem;
width: 100%;
max-width: 100%;
}
.page-head {
display: flex;
@@ -692,10 +673,10 @@ onMounted(load);
}
.title-row { display: flex; gap: 0.75rem; align-items: flex-start; }
.title-ico {
width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: var(--radius); display: grid; place-items: center;
background: var(--teal-soft); color: var(--teal);
}
h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
h1 { margin: 0; font-size: 1.5rem; font-weight: 600; }
.lead { margin: 0.2rem 0 0; color: var(--muted); font-size: 0.9rem; }
.head-actions { position: relative; }
.add-split { display: flex; position: relative; }
@@ -721,11 +702,11 @@ h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
.add-doc { color: var(--teal); }
.plus-badge {
position: absolute; bottom: 6px; left: 6px;
background: var(--teal); color: #fff; border-radius: 4px; padding: 1px;
background: var(--teal); color: #fff; border-radius: var(--radius); padding: 1px;
}
.menu {
position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 170px;
background: #fff; border: 1px solid var(--line); border-radius: 12px;
background: #fff; border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: 0 12px 28px rgba(15,23,42,.12); padding: 0.35rem; display: grid;
}
.menu.left { left: auto; right: 0; }
@@ -733,7 +714,7 @@ h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
.menu button {
display: block; width: 100%;
border: 0; background: transparent; text-align: right; padding: 0.55rem 0.7rem;
border-radius: 8px; color: var(--text); text-decoration: none; cursor: pointer;
border-radius: var(--radius); color: var(--text); text-decoration: none; cursor: pointer;
}
.menu a:hover,
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
@@ -764,21 +745,30 @@ h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.search {
flex: 1 1 220px; display: flex; align-items: center; gap: 0.45rem;
flex: 1 1 220px; max-width: 380px; display: flex; align-items: center; gap: 0.45rem;
border: 1px solid var(--line); border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 0.75rem; background: #fff; color: var(--muted); box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search input { border: 0; outline: 0; width: 100%; background: transparent; color: var(--text); }
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input { border: 0; outline: 0; width: 100%; background: transparent; color: var(--ink); font-size: var(--fs-sm); padding: 0; }
.search .mob-ph { display: none; }
.filters { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
.filter-chip {
display: inline-flex; align-items: center; gap: 0.3rem; flex-wrap: nowrap;
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 0.55rem;
background: #fff; color: #94a3b8; min-height: var(--control-h); height: var(--control-h);
box-sizing: border-box; white-space: nowrap;
background: #fff; color: var(--muted); min-height: var(--control-h); height: var(--control-h);
box-sizing: border-box; white-space: nowrap; font-size: var(--fs-sm);
transition: border-color var(--transition-fast);
}
.filter-chip:focus-within {
border-color: var(--accent);
}
.filter-chip select, .filter-chip :deep(button), .filter-chip :deep(input) {
border: 0; background: transparent; outline: 0; min-height: 0; height: 32px; color: var(--text);
border: 0; background: transparent; outline: 0; min-height: 0; height: 32px; color: var(--ink); font-size: var(--fs-sm);
}
.filter-chip :deep(.wrap) { width: auto; flex: 0 1 auto; }
.filter-chip :deep(.trigger) {
@@ -790,7 +780,7 @@ h1 { margin: 0; font-size: 1.5rem; font-weight: 800; }
.filter-chip .sep { color: #cbd5e1; flex-shrink: 0; }
.table-wrap {
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; overflow: auto;
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); overflow: auto;
box-shadow: 0 8px 24px rgba(15,23,42,.04);
}
table { width: 100%; border-collapse: collapse; min-width: 980px; }
@@ -802,7 +792,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
.cust { display: flex; gap: 0.55rem; align-items: center; }
.avatar {
width: 36px; height: 36px; border-radius: 999px; background: var(--teal-soft); color: var(--teal);
display: grid; place-items: center; font-weight: 800; flex-shrink: 0;
display: grid; place-items: center; font-weight: 600; flex-shrink: 0;
}
.cust strong { display: block; }
.cust small { color: var(--muted); }
@@ -832,7 +822,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
.mob-list { display: none; gap: 0.65rem; }
.mob-card {
position: relative;
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; padding: 0.9rem;
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.9rem;
box-shadow: 0 6px 16px rgba(15,23,42,.04);
display: grid; gap: 0.65rem;
}
@@ -847,7 +837,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
.mob-aside { display: grid; justify-items: center; gap: 0.35rem; }
.mob-menu { top: 2.2rem; }
.mob-card-bottom { display: flex; justify-content: flex-start; }
.mob-card-bottom em { font-style: normal; font-weight: 800; font-size: 0.92rem; }
.mob-card-bottom em { font-style: normal; font-weight: 600; font-size: 0.92rem; }
.pager {
display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center;
@@ -857,11 +847,11 @@ tbody tr:nth-child(even) { background: #fcfdff; }
display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.88rem;
}
.page-size select {
border: 1px solid var(--line); border-radius: 10px; padding: 0.3rem 0.5rem; background: #fff;
border: 1px solid var(--line); border-radius: var(--radius); padding: 0.3rem 0.5rem; background: #fff;
}
.pages { display: flex; gap: 0.3rem; }
.page-btn {
min-width: 34px; height: 34px; border-radius: 10px; border: 1px solid var(--line);
min-width: 34px; height: 34px; border-radius: var(--radius); border: 1px solid var(--line);
background: #fff; cursor: pointer;
}
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
@@ -869,7 +859,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
.fab {
display: none; position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 40;
background: var(--teal); color: #fff; text-align: center; text-decoration: none;
border-radius: 14px; padding: 0.9rem; font-weight: 800;
border-radius: var(--radius); padding: 0.9rem; font-weight: 600;
box-shadow: 0 12px 28px rgba(13,148,136,.35);
}
@@ -878,12 +868,12 @@ tbody tr:nth-child(even) { background: #fcfdff; }
display: grid; align-items: end;
}
.sheet {
background: #fff; border-radius: 20px 20px 0 0; padding: 1rem; max-height: 85vh; overflow: auto;
background: #fff; border-radius: var(--radius, 10px) var(--radius, 10px) 0 0; padding: 16px; max-height: 85vh; overflow: auto;
}
.sheet header {
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
}
.sheet header strong { justify-self: end; }
.sheet header strong { justify-self: end; font-size: 16px; font-weight: 500; color: var(--ink); }
.link { border: 0; background: transparent; color: var(--teal); font-weight: 700; cursor: pointer; }
.lbl { margin: 0.85rem 0 0.4rem; font-weight: 700; color: #334155; }
.pill-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
@@ -894,7 +884,7 @@ tbody tr:nth-child(even) { background: #fcfdff; }
.pill-row button.on { background: var(--teal); color: #fff; border-color: transparent; }
.field { display: grid; gap: 0.3rem; color: var(--muted); font-size: 0.85rem; margin-top: 0.35rem; }
.field input, .field select, .control select {
border: 1px solid var(--line); border-radius: 12px; padding: 0.55rem 0.7rem; background: #fff; color: var(--text);
border: 1px solid var(--line); border-radius: var(--radius); padding: 0.55rem 0.7rem; background: #fff; color: var(--text);
}
.control { position: relative; display: flex; align-items: center; }
.control :deep(.ico) {