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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user