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
+100 -70
View File
@@ -14,6 +14,12 @@ import {
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiPageHeader,
UiPagination,
UiChart,
type UiChartOption,
} from '@/components/ui';
import type {
CreateSalesGoalRequest,
OrgUserDto,
@@ -29,7 +35,6 @@ type StatusFilter = 'all' | SalesGoalStatus;
type MobTab = 'goals' | 'forecast';
type MobScreen = 'list' | 'form';
const PAGE_SIZES = [10, 20, 50];
const GOAL_TYPES: { value: SalesGoalType; label: string; icon: 'target' | 'users' | 'chart' | 'briefcase' | 'check' | 'star' }[] = [
{ value: 'sales', label: 'فروش', icon: 'target' },
{ value: 'customer', label: 'مشتری', icon: 'users' },
@@ -103,9 +108,6 @@ const pageItems = computed(() => {
const pageFrom = computed(() =>
filteredGoals.value.length ? (page.value - 1) * pageSize.value + 1 : 0,
);
const pageTo = computed(() =>
Math.min(page.value * pageSize.value, filteredGoals.value.length),
);
const formTitle = computed(() =>
editingId.value ? 'ویرایش هدف' : 'افزودن هدف جدید',
@@ -133,6 +135,51 @@ function formatMoney(amount: number) {
return formatMoneyLib(amount, currency.value);
}
const forecastChartOption = computed<UiChartOption>(() => {
const f = forecast.value;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
if (!item) return '';
return `${item.name}: <strong>${formatMoney(item.value)}</strong>`;
},
},
grid: { left: '3%', right: '4%', bottom: '8%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: ['هدف دوره', 'تحقق‌یافته', 'کاریز باز', 'پیش‌بینی'],
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
axisLabel: {
color: '#64748b',
fontFamily: 'IRANSans',
formatter: (v: number) => toFaDigits(Math.round(v).toLocaleString('en-US')),
},
},
series: [
{
name: 'مبلغ',
type: 'bar',
barWidth: '40%',
data: [
{ value: f?.targetAmount || 0, itemStyle: { color: '#3b82f6', borderRadius: [6, 6, 0, 0] } },
{ value: f?.wonAmount || 0, itemStyle: { color: '#10b981', borderRadius: [6, 6, 0, 0] } },
{ value: f?.openPipelineAmount || 0, itemStyle: { color: '#f59e0b', borderRadius: [6, 6, 0, 0] } },
{ value: f?.forecastAmount || 0, itemStyle: { color: '#14b8a6', borderRadius: [6, 6, 0, 0] } },
],
},
],
};
});
function formatValue(amount: number, unit: 'money' | 'count', type?: string) {
if (unit === 'count' || type === 'customer' || type === 'deal' || type === 'activity') {
return `${toFaDigits(Math.round(amount))} ${type === 'customer' ? 'مشتری' : type === 'deal' ? 'معامله' : type === 'activity' ? 'فعالیت' : 'مورد'}`;
@@ -418,15 +465,10 @@ onMounted(() => {
</div>
<template v-else>
<header class="page-head">
<div class="title-row">
<span class="title-ico"><UiIcon name="target" :size="22" /></span>
<div>
<h1>هدف و پیش‌بینی</h1>
<p class="lead">اهداف ماهانه را تنظیم و پیش‌بینی‌های خود را مشاهده نمایید</p>
</div>
</div>
</header>
<UiPageHeader
title="هدف و پیش‌بینی"
lead="اهداف ماهانه را تنظیم و پیش‌بینی‌های خود را مشاهده نمایید"
/>
<div class="mob-tabs mob-only">
<button type="button" :class="{ on: mobTab === 'goals' }" @click="mobTab = 'goals'">لیست اهداف</button>
@@ -666,37 +708,18 @@ onMounted(() => {
<div class="sum-row payable"><span>پیش‌بینی درآمد</span><strong>{{ formatMoney(forecast?.forecastAmount || 0) }}</strong></div>
<div class="sum-row"><span>مشتریان جدید</span><strong>{{ toFaDigits(forecast?.newCustomersCount || 0) }}</strong></div>
<div class="sum-row"><span>معاملات جدید</span><strong>{{ toFaDigits(forecast?.newDealsCount || 0) }}</strong></div>
<UiChart :option="forecastChartOption" height="220px" style="margin-top: 1rem;" />
</article>
</div>
<footer v-if="filteredGoals.length && mobTab !== 'forecast'" class="pager desk-only">
<div class="pager-meta">
<p class="muted">
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از
{{ toFaDigits(filteredGoals.length) }} مورد
</p>
<label class="page-size">
نمایش در هر صفحه:
<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="filteredGoals.length && mobTab !== 'forecast'"
:page="page"
:total-pages="pageCount"
:total-items="filteredGoals.length"
:page-size="pageSize"
@update:page="page = $event"
/>
</template>
</main>
</div>
@@ -716,27 +739,28 @@ onMounted(() => {
--teal-soft: #f0fdfa;
display: grid;
gap: 1rem;
padding-bottom: 5.5rem;
width: 100%;
max-width: 100%;
min-width: 0;
}
.page-head { display: flex; justify-content: space-between; gap: 1rem; }
.title-row { display: flex; gap: 0.75rem; align-items: flex-start; }
.title-ico, .side-ico, .doc-badge, .kpi-ico {
width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center;
width: 42px; height: 42px; border-radius: var(--radius); display: grid; place-items: center;
background: var(--teal-soft); color: var(--teal); flex-shrink: 0; position: relative;
}
.doc-badge .plus {
position: absolute; bottom: 4px; left: 4px; background: var(--teal); color: #fff;
border-radius: 4px; padding: 1px;
border-radius: var(--radius); padding: 1px;
}
h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
h1 { margin: 0; font-size: 1.4rem; font-weight: 600; }
.lead, .mob-head-copy p { margin: 0.2rem 0 0; color: var(--muted); font-size: 0.88rem; }
.kpi-row {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem;
}
.kpi-card {
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;
display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center;
box-shadow: 0 8px 20px rgba(15,23,42,.04);
}
@@ -748,7 +772,7 @@ h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
content: ''; position: absolute; inset: 7px; border-radius: 50%; background: #fff;
}
.ring span {
position: relative; z-index: 1; font-size: 0.78rem; font-weight: 800; color: var(--teal);
position: relative; z-index: 1; font-size: 0.78rem; font-weight: 600; color: var(--teal);
}
.ring.sm { width: 56px; height: 56px; }
.ring.sm::before { inset: 6px; }
@@ -762,7 +786,7 @@ h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
display: grid; grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); gap: 1rem; align-items: start;
}
.sidebar, .main, .card, .table-wrap, .goal-card, .forecast-summary {
background: #fff; border: 1px solid #eef2f7; border-radius: 16px;
background: #fff; border: 1px solid #eef2f7; border-radius: var(--radius);
box-shadow: 0 8px 20px rgba(15,23,42,.04);
}
.sidebar { padding: 1rem; display: grid; gap: 0.85rem; position: sticky; top: 0.75rem; }
@@ -776,36 +800,42 @@ h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
.control :deep(.ico) {
position: absolute; inset-inline-start: 0.7rem; color: #94a3b8; pointer-events: none; z-index: 1;
}
.control input, .control select, .control textarea, .search input, .mob-filter select {
width: 100%; border: 1px solid #e2e8f0; border-radius: var(--radius); min-height: var(--control-h);
padding: 0 0.8rem; background: #fff; font: inherit; color: inherit; box-sizing: border-box;
.control input, .control select, .control textarea, .mob-filter select {
width: 100%; border: 1px solid var(--line); border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 0.8rem; background: #fff; font: inherit; font-size: var(--fs-sm); color: inherit; box-sizing: border-box;
}
.control input, .control select, .control textarea { padding-inline-start: 2.2rem; }
.control.area textarea { min-height: 84px; resize: vertical; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.forecast-block {
border-top: 1px dashed #e2e8f0; padding-top: 0.75rem; display: grid; gap: 0.65rem;
border-top: 1px dashed var(--line); padding-top: 0.75rem; display: grid; gap: 0.65rem;
}
.forecast-block h3 {
margin: 0; display: flex; align-items: center; gap: 0.35rem; font-size: 0.95rem; color: var(--teal);
margin: 0; display: flex; align-items: center; gap: 0.35rem; font-size: 0.95rem; color: var(--accent);
}
.btn {
border: 0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 1rem; font-weight: 800; box-sizing: border-box;
padding: 0 0.85rem; font-weight: 600; font-size: var(--fs-sm); box-sizing: border-box;
display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.btn.primary { background: var(--teal); color: #fff; }
.btn.ghost { background: #fff; border: 1px solid #e2e8f0; color: #334155; }
.btn.primary { background: var(--accent); color: #fff; }
.btn.ghost { background: #fff; border: 1px solid var(--line); color: var(--ink-secondary); }
.btn.full { width: 100%; }
.main { padding: 0.9rem; display: grid; gap: 0.85rem; min-width: 0; }
.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.search {
flex: 1 1 180px; display: flex; align-items: center; gap: 0.4rem;
border: 1px solid #e2e8f0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 0.7rem; color: #94a3b8; background: #fff; box-sizing: border-box;
flex: 1 1 180px; max-width: 380px; display: flex; align-items: center; gap: 0.4rem;
border: 1px solid var(--line); border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 0.75rem; color: var(--muted); background: #fff; box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search input { border: 0; outline: 0; 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; background: transparent; color: var(--ink); width: 100%; font-size: var(--fs-sm); padding: 0; }
.status-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.status-tabs button {
border: 0; background: #f1f5f9; color: #64748b; border-radius: 999px; padding: 0.4rem 0.75rem;
@@ -838,18 +868,18 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
.status.needs_attention { background: #ffedd5; color: #c2410c; }
.ops { position: relative; white-space: nowrap; }
.icon-btn {
width: 34px; height: 34px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff;
width: 34px; height: 34px; border-radius: var(--radius); border: 1px solid #e2e8f0; background: #fff;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: #64748b;
}
.icon-btn.sm { width: 30px; height: 30px; }
.menu {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 120px;
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; box-shadow: 0 10px 24px rgba(15,23,42,.12);
background: #fff; border: 1px solid #e2e8f0; border-radius: var(--radius); box-shadow: 0 10px 24px rgba(15,23,42,.12);
padding: 0.3rem; display: grid;
}
.menu.left { right: 0; left: auto; }
.menu button {
border: 0; background: transparent; text-align: right; padding: 0.5rem 0.65rem; border-radius: 8px;
border: 0; background: transparent; text-align: right; padding: 0.5rem 0.65rem; border-radius: var(--radius);
cursor: pointer; font: inherit;
}
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
@@ -858,10 +888,10 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
.pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center; }
.pager-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
.page-size { display: inline-flex; gap: 0.35rem; align-items: center; color: var(--muted); font-size: 0.85rem; }
.page-size select { border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.25rem 0.45rem; }
.page-size select { border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.25rem 0.45rem; }
.pages { display: flex; gap: 0.25rem; }
.page-btn {
min-width: 32px; height: 32px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer;
min-width: 32px; height: 32px; border-radius: var(--radius); border: 1px solid #e2e8f0; background: #fff; cursor: pointer;
}
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
@@ -883,12 +913,12 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
.goal-body { display: grid; grid-template-columns: 1fr auto; gap: 0.65rem; align-items: center; }
.goal-body strong { display: block; }
.goal-body .muted, .goal-body small { display: block; font-size: 0.8rem; }
.goal-body p { margin: 0.25rem 0; font-weight: 800; font-size: 0.9rem; }
.goal-body p { margin: 0.25rem 0; font-weight: 600; font-size: 0.9rem; }
.sum-row {
display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.45rem 0;
border-bottom: 1px solid #f1f5f9; font-size: 0.9rem;
}
.sum-row.payable { color: var(--teal); font-weight: 800; border-bottom: 0; }
.sum-row.payable { color: var(--teal); font-weight: 600; border-bottom: 0; }
.forecast-summary { padding: 1rem; }
.mob-bottom {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
@@ -897,7 +927,7 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
background: #fff; border-top: 1px solid #eef2f7; box-shadow: 0 -8px 24px rgba(15,23,42,.06);
}
.fab {
border: 0; border-radius: 10px; min-height: 36px; background: var(--teal); color: #fff; font-weight: 800;
border: 0; border-radius: var(--radius); min-height: 36px; background: var(--teal); color: #fff; font-weight: 600;
padding: 0 1rem; cursor: pointer;
}
.side-link {
@@ -929,8 +959,8 @@ th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap;
background: #f1f5f9; border-radius: var(--radius);
}
.mob-tabs button {
border: 0; border-radius: 8px; min-height: var(--control-h); height: var(--control-h); background: transparent; color: #64748b;
font-weight: 800; cursor: pointer;
border: 0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h); background: transparent; color: #64748b;
font-weight: 600; cursor: pointer;
}
.mob-tabs button.on { background: var(--teal); color: #fff; }
.mob-filter { flex: 1; }