feat(web): adopt vuesax icons and shared skeleton loading
Replace the inline SVG icon set with the vuesax linear pack, add skeleton primitives for loading states, and keep page scroll inside the app shell. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons-vue';
|
||||
import { ThunderboltOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const { Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
InputNumber as AInputNumber,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import type {
|
||||
CustomFieldDefinitionDto,
|
||||
CustomFieldEntity,
|
||||
@@ -73,61 +73,63 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-spin v-if="loading" />
|
||||
<template v-else-if="fields.length">
|
||||
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
|
||||
فیلدهای سفارشی
|
||||
</a-typography-title>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
|
||||
<a-form-item :label="field.label" :required="field.required">
|
||||
<a-select
|
||||
v-if="field.fieldType === 'select'"
|
||||
:id="`cf-${field.key}`"
|
||||
allow-clear
|
||||
:value="String(displayValue(field.key)) || undefined"
|
||||
:options="selectOptions(field)"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event ?? '')"
|
||||
/>
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard v-if="loading" :rows="3" :min-height="120" />
|
||||
<template v-else-if="fields.length">
|
||||
<a-typography-title :level="5" :style="{ marginTop: '16px' }">
|
||||
فیلدهای سفارشی
|
||||
</a-typography-title>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col v-for="field in fields" :key="field.id" :xs="24" :md="12">
|
||||
<a-form-item :label="field.label" :required="field.required">
|
||||
<a-select
|
||||
v-if="field.fieldType === 'select'"
|
||||
:id="`cf-${field.key}`"
|
||||
allow-clear
|
||||
:value="String(displayValue(field.key)) || undefined"
|
||||
:options="selectOptions(field)"
|
||||
placeholder="انتخاب کنید"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event ?? '')"
|
||||
/>
|
||||
|
||||
<a-checkbox
|
||||
v-else-if="field.fieldType === 'boolean'"
|
||||
:id="`cf-${field.key}`"
|
||||
:checked="Boolean(props.modelValue[field.key])"
|
||||
@update:checked="setValue(field.key, $event)"
|
||||
>
|
||||
فعال
|
||||
</a-checkbox>
|
||||
<a-checkbox
|
||||
v-else-if="field.fieldType === 'boolean'"
|
||||
:id="`cf-${field.key}`"
|
||||
:checked="Boolean(props.modelValue[field.key])"
|
||||
@update:checked="setValue(field.key, $event)"
|
||||
>
|
||||
فعال
|
||||
</a-checkbox>
|
||||
|
||||
<a-input-number
|
||||
v-else-if="field.fieldType === 'number'"
|
||||
:id="`cf-${field.key}`"
|
||||
:value="
|
||||
props.modelValue[field.key] != null
|
||||
? Number(props.modelValue[field.key])
|
||||
: undefined
|
||||
"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event == null ? null : Number($event))"
|
||||
/>
|
||||
<a-input-number
|
||||
v-else-if="field.fieldType === 'number'"
|
||||
:id="`cf-${field.key}`"
|
||||
:value="
|
||||
props.modelValue[field.key] != null
|
||||
? Number(props.modelValue[field.key])
|
||||
: undefined
|
||||
"
|
||||
style="width: 100%"
|
||||
@update:value="setValue(field.key, $event == null ? null : Number($event))"
|
||||
/>
|
||||
|
||||
<JalaliDateInput
|
||||
v-else-if="field.fieldType === 'date'"
|
||||
:id="`cf-${field.key}`"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event || null)"
|
||||
/>
|
||||
<JalaliDateInput
|
||||
v-else-if="field.fieldType === 'date'"
|
||||
:id="`cf-${field.key}`"
|
||||
:model-value="String(displayValue(field.key))"
|
||||
@update:model-value="setValue(field.key, $event || null)"
|
||||
/>
|
||||
|
||||
<a-input
|
||||
v-else
|
||||
:id="`cf-${field.key}`"
|
||||
:value="String(displayValue(field.key))"
|
||||
@update:value="setValue(field.key, $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
<a-input
|
||||
v-else
|
||||
:id="`cf-${field.key}`"
|
||||
:value="String(displayValue(field.key))"
|
||||
@update:value="setValue(field.key, $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
</CrmSkeleton>
|
||||
</template>
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
Input as AInput,
|
||||
List as AList,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
|
||||
const ASpaceCompact = ASpace.Compact;
|
||||
const AListItem = AList.Item;
|
||||
@@ -259,7 +259,8 @@ onMounted(load);
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="5">
|
||||
<a-list
|
||||
v-if="items.length"
|
||||
item-layout="horizontal"
|
||||
@@ -304,6 +305,7 @@ onMounted(load);
|
||||
description="هنوز چیزی در تایملاین نیست. یک یادداشت یا فعالیت ثبت کنید."
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Space as ASpace,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PrinterOutlined } from '@ant-design/icons-vue';
|
||||
import { PrinterOutlined } from '@/icons/ant-bridge';
|
||||
import { brand } from '@mento/shared';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { formatMoney, currencySuffix } from '@/lib/money';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ClockCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
addJalaliMonths,
|
||||
dateToJalali,
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
List as AList,
|
||||
Modal,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
import { api } from '@/lib/api';
|
||||
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
|
||||
import { formatJalaliDateTime } from '@/lib/jalali';
|
||||
@@ -163,7 +163,8 @@ onMounted(load);
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" :style="{ marginBottom: '12px' }" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="4">
|
||||
<a-typography-text v-if="!loading && !hasItems" type="secondary">
|
||||
هنوز موردی در حافظه ثبت نشده.
|
||||
</a-typography-text>
|
||||
@@ -201,6 +202,7 @@ onMounted(load);
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
@@ -8,8 +8,20 @@ import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UiModal from '@/components/ui/UiModal.vue';
|
||||
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
variant?: 'default' | 'workspace';
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
variant: 'default',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
@@ -28,10 +40,28 @@ const menuStyle = ref<Record<string, string>>({});
|
||||
let ignoreDocClickUntil = 0;
|
||||
|
||||
const currentName = computed(
|
||||
() => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
|
||||
() =>
|
||||
auth.user?.organizationName ||
|
||||
orgs.value.find((o) => o.id === auth.user?.organizationId)?.name ||
|
||||
'محیط کاری',
|
||||
);
|
||||
|
||||
const currentSubtitle = computed(() => {
|
||||
const current = orgs.value.find((o) => o.id === auth.user?.organizationId);
|
||||
if (current?.orgRoleName) return current.orgRoleName;
|
||||
if (auth.user?.role) return USER_ROLE_LABELS[auth.user.role];
|
||||
return 'محیط کاری';
|
||||
});
|
||||
|
||||
const currentInitial = computed(() => {
|
||||
const name = currentName.value.trim();
|
||||
return name ? name.charAt(0) : 'م';
|
||||
});
|
||||
|
||||
const canSwitch = computed(() => orgs.value.length > 1);
|
||||
const isWorkspace = computed(() => props.variant === 'workspace');
|
||||
/** در حالت workspace منو همیشه باز میشود (لیست org + محیط جدید)؛ در حالت پیشفرض فقط وقتی بیش از یکی باشد */
|
||||
const menuEnabled = computed(() => isWorkspace.value || canSwitch.value);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
@@ -49,34 +79,62 @@ async function loadOrgs() {
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
if (!open.value) return;
|
||||
open.value = false;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (!canSwitch.value) return;
|
||||
if (!menuEnabled.value) return;
|
||||
error.value = '';
|
||||
if (open.value) {
|
||||
open.value = false;
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
ignoreDocClickUntil = Date.now() + 400;
|
||||
open.value = true;
|
||||
emit('open');
|
||||
await nextTick();
|
||||
placeMenu();
|
||||
await loadOrgs();
|
||||
await nextTick();
|
||||
placeMenu();
|
||||
}
|
||||
|
||||
function placeMenu() {
|
||||
const btn = rootEl.value?.querySelector('button');
|
||||
if (!(btn instanceof HTMLElement)) return;
|
||||
const box = btn.getBoundingClientRect();
|
||||
const minWidth = Math.round(Math.max(box.width, isWorkspace.value ? 240 : 200));
|
||||
const sidebar = document.getElementById('app-sidebar');
|
||||
const rail = sidebar?.getBoundingClientRect();
|
||||
// لبهٔ راست منو = لبهٔ چپ سایدبار − ۴px
|
||||
const edge = rail?.left ?? box.left;
|
||||
const beside = {
|
||||
right: 'auto',
|
||||
left: `${Math.round(edge - 4)}px`,
|
||||
transform: 'translateX(-100%)',
|
||||
minWidth: `${minWidth}px`,
|
||||
};
|
||||
if (isWorkspace.value) {
|
||||
menuStyle.value = {
|
||||
...beside,
|
||||
top: `${Math.round(box.top)}px`,
|
||||
bottom: 'auto',
|
||||
};
|
||||
return;
|
||||
}
|
||||
menuStyle.value = {
|
||||
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
|
||||
right: `${Math.round(window.innerWidth - box.right)}px`,
|
||||
minWidth: `${Math.round(Math.max(box.width, 200))}px`,
|
||||
...beside,
|
||||
top: 'auto',
|
||||
bottom: `${Math.round(window.innerHeight - box.bottom)}px`,
|
||||
};
|
||||
}
|
||||
|
||||
async function choose(org: OrganizationMembershipDto) {
|
||||
if (org.id === auth.user?.organizationId) {
|
||||
open.value = false;
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
@@ -91,7 +149,7 @@ async function choose(org: OrganizationMembershipDto) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
open.value = false;
|
||||
closeMenu();
|
||||
newName.value = '';
|
||||
newField.value = 'personal';
|
||||
error.value = '';
|
||||
@@ -123,9 +181,11 @@ function onDocClick(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
|
||||
open.value = false;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
defineExpose({ close: closeMenu });
|
||||
|
||||
onMounted(() => {
|
||||
void loadOrgs();
|
||||
document.addEventListener('click', onDocClick);
|
||||
@@ -137,27 +197,46 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="org-switch">
|
||||
<div ref="rootEl" class="org-switch" :class="{ 'org-ws': isWorkspace, compact }">
|
||||
<button
|
||||
class="org-btn"
|
||||
:class="{ compact, static: !canSwitch }"
|
||||
:class="{
|
||||
compact,
|
||||
static: !menuEnabled,
|
||||
'org-ws': isWorkspace,
|
||||
open,
|
||||
}"
|
||||
type="button"
|
||||
:title="currentName"
|
||||
:aria-disabled="!canSwitch"
|
||||
:aria-expanded="open"
|
||||
:aria-haspopup="menuEnabled ? 'listbox' : undefined"
|
||||
:aria-disabled="!menuEnabled"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<UiIcon name="building" :size="18" />
|
||||
<span>{{ currentName }}</span>
|
||||
<template v-if="isWorkspace">
|
||||
<span class="org-mark" aria-hidden="true">{{ currentInitial }}</span>
|
||||
<span class="org-copy">
|
||||
<strong>{{ currentName }}</strong>
|
||||
<small>{{ currentSubtitle }}</small>
|
||||
</span>
|
||||
<i class="org-chev" :class="{ open }" aria-hidden="true" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<UiIcon name="building" :size="18" />
|
||||
<span>{{ currentName }}</span>
|
||||
</template>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open && canSwitch"
|
||||
v-if="open && menuEnabled"
|
||||
ref="menuEl"
|
||||
class="menu"
|
||||
:class="{ 'org-ws': isWorkspace }"
|
||||
:style="menuStyle"
|
||||
role="listbox"
|
||||
aria-label="محیطهای کاری"
|
||||
>
|
||||
<p class="hint">ورود به محیط</p>
|
||||
<p class="hint">{{ canSwitch ? 'تعویض محیط کاری' : 'محیط کاری' }}</p>
|
||||
<button
|
||||
v-for="org in orgs"
|
||||
:key="org.id"
|
||||
@@ -165,10 +244,21 @@ onUnmounted(() => {
|
||||
class="org-item"
|
||||
:class="{ on: org.id === auth.user?.organizationId }"
|
||||
:disabled="loading"
|
||||
role="option"
|
||||
:aria-selected="org.id === auth.user?.organizationId"
|
||||
@click="choose(org)"
|
||||
>
|
||||
<strong>{{ org.name }}</strong>
|
||||
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
|
||||
<span class="org-item-mark" aria-hidden="true">{{ org.name.trim().charAt(0) || 'م' }}</span>
|
||||
<span class="org-item-copy">
|
||||
<strong>{{ org.name }}</strong>
|
||||
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
|
||||
</span>
|
||||
<UiIcon
|
||||
v-if="org.id === auth.user?.organizationId"
|
||||
class="org-check"
|
||||
name="check"
|
||||
:size="16"
|
||||
/>
|
||||
</button>
|
||||
<button type="button" class="create" :disabled="loading" @click="openCreate">
|
||||
محیط جدید
|
||||
@@ -234,7 +324,7 @@ onUnmounted(() => {
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
.org-btn span {
|
||||
.org-btn > span {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -253,7 +343,7 @@ onUnmounted(() => {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.org-btn.compact {
|
||||
.org-btn.compact:not(.org-ws) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
@@ -262,18 +352,113 @@ onUnmounted(() => {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.org-btn.compact span {
|
||||
.org-btn.compact:not(.org-ws) > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.org-btn.compact:hover {
|
||||
.org-btn.compact:not(.org-ws):hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.org-btn.compact.static:hover {
|
||||
.org-btn.compact.static:not(.org-ws):hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.org-btn.org-ws {
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 16px;
|
||||
color: #0f172a;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.org-btn.org-ws:hover,
|
||||
.org-btn.org-ws.open {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.org-btn.org-ws.static:hover {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.org-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--accent-soft) 80%, white);
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.org-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.org-copy strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-copy small {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-chev {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-right: 1.6px solid #94a3b8;
|
||||
border-bottom: 1.6px solid #94a3b8;
|
||||
/* RTL: اشاره به بیرون (چپ) */
|
||||
transform: rotate(135deg);
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: 0.15rem;
|
||||
}
|
||||
|
||||
.org-chev.open {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.org-btn.compact.org-ws {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
padding: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.org-btn.compact.org-ws .org-copy,
|
||||
.org-btn.compact.org-ws .org-chev {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.org-btn.compact.org-ws .org-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
@@ -286,11 +471,21 @@ onUnmounted(() => {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.menu.org-ws {
|
||||
padding: 0.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e8eef3;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
padding: 0.35rem 0.65rem 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.org-item,
|
||||
@@ -305,29 +500,104 @@ onUnmounted(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-item strong {
|
||||
font-size: 0.82rem;
|
||||
.menu.org-ws .org-item {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.org-item span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
.org-item-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-item-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.org-item strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-item span,
|
||||
.org-item-copy span {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-check {
|
||||
color: #0f172a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.org-item:hover,
|
||||
.create:hover {
|
||||
background: color-mix(in srgb, var(--accent-soft) 70%, white);
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item:hover .org-item-mark {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.org-item.on {
|
||||
background: var(--accent-soft);
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item.on {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item.on .org-item-mark {
|
||||
background: color-mix(in srgb, var(--accent-soft) 85%, white);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.menu.org-ws .org-item.on strong {
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.create {
|
||||
margin-top: 0.15rem;
|
||||
color: var(--accent);
|
||||
padding: 0.7rem 0.65rem 0.55rem;
|
||||
border-top: 1px solid #eef2f7;
|
||||
border-radius: 0 0 12px 12px;
|
||||
color: var(--accent-deep);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.menu.org-ws .create:hover {
|
||||
background: #f8fafc;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.err {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Upload as AUpload,
|
||||
} from 'ant-design-vue';
|
||||
import type { UploadProps } from 'ant-design-vue';
|
||||
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@ant-design/icons-vue';
|
||||
import { PhoneOutlined, SearchOutlined, UserAddOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
filterPhoneContacts,
|
||||
isContactPickerSupported,
|
||||
|
||||
@@ -185,6 +185,13 @@ const fabStyle = computed(() => ({
|
||||
bottom: 'auto',
|
||||
}));
|
||||
|
||||
const panelPlacement = computed(() => {
|
||||
const edge = 16;
|
||||
const spaceAbove = fabPos.value.top - edge;
|
||||
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
|
||||
return spaceAbove >= spaceBelow ? 'above' : 'below';
|
||||
});
|
||||
|
||||
const panelStyle = computed(() => {
|
||||
const gap = 12;
|
||||
const edge = 16;
|
||||
@@ -193,28 +200,33 @@ const panelStyle = computed(() => {
|
||||
left = Math.min(left, viewport.value.w - width - edge);
|
||||
left = Math.max(edge, left);
|
||||
|
||||
const spaceAbove = fabPos.value.top - edge;
|
||||
const spaceBelow = viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge;
|
||||
const placeAbove = spaceAbove >= spaceBelow;
|
||||
const maxHeight = Math.max(0, (placeAbove ? spaceAbove : spaceBelow) - gap);
|
||||
const placeAbove = panelPlacement.value === 'above';
|
||||
const maxHeight = Math.max(
|
||||
0,
|
||||
(placeAbove
|
||||
? fabPos.value.top - edge
|
||||
: viewport.value.h - (fabPos.value.top + FAB_SIZE) - edge) - gap,
|
||||
);
|
||||
|
||||
const shared = {
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
transformOrigin: placeAbove ? 'bottom left' : 'top left',
|
||||
};
|
||||
|
||||
if (placeAbove) {
|
||||
return {
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
...shared,
|
||||
bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
|
||||
top: 'auto',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: `${left}px`,
|
||||
right: 'auto',
|
||||
...shared,
|
||||
top: `${fabPos.value.top + FAB_SIZE + gap}px`,
|
||||
bottom: 'auto',
|
||||
width: `${width}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -238,10 +250,6 @@ function onKeydown(ev: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
watch(isQuickCreateOpen, (value) => {
|
||||
document.body.style.overflow = value ? 'hidden' : '';
|
||||
});
|
||||
|
||||
watch(hidden, (value) => {
|
||||
if (value) close();
|
||||
});
|
||||
@@ -264,46 +272,51 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="!hidden" class="qc" dir="rtl">
|
||||
<button
|
||||
v-if="isQuickCreateOpen"
|
||||
class="overlay"
|
||||
type="button"
|
||||
aria-label="بستن ایجاد سریع"
|
||||
@click="close"
|
||||
/>
|
||||
<Transition name="qc-overlay">
|
||||
<button
|
||||
v-if="isQuickCreateOpen"
|
||||
class="overlay"
|
||||
type="button"
|
||||
aria-label="بستن ایجاد سریع"
|
||||
@click="close"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<section
|
||||
v-if="isQuickCreateOpen"
|
||||
class="panel"
|
||||
aria-labelledby="qc-title"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<h2 id="qc-title">ایجاد سریع</h2>
|
||||
<div class="items">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="item"
|
||||
type="button"
|
||||
@click="go(item.to)"
|
||||
>
|
||||
<span class="item-ico" :class="item.tone">
|
||||
<UiIcon :name="item.icon" :size="18" />
|
||||
</span>
|
||||
<span class="copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.desc }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="qc-panel">
|
||||
<section
|
||||
v-if="isQuickCreateOpen"
|
||||
class="panel"
|
||||
:data-place="panelPlacement"
|
||||
aria-labelledby="qc-title"
|
||||
:style="panelStyle"
|
||||
>
|
||||
<h2 id="qc-title">ایجاد سریع</h2>
|
||||
<div class="items">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.key"
|
||||
class="item"
|
||||
type="button"
|
||||
:style="{ '--qc-i': index }"
|
||||
@click="go(item.to)"
|
||||
>
|
||||
<span class="item-ico" :class="item.tone">
|
||||
<UiIcon :name="item.icon" :size="18" />
|
||||
</span>
|
||||
<span class="copy">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.desc }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</Transition>
|
||||
|
||||
<button
|
||||
v-if="ready"
|
||||
@@ -319,6 +332,8 @@ onBeforeUnmount(() => {
|
||||
@pointerup="onFabPointerUp"
|
||||
@pointercancel="onFabPointerUp"
|
||||
>
|
||||
<span class="fab-pulse" aria-hidden="true" />
|
||||
<span class="fab-shine" aria-hidden="true" />
|
||||
<span class="fab-face" aria-hidden="true">
|
||||
<UiIcon :name="isQuickCreateOpen ? 'x' : 'apps-plus'" :size="20" />
|
||||
</span>
|
||||
@@ -338,11 +353,12 @@ onBeforeUnmount(() => {
|
||||
z-index: 90;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.68);
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel {
|
||||
--qc-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: fixed;
|
||||
z-index: 92;
|
||||
width: 300px;
|
||||
@@ -352,10 +368,90 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
border-radius: 14px;
|
||||
padding: 12px 12px 10px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
box-shadow:
|
||||
0 18px 40px -18px rgba(15, 23, 42, 0.35),
|
||||
0 8px 18px -10px rgba(13, 148, 136, 0.18),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.65) inset;
|
||||
overflow: hidden;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.qc-overlay-enter-active,
|
||||
.qc-overlay-leave-active {
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.qc-overlay-enter-from,
|
||||
.qc-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.qc-panel-enter-active {
|
||||
transition:
|
||||
opacity 0.18s var(--qc-ease),
|
||||
transform 0.2s var(--qc-ease);
|
||||
}
|
||||
|
||||
.qc-panel-leave-active {
|
||||
transition:
|
||||
opacity 0.14s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
|
||||
.qc-panel-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.97);
|
||||
}
|
||||
|
||||
.panel[data-place='above'].qc-panel-enter-from {
|
||||
transform: translateY(-8px) scale(0.97);
|
||||
}
|
||||
|
||||
.qc-panel-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
|
||||
.panel[data-place='above'].qc-panel-leave-to {
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
|
||||
.qc-panel-enter-active .items {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qc-panel-enter-active .item {
|
||||
animation: qc-item-in 0.2s var(--qc-ease) both;
|
||||
animation-delay: calc(0.02s + var(--qc-i, 0) * 0.02s);
|
||||
}
|
||||
|
||||
.qc-panel-enter-active h2 {
|
||||
animation: qc-item-in 0.16s var(--qc-ease) both;
|
||||
}
|
||||
|
||||
@keyframes qc-item-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.qc-overlay-enter-active,
|
||||
.qc-overlay-leave-active,
|
||||
.qc-panel-enter-active,
|
||||
.qc-panel-leave-active,
|
||||
.qc-panel-enter-active .item,
|
||||
.qc-panel-enter-active h2 {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
@@ -369,12 +465,20 @@ onBeforeUnmount(() => {
|
||||
|
||||
.items {
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-left: 2px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.items::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
@@ -450,6 +554,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.fab {
|
||||
--fab-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: fixed;
|
||||
z-index: 93;
|
||||
box-sizing: border-box;
|
||||
@@ -459,57 +564,226 @@ onBeforeUnmount(() => {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 100%);
|
||||
overflow: visible;
|
||||
background: linear-gradient(145deg, #0f766e 0%, #14b8a6 55%, #2dd4bf 100%);
|
||||
background-size: 140% 140%;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
line-height: 0;
|
||||
direction: ltr;
|
||||
isolation: isolate;
|
||||
box-shadow:
|
||||
0 4px 14px rgba(13, 148, 136, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
0 0 0 0 rgba(45, 212, 191, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition:
|
||||
filter 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.12s ease;
|
||||
filter 0.35s var(--fab-ease),
|
||||
box-shadow 0.35s var(--fab-ease),
|
||||
transform 0.35s var(--fab-ease),
|
||||
background 0.4s var(--fab-ease);
|
||||
animation:
|
||||
fab-glow 3.4s ease-in-out infinite,
|
||||
fab-gradient 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fab-pulse,
|
||||
.fab-shine,
|
||||
.fab-face {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.fab-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.42);
|
||||
animation: fab-ring 2.8s var(--fab-ease) infinite;
|
||||
}
|
||||
|
||||
.fab-shine {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fab-shine::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20%;
|
||||
bottom: -20%;
|
||||
left: -40%;
|
||||
width: 42%;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.08) 35%,
|
||||
rgba(255, 255, 255, 0.42) 50%,
|
||||
rgba(255, 255, 255, 0.08) 65%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: translateX(-160%) skewX(-18deg);
|
||||
animation: fab-sheen 4.8s var(--fab-ease) infinite;
|
||||
}
|
||||
|
||||
.fab-face {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.45s var(--fab-ease);
|
||||
animation: fab-breathe 3.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fab :deep(.ico) {
|
||||
.fab :deep(.ico),
|
||||
.fab :deep(svg) {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
margin: 0;
|
||||
stroke-width: 1.7;
|
||||
filter: drop-shadow(0 1px 1px rgba(15, 23, 42, 0.18));
|
||||
transition: transform 0.45s var(--fab-ease), opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
filter: brightness(1.06);
|
||||
filter: brightness(1.07) saturate(1.05);
|
||||
transform: translateY(-1px) scale(1.04);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(13, 148, 136, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
0 10px 24px rgba(13, 148, 136, 0.42),
|
||||
0 0 0 5px rgba(45, 212, 191, 0.12),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
|
||||
.fab:hover .fab-face {
|
||||
animation-play-state: paused;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.fab.open {
|
||||
background: linear-gradient(145deg, #115e59 0%, #0d9488 100%);
|
||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.25);
|
||||
box-shadow:
|
||||
0 8px 22px rgba(15, 23, 42, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.fab.open .fab-pulse,
|
||||
.fab.open .fab-shine::after {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fab.open .fab-face {
|
||||
animation: none;
|
||||
transform: rotate(90deg) scale(1.02);
|
||||
}
|
||||
|
||||
.fab.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 8px 24px rgba(13, 148, 136, 0.45);
|
||||
transform: scale(1.08);
|
||||
box-shadow:
|
||||
0 12px 28px rgba(13, 148, 136, 0.48),
|
||||
0 0 0 6px rgba(45, 212, 191, 0.14),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
animation: none;
|
||||
transition-duration: 0.12s;
|
||||
}
|
||||
|
||||
.fab.dragging .fab-pulse,
|
||||
.fab.dragging .fab-shine::after,
|
||||
.fab.dragging .fab-face {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes fab-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 4px 14px rgba(13, 148, 136, 0.32),
|
||||
0 0 0 0 rgba(45, 212, 191, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 8px 22px rgba(13, 148, 136, 0.48),
|
||||
0 0 18px 2px rgba(45, 212, 191, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fab-gradient {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 40%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fab-ring {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(45, 212, 191, 0.4);
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 12px rgba(45, 212, 191, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fab-sheen {
|
||||
0%,
|
||||
55% {
|
||||
transform: translateX(-160%) skewX(-18deg);
|
||||
}
|
||||
85%,
|
||||
100% {
|
||||
transform: translateX(320%) skewX(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fab-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fab,
|
||||
.fab-pulse,
|
||||
.fab-shine::after,
|
||||
.fab-face {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.fab:hover,
|
||||
.fab.open .fab-face {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { UserOutlined } from '@ant-design/icons-vue';
|
||||
import { UserOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const { Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { DeleteOutlined, FlagOutlined } from '@ant-design/icons-vue';
|
||||
import { DeleteOutlined, FlagOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Checkbox as ACheckbox,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Popover as APopover,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { SettingOutlined } from '@ant-design/icons-vue';
|
||||
import { SettingOutlined } from '@/icons/ant-bridge';
|
||||
import { useTableColumns } from '@/lib/tableColumns';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -447,7 +447,8 @@ onMounted(() => {
|
||||
</a-form>
|
||||
|
||||
<a-divider orientation="right">تیمها</a-divider>
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="6">
|
||||
<a-empty
|
||||
v-if="!loading && !teams.length"
|
||||
description="تیمی تعریف نشده. تیم بسازید تا اعضای فروش پروندههای همتیمی را ببینند."
|
||||
@@ -535,7 +536,8 @@ onMounted(() => {
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-collapse :bordered="false" ghost>
|
||||
<a-collapse-panel v-if="showPriorArchive" key="prior" header="بایگانی اطلاعات قبل">
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* آیکونهای legacy — حالا از پک vuesax-linear رندر میشوند.
|
||||
* API نامها برای سازگاری با صفحات قدیمی حفظ شده.
|
||||
*/
|
||||
import VsIcon from '@/components/VsIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
name:
|
||||
| 'home'
|
||||
@@ -82,352 +88,7 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="ico"
|
||||
:width="size ?? 20"
|
||||
:height="size ?? 20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<template v-if="name === 'home'">
|
||||
<path d="M3 10.5 12 3l9 7.5" />
|
||||
<path d="M5 9.5V21h14V9.5" />
|
||||
<path d="M10 21v-6h4v6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'users'">
|
||||
<circle cx="9" cy="8" r="3.2" />
|
||||
<path d="M2.5 19c.8-3 2.9-4.5 6.5-4.5s5.7 1.5 6.5 4.5" />
|
||||
<circle cx="17" cy="9" r="2.4" />
|
||||
<path d="M15.2 14.2c2.3.3 3.9 1.5 4.8 3.8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'briefcase'">
|
||||
<rect x="3" y="7" width="18" height="13" rx="2" />
|
||||
<path d="M8 7V5.5A1.5 1.5 0 0 1 9.5 4h5A1.5 1.5 0 0 1 16 5.5V7" />
|
||||
<path d="M3 12h18" />
|
||||
</template>
|
||||
<template v-else-if="name === 'check'">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8.5 12.5 2.4 2.4 4.6-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'box'">
|
||||
<path d="M12 3 3.5 7.5v9L12 21l8.5-4.5v-9L12 3Z" />
|
||||
<path d="M12 12 3.5 7.5M12 12l8.5-4.5M12 12v9" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chart'">
|
||||
<path d="M4 19h16" />
|
||||
<path d="M7 16V10" />
|
||||
<path d="M12 16V6" />
|
||||
<path d="M17 16v-4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'settings'">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'search'">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'sparkle'">
|
||||
<path d="M12 3v4M12 17v4M3 12h4M17 12h4" />
|
||||
<path d="m6.5 6.5 2.5 2.5M15 15l2.5 2.5M17.5 6.5 15 9M9 15l-2.5 2.5" />
|
||||
<circle cx="12" cy="12" r="2.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'bell'">
|
||||
<path d="M6 9a6 6 0 1 1 12 0c0 4 1.5 5.5 1.5 5.5H4.5S6 13 6 9Z" />
|
||||
<path d="M10 19a2 2 0 0 0 4 0" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar'">
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar-plus'">
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
|
||||
<path d="M12 13v5M9.5 15.5h5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar-sun'">
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
|
||||
<circle cx="12" cy="15.2" r="2.1" />
|
||||
<path d="M12 11.4v.7M12 18.3v.7M8.9 15.2h.7M14.4 15.2h.7M9.6 12.8l.5.5M13.9 17.1l.5.5M13.9 12.8l-.5.5M9.6 17.1l-.5.5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'calendar-edit'">
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M8 3.5V7M16 3.5V7M3.5 10h17" />
|
||||
<path d="M11.2 18.2 16.6 12.8l1.5 1.5-5.4 5.4H11.2v-1.5Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'mail'">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m4 7 8 6 8-6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'megaphone'">
|
||||
<path d="M3 11v2a2 2 0 0 0 2 2h1l4 4V5L6 9H5a2 2 0 0 0-2 2Z" />
|
||||
<path d="M14 8.5c2 .8 3.5 2.3 3.5 3.5s-1.5 2.7-3.5 3.5" />
|
||||
<path d="M10 15v3a2 2 0 0 1-2 2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'send'">
|
||||
<path d="m4 12 16-8-6 16-2.5-6.5L4 12Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'fire'">
|
||||
<path d="M12 3c2 3-1 4 1 7 1.5 2.2 4 2.5 4 6a5 5 0 1 1-10 0c0-3.5 2-5 3-7 1-2-1-4 2-6Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'warning'">
|
||||
<path d="m12 4 9 16H3L12 4Z" />
|
||||
<path d="M12 10v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</template>
|
||||
<template v-else-if="name === 'trend'">
|
||||
<path d="M4 17 10 11l4 4 6-8" />
|
||||
<path d="M15 7h5v5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'phone'">
|
||||
<path d="M8 3.5h2.5l1 4-2 1.2a12 12 0 0 0 5.8 5.8L16.5 12.5l4 1V16a2 2 0 0 1-2.2 2A16 16 0 0 1 4 6.2 2 2 0 0 1 6 4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'trophy'">
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 17v4" />
|
||||
<path d="M7 4h10v4a5 5 0 0 1-10 0V4Z" />
|
||||
<path d="M7 6H4.5A2.5 2.5 0 0 0 7 8.5" />
|
||||
<path d="M17 6h2.5A2.5 2.5 0 0 1 17 8.5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chat'">
|
||||
<path d="M5 18.5 6.2 15A7.5 7.5 0 1 1 9 19.2L5 18.5Z" />
|
||||
<path d="M8.5 11h7M8.5 14h4.5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dots'">
|
||||
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dots-v'">
|
||||
<circle cx="12" cy="5" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="19" r="1.4" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'document'">
|
||||
<path d="M7 4.5h7.5L19 9v10.5a1.5 1.5 0 0 1-1.5 1.5h-10A1.5 1.5 0 0 1 6 19.5v-14A1.5 1.5 0 0 1 7.5 4.5H7Z" />
|
||||
<path d="M14.5 4.5V9H19" />
|
||||
<path d="M9 13h6M9 16.5h4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'download'">
|
||||
<path d="M12 4.5v9" />
|
||||
<path d="M8.5 10.5 12 14l3.5-3.5" />
|
||||
<path d="M6 17.5h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'upload'">
|
||||
<path d="M12 14.5v-9" />
|
||||
<path d="M8.5 8.5 12 5l3.5 3.5" />
|
||||
<path d="M6 17.5h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'bag'">
|
||||
<path d="M6.5 8h11l1 12.5H5.5L6.5 8Z" />
|
||||
<path d="M9 8V6.5A3 3 0 0 1 12 3.5 3 3 0 0 1 15 6.5V8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'clock'">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 8v4.2l2.8 1.8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'user-plus'">
|
||||
<circle cx="10" cy="8" r="3" />
|
||||
<path d="M4 19c.7-3 2.6-4.5 6-4.5s5.3 1.5 6 4.5" />
|
||||
<path d="M17 8v5M14.5 10.5h5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'plus'">
|
||||
<path d="M12 6v12M6 12h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevron' || name === 'chevron-right'">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevron-left'">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevrons-right'">
|
||||
<path d="m6 17 5-5-5-5M13 17l5-5-5-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chevrons-left'">
|
||||
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'arrow-right'">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'arrow-left'">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'star'">
|
||||
<path d="m12 3.4 2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 15.8 7.2 18.3l.9-5.4-3.9-3.8 5.4-.8L12 3.4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pin'">
|
||||
<path d="M15.5 4.5 19.5 8.5 14 14v5l-2 1-2-1v-5L4.5 8.5 8.5 4.5l2.2 2.2a3 3 0 0 0 2.6 0L15.5 4.5Z" />
|
||||
<path d="M12 20v-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'filter'">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'funnel'">
|
||||
<path d="M4 4.5h16l-6.2 8.2V19l-3.6 2v-8.3L4 4.5Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'lightbulb'">
|
||||
<path d="M9 18h6" />
|
||||
<path d="M10 21h4" />
|
||||
<path d="M8 14.2A5.2 5.2 0 1 1 16 14.2C16 16 14.7 17 14 18H10c-.7-1-2-2-2-3.8Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'building'">
|
||||
<path d="M5 21V5.5A1.5 1.5 0 0 1 6.5 4h7A1.5 1.5 0 0 1 15 5.5V21" />
|
||||
<path d="M15 10h2.5A1.5 1.5 0 0 1 19 11.5V21" />
|
||||
<path d="M8 8h2M8 12h2M8 16h2M12 8h1M12 12h1M12 16h1" />
|
||||
<path d="M4 21h16" />
|
||||
</template>
|
||||
<template v-else-if="name === 'user'">
|
||||
<circle cx="12" cy="8" r="3.2" />
|
||||
<path d="M5 19c.9-3.2 3.2-4.8 7-4.8s6.1 1.6 7 4.8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'flag'">
|
||||
<path d="M6 21V4" />
|
||||
<path d="M6 4h10.5l-1.8 3.4L16.5 11H6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'refresh'">
|
||||
<path d="M20 12a8 8 0 1 1-2.2-5.5" />
|
||||
<path d="M20 4v5h-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'x'">
|
||||
<path d="M6 6 18 18M18 6 6 18" />
|
||||
</template>
|
||||
<template v-else-if="name === 'x-circle'">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m9 9 6 6M15 9l-6 6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'mic'">
|
||||
<rect x="9" y="3.5" width="6" height="10" rx="3" />
|
||||
<path d="M7 11.5a5 5 0 0 0 10 0" />
|
||||
<path d="M12 16.5V20M9 20h6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'trash'">
|
||||
<path d="M5 7h14" />
|
||||
<path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" />
|
||||
<path d="M7.5 7 8.3 19a1.5 1.5 0 0 0 1.5 1.4h4.4a1.5 1.5 0 0 0 1.5-1.4L16.5 7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'stop'">
|
||||
<rect x="7.5" y="7.5" width="9" height="9" rx="1.6" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'minus'">
|
||||
<path d="M6 12h12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dollar'">
|
||||
<circle cx="12" cy="12" r="8.4" />
|
||||
<path d="M12 6.8v10.4" />
|
||||
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'check-plain'">
|
||||
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
|
||||
</template>
|
||||
<template v-else-if="name === 'layers'">
|
||||
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
|
||||
<path d="M4 12.2 12 16.4 20 12.2" />
|
||||
<path d="M4 16.2 12 20.4 20 16.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'target'">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<circle cx="12" cy="12" r="4.2" />
|
||||
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pencil'">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'edit'">
|
||||
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'grid'">
|
||||
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2" />
|
||||
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2" />
|
||||
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2" />
|
||||
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'list'">
|
||||
<path d="M8 6h12M8 12h12M8 18h12" />
|
||||
<circle cx="4.5" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="4.5" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="4.5" cy="18" r="1" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'eye'">
|
||||
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'eye-off'">
|
||||
<path d="M2.5 12S6.5 5.5 12 5.5 21.5 12 21.5 12 17.5 18.5 12 18.5 2.5 12 2.5 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M4 20 20 4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'lock'">
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'truck'">
|
||||
<path d="M3 7.5h11V16H3z" />
|
||||
<path d="M14 10h4.2l2.3 3.2V16H14z" />
|
||||
<circle cx="7" cy="17.5" r="1.6" />
|
||||
<circle cx="17" cy="17.5" r="1.6" />
|
||||
<path d="M3 16h1.2M8.7 16H15" />
|
||||
</template>
|
||||
<template v-else-if="name === 'grip'">
|
||||
<circle cx="9" cy="7" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="7" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="9" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="9" cy="17" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="17" r="1.2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'apps-plus'">
|
||||
<rect x="3.2" y="3.2" width="7.4" height="7.4" rx="1.6" />
|
||||
<rect x="13.4" y="3.2" width="7.4" height="7.4" rx="1.6" />
|
||||
<rect x="13.4" y="13.4" width="7.4" height="7.4" rx="1.6" />
|
||||
<path d="M6.9 13.6v6.8M3.5 17h6.8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'crown'">
|
||||
<path d="M4 16.5 6.2 8.2 12 12.4 17.8 8.2 20 16.5H4Z" />
|
||||
<path d="M5.2 18.6h13.6" />
|
||||
<circle cx="6.2" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="5.6" r="1.15" fill="currentColor" stroke="none" />
|
||||
<circle cx="17.8" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'gift'">
|
||||
<rect x="4" y="11" width="16" height="9" rx="1.4" />
|
||||
<path d="M4 11V8.2A1.4 1.4 0 0 1 5.4 6.8h13.2A1.4 1.4 0 0 1 20 8.2V11" />
|
||||
<path d="M12 6.8v13.2" />
|
||||
<path d="M12 6.8c0-2.1-1.5-3.4-3.2-2.4C7.2 5.3 8.1 7.6 12 6.8Z" />
|
||||
<path d="M12 6.8c0-2.1 1.5-3.4 3.2-2.4 1.6.9.7 3.2-3.2 2.4Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'medal'">
|
||||
<circle cx="12" cy="9" r="5.2" />
|
||||
<circle cx="12" cy="9" r="2.3" />
|
||||
<path d="m9.1 13.6-1.4 6.2 4.3-2.3 4.3 2.3-1.4-6.2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'logout'">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M16 17l5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pulse'">
|
||||
<path d="M3 12h3.2l2-6.5L12 19l2.4-7.5H21" />
|
||||
</template>
|
||||
<template v-else-if="name === 'sliders'">
|
||||
<path d="M4 7h16M4 17h16" />
|
||||
<path d="M8 4.5v5M16 14.5v5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'camera'">
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'clip'">
|
||||
<path d="M21.4 11.6 12.7 20.3a5 5 0 0 1-7.1-7.1l8.5-8.4a3.5 3.5 0 0 1 5 5L10.6 18.2a2 2 0 1 1-2.8-2.8l7.1-7.1" />
|
||||
</template>
|
||||
</svg>
|
||||
<VsIcon class="ico" :name="name" map="ui" :size="size ?? 20" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { Button as AButton, Typography } from 'ant-design-vue';
|
||||
import { AudioOutlined, PauseCircleOutlined } from '@ant-design/icons-vue';
|
||||
import { AudioOutlined, PauseCircleOutlined } from '@/icons/ant-bridge';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getVuesaxSvg } from '@/icons/vuesax';
|
||||
import { resolveVuesaxName } from '@/icons/name-map';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** کلید کوتاه vuesax (مثل add) یا نام UiIcon / Ant با map */
|
||||
name: string;
|
||||
size?: number | string;
|
||||
/** منبع نگاشت نام؛ پیشفرض raw = کلید مستقیم پک */
|
||||
map?: 'raw' | 'ui' | 'ant';
|
||||
spin?: boolean;
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
size: 20,
|
||||
map: 'raw',
|
||||
spin: false,
|
||||
},
|
||||
);
|
||||
|
||||
const resolved = computed(() => resolveVuesaxName(props.name, props.map));
|
||||
|
||||
const markup = computed(() => {
|
||||
const svg = getVuesaxSvg(resolved.value);
|
||||
if (!svg) return '';
|
||||
// Strip fixed width/height so CSS size controls the box; keep viewBox.
|
||||
return svg
|
||||
.replace(/\swidth="[^"]*"/, '')
|
||||
.replace(/\sheight="[^"]*"/, '')
|
||||
.replace(
|
||||
'<svg ',
|
||||
`<svg width="${props.size}" height="${props.size}" style="width:${typeof props.size === 'number' ? props.size + 'px' : props.size};height:${typeof props.size === 'number' ? props.size + 'px' : props.size}" `,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="vs-icon"
|
||||
:class="{ 'vs-icon--spin': spin }"
|
||||
:style="{
|
||||
width: typeof size === 'number' ? `${size}px` : size,
|
||||
height: typeof size === 'number' ? `${size}px` : size,
|
||||
}"
|
||||
:title="title"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
v-html="markup"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vs-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.vs-icon :deep(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.vs-icon--spin {
|
||||
animation: vs-icon-spin 1s linear infinite;
|
||||
}
|
||||
@keyframes vs-icon-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,9 +10,10 @@ import type {
|
||||
import { api } from '@/lib/api';
|
||||
import { activityTypeView } from '@/lib/activity-types';
|
||||
import { calendarDaysBetween, formatClockFa, formatJalaliDay, startOfLocalDay, toFaDigits } from '@/lib/jalali';
|
||||
import { Spin as ASpin } from 'ant-design-vue';
|
||||
import { Flex as AFlex } from 'ant-design-vue';
|
||||
import PipelineBoard from '@/components/pipeline/PipelineBoard.vue';
|
||||
import DealCardOverlay from '@/components/pipeline/DealCardOverlay.vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import {
|
||||
ACTIVITY_BOARD_COPY,
|
||||
initialsFromTitle,
|
||||
@@ -441,36 +442,47 @@ function openActivity(id: string) {
|
||||
flexDirection: 'column',
|
||||
}"
|
||||
>
|
||||
<a-spin
|
||||
v-if="initialLoading && !Object.keys(columns).length"
|
||||
tip="در حال بارگذاری ستونها…"
|
||||
/>
|
||||
<PipelineBoard
|
||||
v-else
|
||||
:stages="boardStages"
|
||||
:dragging-id="draggingId"
|
||||
:drop-stage-id="dropStageId"
|
||||
:can-manage="canManage"
|
||||
:copy="ACTIVITY_BOARD_COPY"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-deal="emit('add', $event)"
|
||||
@open-deal="openActivity"
|
||||
@menu-deal="onMenuActivity"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
@rename-stage="onRenameStage"
|
||||
@delete-stage="onDeleteStage"
|
||||
@move-stage="onMoveStage"
|
||||
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
||||
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
||||
@add-stage="onAddStage"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
<CrmSkeleton :loading="initialLoading && !Object.keys(columns).length">
|
||||
<a-flex
|
||||
v-if="initialLoading && !Object.keys(columns).length"
|
||||
:gap="12"
|
||||
:style="{ overflow: 'auto', padding: '8px', flex: 1, minHeight: 0 }"
|
||||
>
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="`sk-col-${i}`"
|
||||
:style="{ width: '280px', flex: '0 0 280px' }"
|
||||
>
|
||||
<CrmSkeletonCard :rows="6" :min-height="360" />
|
||||
</div>
|
||||
</a-flex>
|
||||
<PipelineBoard
|
||||
v-else
|
||||
:stages="boardStages"
|
||||
:dragging-id="draggingId"
|
||||
:drop-stage-id="dropStageId"
|
||||
:can-manage="canManage"
|
||||
:copy="ACTIVITY_BOARD_COPY"
|
||||
:select-mode="selectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drag-over="onDragOver"
|
||||
@drop="onDrop"
|
||||
@add-deal="emit('add', $event)"
|
||||
@open-deal="openActivity"
|
||||
@menu-deal="onMenuActivity"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
@rename-stage="onRenameStage"
|
||||
@delete-stage="onDeleteStage"
|
||||
@move-stage="onMoveStage"
|
||||
@column-drag-start="(id: string) => (draggingColumnId = id)"
|
||||
@column-drag-end="draggingColumnId = null; dropStageId = null"
|
||||
@add-stage="onAddStage"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
</CrmSkeleton>
|
||||
<Teleport to="body">
|
||||
<DealCardOverlay
|
||||
v-if="overlayDeal && draggingId"
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
PlusOutlined,
|
||||
UpOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
ContainerOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
ThunderboltOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
export type AutomationAddPayload =
|
||||
| { kind: 'action'; action: AutomationAction }
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
FilterOutlined,
|
||||
FlagOutlined,
|
||||
GiftOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { isAutomationCondition, type AutomationStep } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { RobotOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
@@ -15,12 +17,15 @@ import {
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
|
||||
import { api } from '@/lib/api';
|
||||
import { brand, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
|
||||
import { LinkOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const props = defineProps<{
|
||||
askQuery: string;
|
||||
@@ -38,11 +43,41 @@ const emit = defineEmits<{
|
||||
notice: [value: { error: string; done: string }];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph, Title: ATypographyTitle } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const AFormItem = AForm.Item;
|
||||
const ATextarea = AInput.TextArea;
|
||||
|
||||
type AnswerMetric = { label: string; value: string };
|
||||
|
||||
const answerText = computed(() => rewriteGregorianDates(props.answer || '').trim());
|
||||
|
||||
/** وقتی پاسخ شبیه «برچسب: مقدار» است، بهصورت کارت متریک نشان بده */
|
||||
const answerMetrics = computed((): AnswerMetric[] | null => {
|
||||
const text = answerText.value;
|
||||
if (!text) return null;
|
||||
|
||||
const linePairs = text
|
||||
.split(/\n+/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const m = line.match(/^([^::]+)\s*[::]\s*(.+)$/);
|
||||
return m ? { label: m[1].trim(), value: m[2].trim() } : null;
|
||||
});
|
||||
|
||||
if (linePairs.length >= 1 && linePairs.every(Boolean)) {
|
||||
return linePairs as AnswerMetric[];
|
||||
}
|
||||
|
||||
const inline = [...text.matchAll(/([^::\n]+?)\s*[::]\s*([۰-۹0-9٪%.,+\-]+)/g)].map((m) => ({
|
||||
label: m[1].trim(),
|
||||
value: m[2].trim(),
|
||||
}));
|
||||
return inline.length >= 2 ? inline : null;
|
||||
});
|
||||
|
||||
const shortcuts = [
|
||||
{ label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
|
||||
{ label: 'عقبافتادهها', prompt: 'فعالیتهای عقبافتاده چیست؟' },
|
||||
@@ -296,33 +331,40 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="home-ai-assistant" class="bg-white rounded-2xl border border-slate-200/80 p-3 shadow-sm flex flex-col justify-between h-[330px] gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0">
|
||||
<RobotOutlined />
|
||||
</span>
|
||||
<h3 class="font-bold text-slate-800 text-base truncate">{{ brand.aiNameFa }}</h3>
|
||||
<section id="home-ai-assistant" class="home-ai">
|
||||
<div class="home-ai__pattern" aria-hidden="true" />
|
||||
<div class="home-ai__orb home-ai__orb--a" aria-hidden="true" />
|
||||
<div class="home-ai__orb home-ai__orb--b" aria-hidden="true" />
|
||||
|
||||
<header class="home-ai__head">
|
||||
<div class="home-ai__identity">
|
||||
<div class="home-ai__avatar">
|
||||
<MentoAiAvatar :size="42" :show-bubble="false" />
|
||||
</div>
|
||||
<div class="home-ai__titles min-w-0">
|
||||
<h3>{{ brand.aiNameFa }}</h3>
|
||||
<p>همراه هوشمند کسبوکار شما</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-tag color="success" class="!m-0 shrink-0">هوش مصنوعی</a-tag>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="bg-emerald-50/50 border border-emerald-200/60 rounded-xl p-3 text-xs text-slate-600 leading-relaxed">
|
||||
<div class="home-ai__tip">
|
||||
<template v-if="answer">{{ answer }}</template>
|
||||
<template v-else-if="error">{{ error }}</template>
|
||||
<template v-else>
|
||||
<strong class="text-slate-800">پیشنهاد هوشمند امروز:</strong>
|
||||
<strong>پیشنهاد هوشمند امروز:</strong>
|
||||
از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200/90 rounded-xl p-1.5 flex items-center gap-2">
|
||||
<div class="home-ai__composer">
|
||||
<a-input
|
||||
:value="askQuery"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:bordered="false"
|
||||
:disabled="asking || voiceBusy || confirming"
|
||||
class="flex-1"
|
||||
class="home-ai__input"
|
||||
@update:value="emit('update:askQuery', $event)"
|
||||
@press-enter="onTypedSubmit"
|
||||
/>
|
||||
@@ -345,14 +387,14 @@ watch(
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 border-t border-slate-100 flex items-center justify-between flex-wrap gap-2">
|
||||
<span class="text-[11px] font-semibold text-slate-400">پیشنهادهای سریع</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<div class="home-ai__chips">
|
||||
<span class="home-ai__chips-label">پیشنهادهای سریع</span>
|
||||
<div class="home-ai__chip-list">
|
||||
<button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="!text-[11px] !leading-4 !font-medium bg-emerald-50/70 text-emerald-700 border border-emerald-200/60 px-2 py-0.5 rounded-md disabled:opacity-50"
|
||||
class="home-ai__chip"
|
||||
:disabled="asking"
|
||||
@click="pick(item.prompt)"
|
||||
>
|
||||
@@ -361,10 +403,12 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" />
|
||||
<a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" />
|
||||
<a-alert v-else-if="voiceDone" type="success" show-icon :message="voiceDone" />
|
||||
<a-alert v-else-if="voiceError" type="error" show-icon :message="voiceError" />
|
||||
<div v-if="voiceBusy || confirming || voiceDone || voiceError" class="home-ai__status">
|
||||
<a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" />
|
||||
<a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" />
|
||||
<a-alert v-else-if="voiceDone" type="success" show-icon :message="voiceDone" />
|
||||
<a-alert v-else-if="voiceError" type="error" show-icon :message="voiceError" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
@@ -493,22 +537,365 @@ watch(
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:open="answerOpen" title="پاسخ دستیار" destroy-on-close @cancel="closeAnswer">
|
||||
<a-typography-paragraph style="white-space: pre-wrap">
|
||||
{{ rewriteGregorianDates(answer || '') }}
|
||||
</a-typography-paragraph>
|
||||
<a-space v-if="sources?.length" wrap>
|
||||
<RouterLink
|
||||
v-for="source in sources"
|
||||
:key="`${source.type}-${source.id}`"
|
||||
:to="sourceTo(source)"
|
||||
@click="closeAnswer"
|
||||
<a-modal
|
||||
v-model:open="answerOpen"
|
||||
:width="560"
|
||||
centered
|
||||
destroy-on-close
|
||||
:body-style="{ paddingTop: `${token.paddingSM}px` }"
|
||||
@cancel="closeAnswer"
|
||||
>
|
||||
<template #title>
|
||||
<a-flex align="center" :gap="token.marginSM">
|
||||
<div
|
||||
:style="{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
borderRadius: '999px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
background: `linear-gradient(145deg, ${token.colorSuccessBg}, ${token.colorBgContainer})`,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<MentoAiAvatar :size="36" :show-bubble="false" />
|
||||
</div>
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong :style="{ fontSize: `${token.fontSizeLG}px`, lineHeight: 1.3 }">
|
||||
پاسخ دستیار
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ brand.aiNameFa }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<a-flex vertical :gap="token.margin">
|
||||
<a-row v-if="answerMetrics?.length" :gutter="[token.marginSM, token.marginSM]">
|
||||
<a-col
|
||||
v-for="(metric, idx) in answerMetrics"
|
||||
:key="`${metric.label}-${idx}`"
|
||||
:xs="24"
|
||||
:sm="answerMetrics.length === 1 ? 24 : 12"
|
||||
>
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:style="{
|
||||
background: token.colorFillAlter,
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
boxShadow: `inset 0 0 0 1px ${token.colorBorderSecondary}`,
|
||||
}"
|
||||
>
|
||||
<a-flex vertical :gap="token.paddingXXS">
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
{{ metric.label }}
|
||||
</a-typography-text>
|
||||
<a-typography-title
|
||||
:level="3"
|
||||
:style="{
|
||||
margin: 0,
|
||||
color: token.colorPrimary,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}"
|
||||
>
|
||||
{{ metric.value }}
|
||||
</a-typography-title>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card
|
||||
v-else
|
||||
:bordered="false"
|
||||
:style="{
|
||||
background: token.colorFillAlter,
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
boxShadow: `inset 0 0 0 1px ${token.colorBorderSecondary}`,
|
||||
}"
|
||||
>
|
||||
<a-tag color="processing">{{ source.label }}</a-tag>
|
||||
</RouterLink>
|
||||
</a-space>
|
||||
<a-typography-paragraph
|
||||
:style="{
|
||||
marginBottom: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
lineHeight: 1.85,
|
||||
color: token.colorText,
|
||||
}"
|
||||
>
|
||||
{{ answerText }}
|
||||
</a-typography-paragraph>
|
||||
</a-card>
|
||||
|
||||
<template v-if="sources?.length">
|
||||
<a-divider :style="{ margin: `${token.marginXS}px 0` }" />
|
||||
<a-flex vertical :gap="token.paddingXS">
|
||||
<a-flex align="center" :gap="token.paddingXXS">
|
||||
<LinkOutlined />
|
||||
<a-typography-text type="secondary" :style="{ fontSize: `${token.fontSizeSM}px` }">
|
||||
منابع مرتبط
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space wrap :size="[8, 8]">
|
||||
<RouterLink
|
||||
v-for="source in sources"
|
||||
:key="`${source.type}-${source.id}`"
|
||||
:to="sourceTo(source)"
|
||||
@click="closeAnswer"
|
||||
>
|
||||
<a-tag
|
||||
color="processing"
|
||||
:bordered="false"
|
||||
:style="{ cursor: 'pointer', borderRadius: `${token.borderRadiusSM}px` }"
|
||||
>
|
||||
{{ source.label }}
|
||||
</a-tag>
|
||||
</RouterLink>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-flex>
|
||||
|
||||
<template #footer>
|
||||
<a-button type="primary" @click="closeAnswer">بستن</a-button>
|
||||
<a-flex justify="end" :gap="token.paddingXS">
|
||||
<a-button type="primary" @click="closeAnswer">متوجه شدم</a-button>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-ai {
|
||||
--home-ai-h: 330px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: var(--home-ai-h);
|
||||
min-height: var(--home-ai-h);
|
||||
max-height: var(--home-ai-h);
|
||||
padding: 0.85rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgb(226 232 240 / 0.9);
|
||||
background:
|
||||
linear-gradient(165deg, #ffffff 0%, #f8fffc 48%, #ffffff 100%);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
|
||||
.home-ai__pattern,
|
||||
.home-ai__orb {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.home-ai__pattern {
|
||||
inset: 0;
|
||||
opacity: 0.55;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.18) 1px, transparent 0),
|
||||
linear-gradient(
|
||||
125deg,
|
||||
rgba(45, 212, 191, 0.06) 0%,
|
||||
transparent 42%,
|
||||
rgba(16, 185, 129, 0.05) 100%
|
||||
);
|
||||
background-size:
|
||||
16px 16px,
|
||||
auto;
|
||||
mask-image: linear-gradient(140deg, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.home-ai__orb {
|
||||
border-radius: 999px;
|
||||
filter: blur(28px);
|
||||
}
|
||||
|
||||
.home-ai__orb--a {
|
||||
top: -2.5rem;
|
||||
inset-inline-end: -1.5rem;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
background: rgb(52 211 153 / 0.22);
|
||||
}
|
||||
|
||||
.home-ai__orb--b {
|
||||
bottom: -2rem;
|
||||
inset-inline-start: -1.25rem;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
background: rgb(45 212 191 / 0.16);
|
||||
}
|
||||
|
||||
.home-ai__head,
|
||||
.home-ai__identity,
|
||||
.home-ai__composer,
|
||||
.home-ai__chips,
|
||||
.home-ai__chip-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-ai__head {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-ai__identity {
|
||||
gap: 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-ai__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 0.9rem;
|
||||
background:
|
||||
radial-gradient(circle at 35% 25%, rgba(255, 255, 255, 0.9), transparent 55%),
|
||||
linear-gradient(145deg, #ecfdf5 0%, #d1fae5 100%);
|
||||
border: 1px solid rgb(167 243 208 / 0.8);
|
||||
box-shadow: 0 4px 12px rgb(16 185 129 / 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-ai__avatar :deep(.mento-bot) {
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.home-ai__avatar :deep(.bot-img) {
|
||||
filter: drop-shadow(0 4px 10px rgba(34, 211, 238, 0.28));
|
||||
}
|
||||
|
||||
.home-ai__titles {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-ai__titles h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-ai__titles p {
|
||||
margin: 0.1rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-ai__tip {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgb(167 243 208 / 0.55);
|
||||
background: rgb(236 253 245 / 0.55);
|
||||
color: #475569;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.65;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.home-ai__tip strong {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-ai__composer {
|
||||
flex-shrink: 0;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.35rem 0.3rem 0.55rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: rgb(248 250 252 / 0.92);
|
||||
}
|
||||
|
||||
.home-ai__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.home-ai__chips {
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem 0.65rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.home-ai__chips-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.home-ai__chip-list {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.home-ai__chip {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(167 243 208 / 0.7);
|
||||
background: rgb(236 253 245 / 0.75);
|
||||
color: #047857;
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.home-ai__chip:hover:not(:disabled) {
|
||||
background: #d1fae5;
|
||||
border-color: #6ee7b7;
|
||||
}
|
||||
|
||||
.home-ai__chip:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.home-ai__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset-inline: 0.85rem;
|
||||
bottom: 0.7rem;
|
||||
max-height: 4.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.home-ai__status :deep(.ant-alert) {
|
||||
padding-block: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ExclamationCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
LeftOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardAlertDto } from '@mento/shared';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { GiftOutlined } from '@ant-design/icons-vue';
|
||||
import { GiftOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { SearchOutlined, SendOutlined } from '@ant-design/icons-vue';
|
||||
import { SearchOutlined, SendOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Input as AInput,
|
||||
@@ -48,26 +48,15 @@ const statusMessage = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="home-hero relative overflow-hidden rounded-2xl border border-emerald-300/50 shadow-sm">
|
||||
<!-- base gradient -->
|
||||
<div class="home-hero__gradient pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
<!-- geometric pattern -->
|
||||
<div class="home-hero__pattern pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
<!-- soft orbs -->
|
||||
<div
|
||||
class="pointer-events-none absolute -left-20 -top-24 h-52 w-52 rounded-full bg-emerald-400/20 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute -right-16 -bottom-20 h-48 w-48 rounded-full bg-teal-500/15 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="home-hero">
|
||||
<div class="home-hero__gradient" aria-hidden="true" />
|
||||
<div class="home-hero__pattern" aria-hidden="true" />
|
||||
<div class="home-hero__orb home-hero__orb--start" aria-hidden="true" />
|
||||
<div class="home-hero__orb home-hero__orb--end" aria-hidden="true" />
|
||||
|
||||
<div
|
||||
class="relative flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-4 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3.5 min-w-0">
|
||||
<div class="home-hero__identity relative shrink-0" :aria-label="firstName">
|
||||
<div class="home-hero__row">
|
||||
<div class="home-hero__greet">
|
||||
<div class="home-hero__identity" :aria-label="firstName">
|
||||
<div class="home-hero__frame">
|
||||
<img
|
||||
v-if="hasCustomPhoto"
|
||||
@@ -81,29 +70,19 @@ const statusMessage = computed(() => {
|
||||
</div>
|
||||
<span class="home-hero__online" title="آنلاین" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] font-semibold tracking-wide text-emerald-700/80">
|
||||
{{ brand.nameFa }}
|
||||
</p>
|
||||
<a-typography-title
|
||||
:level="4"
|
||||
class="!m-0 !mt-0.5 !text-slate-800 !text-lg sm:!text-xl !font-extrabold !leading-7 truncate"
|
||||
>
|
||||
<div class="home-hero__copy">
|
||||
<p class="home-hero__brand">{{ brand.nameFa }}</p>
|
||||
<a-typography-title :level="4" class="home-hero__title">
|
||||
{{ greeting }}، {{ firstName }}
|
||||
</a-typography-title>
|
||||
<a-typography-text class="!text-slate-500 !text-sm !mt-0.5 block">
|
||||
<a-typography-text class="home-hero__subtitle">
|
||||
از دادهها بپرسید یا کار امروز را از همینجا شروع کنید
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 w-full lg:max-w-xl bg-white border border-white/80 rounded-2xl px-3 py-2 shadow-md shadow-emerald-900/5 focus-within:border-emerald-300 focus-within:ring-2 focus-within:ring-emerald-200/70 transition"
|
||||
>
|
||||
<span
|
||||
class="w-8 h-8 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="home-hero__ask">
|
||||
<span class="home-hero__ask-ico" aria-hidden="true">
|
||||
<SearchOutlined />
|
||||
</span>
|
||||
<a-input
|
||||
@@ -112,15 +91,11 @@ const statusMessage = computed(() => {
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:disabled="asking"
|
||||
:bordered="false"
|
||||
class="flex-1 !bg-transparent"
|
||||
class="home-hero__ask-input"
|
||||
@update:value="emit('update:askQuery', $event)"
|
||||
@press-enter="emit('ask')"
|
||||
/>
|
||||
<kbd
|
||||
class="hidden sm:inline-flex items-center text-[11px] font-sans text-slate-400 bg-slate-50 px-2 py-1 rounded-lg border border-slate-200 shrink-0"
|
||||
>
|
||||
{{ shortcutLabel }}
|
||||
</kbd>
|
||||
<kbd class="home-hero__ask-kbd">{{ shortcutLabel }}</kbd>
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="round"
|
||||
@@ -130,7 +105,7 @@ const statusMessage = computed(() => {
|
||||
@click="emit('ask')"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
<span class="hidden sm:inline">بپرس</span>
|
||||
<span class="home-hero__ask-label">بپرس</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,6 +134,31 @@ const statusMessage = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-hero {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgb(110 231 183 / 0.5);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.04);
|
||||
}
|
||||
|
||||
.home-hero__gradient,
|
||||
.home-hero__pattern,
|
||||
.home-hero__orb {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.home-hero__gradient,
|
||||
.home-hero__pattern {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.home-hero__gradient {
|
||||
background:
|
||||
linear-gradient(
|
||||
@@ -188,10 +188,55 @@ const statusMessage = computed(() => {
|
||||
background-size:
|
||||
18px 18px,
|
||||
28px 28px;
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
|
||||
mask-image: linear-gradient(to left, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.home-hero__orb {
|
||||
border-radius: 999px;
|
||||
filter: blur(48px);
|
||||
}
|
||||
|
||||
.home-hero__orb--start {
|
||||
inset-inline-end: -5rem;
|
||||
top: -6rem;
|
||||
width: 13rem;
|
||||
height: 13rem;
|
||||
background: rgb(52 211 153 / 0.2);
|
||||
}
|
||||
|
||||
.home-hero__orb--end {
|
||||
inset-inline-start: -4rem;
|
||||
bottom: -5rem;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
background: rgb(20 184 166 / 0.15);
|
||||
}
|
||||
|
||||
.home-hero__row {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.home-hero__greet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
min-width: 0;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.home-hero__identity {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
@@ -231,7 +276,7 @@ const statusMessage = computed(() => {
|
||||
|
||||
.home-hero__online {
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
inset-inline-start: -1px;
|
||||
bottom: -1px;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
@@ -240,4 +285,123 @@ const statusMessage = computed(() => {
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.home-hero__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-hero__brand {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgb(4 120 87 / 0.8);
|
||||
}
|
||||
|
||||
.home-hero__title {
|
||||
margin: 0.125rem 0 0 !important;
|
||||
color: #1e293b !important;
|
||||
font-size: 1.125rem !important;
|
||||
font-weight: 800 !important;
|
||||
line-height: 1.75rem !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-hero__subtitle {
|
||||
display: block !important;
|
||||
margin-top: 0.125rem !important;
|
||||
color: #64748b !important;
|
||||
font-size: 0.875rem !important;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.home-hero__ask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding-block: 0.375rem;
|
||||
padding-inline: 0.75rem 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgb(255 255 255 / 0.8);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 4px 12px rgb(6 78 59 / 0.05),
|
||||
0 1px 2px rgb(15 23 42 / 0.04);
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.home-hero__ask:focus-within {
|
||||
border-color: #6ee7b7;
|
||||
box-shadow:
|
||||
0 0 0 2px rgb(167 243 208 / 0.7),
|
||||
0 4px 12px rgb(6 78 59 / 0.05);
|
||||
}
|
||||
|
||||
.home-hero__ask-ico {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.75rem;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.home-hero__ask-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.home-hero__ask-kbd {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.home-hero__ask-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.home-hero__ask-kbd {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.home-hero__ask-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.home-hero__title {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.home-hero__row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.home-hero__greet {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RiseOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import type { Component } from 'vue';
|
||||
import type { HomeDashboardKpiDto } from '@mento/shared';
|
||||
import { currencySuffix } from '@/lib/money';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
FundOutlined,
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { PipelineBoardDto } from '@mento/shared';
|
||||
import {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
CheckOutlined,
|
||||
MailOutlined,
|
||||
PhoneOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
Tag as ATag,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { TrophyOutlined } from '@ant-design/icons-vue';
|
||||
import { TrophyOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Flex as AFlex,
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Card as ACard,
|
||||
Divider as ADivider,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
Segmented as ASegmented,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { UserOutlined } from '@/icons/ant-bridge';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
import crownImg from '@/assets/club/crown.png';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const PERIODS: Array<{ value: GamificationPeriod; label: string }> = [
|
||||
{ value: 'today', label: 'امروز' },
|
||||
{ value: 'month', label: 'این ماه' },
|
||||
@@ -40,316 +58,342 @@ onMounted(() => {
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
|
||||
|
||||
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0));
|
||||
/** ترتیب نمایش از راست: سوم، اول، دوم */
|
||||
const podium = computed(() => {
|
||||
const [a, b, c] = top3.value;
|
||||
return [c, a, b].filter((x): x is GamificationLeaderEntryDto => !!x);
|
||||
});
|
||||
const rest = computed(() => {
|
||||
const items = board.value?.items ?? [];
|
||||
return top3.value.length ? items.slice(top3.value.length) : items;
|
||||
});
|
||||
function chgClass(change: number | null) {
|
||||
return change && change > 0 ? 'up' : change && change < 0 ? 'down' : '';
|
||||
const allItems = computed(() => board.value?.items ?? []);
|
||||
|
||||
interface PodiumSlot {
|
||||
rank: number;
|
||||
entry: GamificationLeaderEntryDto | null;
|
||||
stepHeight: number;
|
||||
avatarSize: number;
|
||||
label: string;
|
||||
gradient: string;
|
||||
numberColor: string;
|
||||
ringColor: string;
|
||||
borderColor: string;
|
||||
}
|
||||
|
||||
/** چیدمان ۳ پله سکوی قهرمانی به ترتیب ورزشی از راست به چپ: دوم (نقره)، اول (طلا - مرکز)، سوم (برنز) */
|
||||
const podiumSlots = computed<PodiumSlot[]>(() => {
|
||||
const items = allItems.value;
|
||||
if (!items.length) return [];
|
||||
|
||||
const first = items[0] ?? null;
|
||||
const second = items[1] ?? null;
|
||||
const third = items[2] ?? null;
|
||||
|
||||
return [
|
||||
{
|
||||
rank: 2,
|
||||
entry: second,
|
||||
stepHeight: 74,
|
||||
avatarSize: 48,
|
||||
label: 'نقره',
|
||||
gradient: 'linear-gradient(180deg, #f8fafc 0%, #e2e8f0 45%, #94a3b8 100%)',
|
||||
numberColor: '#334155',
|
||||
ringColor: '#94a3b8',
|
||||
borderColor: '#cbd5e1',
|
||||
},
|
||||
{
|
||||
rank: 1,
|
||||
entry: first,
|
||||
stepHeight: 110,
|
||||
avatarSize: 58,
|
||||
label: 'طلا',
|
||||
gradient: 'linear-gradient(180deg, #fef08a 0%, #f59e0b 55%, #d97706 100%)',
|
||||
numberColor: '#78350f',
|
||||
ringColor: '#f59e0b',
|
||||
borderColor: '#f59e0b',
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
entry: third,
|
||||
stepHeight: 52,
|
||||
avatarSize: 44,
|
||||
label: 'برنز',
|
||||
gradient: 'linear-gradient(180deg, #e8b896 0%, #b87333 50%, #7c4a1e 100%)',
|
||||
numberColor: '#5c3317',
|
||||
ringColor: '#b87333',
|
||||
borderColor: '#cd7f32',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const rest = computed(() => {
|
||||
const items = allItems.value;
|
||||
return items.length > 3 ? items.slice(3) : [];
|
||||
});
|
||||
|
||||
const meOutside = computed(() => {
|
||||
const b = board.value;
|
||||
if (!b?.me) return null;
|
||||
return b.items.some((i) => i.userId === b.me!.userId) ? null : b.me;
|
||||
const inTop3 = allItems.value.slice(0, 3).some((i) => i.userId === b.me!.userId);
|
||||
const inRest = rest.value.some((i) => i.userId === b.me!.userId);
|
||||
return inTop3 || inRest ? null : b.me;
|
||||
});
|
||||
|
||||
type Row = { key: string; sep: boolean; entry: GamificationLeaderEntryDto | null };
|
||||
const rows = computed<Row[]>(() => {
|
||||
const list: Row[] = rest.value.map((e) => ({ key: e.userId, sep: false, entry: e }));
|
||||
if (meOutside.value) {
|
||||
list.push({ key: '__sep', sep: true, entry: null });
|
||||
list.push({ key: `__me-${meOutside.value.userId}`, sep: false, entry: meOutside.value });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function changeType(change: number | null): 'success' | 'danger' | undefined {
|
||||
return change && change > 0 ? 'success' : change && change < 0 ? 'danger' : undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="board">
|
||||
<div class="periods" role="tablist" aria-label="بازهٔ جدول امتیازات">
|
||||
<button
|
||||
v-for="p in PERIODS"
|
||||
:key="p.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="period === p.value"
|
||||
:class="{ on: period === p.value }"
|
||||
@click="period = p.value"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
<a-card title="جدول قهرمانان و رتبهبندی">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-segmented v-model:value="period" block :options="PERIODS" aria-label="بازهٔ جدول امتیازات" />
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<div v-if="podium.length" class="top3" :class="{ loading }">
|
||||
<div
|
||||
v-for="e in podium"
|
||||
:key="e.userId"
|
||||
class="t-card"
|
||||
:class="[`r${e.rank}`, { me: e.isMe }]"
|
||||
>
|
||||
<img v-if="e.rank === 1" :src="crownImg" alt="" class="t-crown" />
|
||||
<span class="t-ava">
|
||||
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="e.rank === 1 ? 50 : 46" />
|
||||
</span>
|
||||
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong>
|
||||
<small>{{ faNum(e.score) }} امتیاز</small>
|
||||
</div>
|
||||
</div>
|
||||
<a-spin :spinning="loading">
|
||||
<a-flex vertical :gap="16">
|
||||
<!-- بخش سکوی قهرمانی ۳ پله با ارتفاعهای نامساوی (Podium) -->
|
||||
<div
|
||||
v-if="podiumSlots.length"
|
||||
:style="{
|
||||
position: 'relative',
|
||||
borderRadius: `${token.borderRadiusLG}px`,
|
||||
background: 'radial-gradient(ellipse at top, rgba(16, 185, 129, 0.06), transparent 70%), linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%)',
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
padding: '24px 12px 0 12px',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
>
|
||||
<!-- ردیف ۳ پله قهرمانی همتراز در کف سکو -->
|
||||
<a-flex justify="center" align="flex-end" :gap="12" :style="{ minHeight: '220px', width: '100%' }">
|
||||
<div
|
||||
v-for="slot in podiumSlots"
|
||||
:key="slot.rank"
|
||||
:style="{
|
||||
flex: 1,
|
||||
maxWidth: '140px',
|
||||
minWidth: '85px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
}"
|
||||
>
|
||||
<!-- اطلاعات کاربر بالای پله (تصویر، نام، امتیاز) -->
|
||||
<a-flex
|
||||
vertical
|
||||
align="center"
|
||||
:gap="4"
|
||||
:style="{
|
||||
width: '100%',
|
||||
marginBottom: '8px',
|
||||
textAlign: 'center',
|
||||
minWidth: 0,
|
||||
}"
|
||||
>
|
||||
<!-- تاج درخشان نفر اول -->
|
||||
<div
|
||||
v-if="slot.rank === 1"
|
||||
:style="{
|
||||
height: '26px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}"
|
||||
>
|
||||
<img
|
||||
:src="crownImg"
|
||||
alt="نفر اول"
|
||||
:style="{ width: '28px', height: '28px', objectFit: 'contain' }"
|
||||
/>
|
||||
</div>
|
||||
<div v-else :style="{ height: '26px' }" />
|
||||
|
||||
<ol class="rows" :class="{ loading }">
|
||||
<li v-for="e in rest" :key="e.userId" :class="{ me: e.isMe }">
|
||||
<span class="rank">{{ toFaDigits(e.rank) }}</span>
|
||||
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="28" />
|
||||
<b class="who">{{ e.isMe ? 'شما' : e.fullName }}</b>
|
||||
<span class="score">{{ faNum(e.score) }}</span>
|
||||
<span class="chg" :class="chgClass(e.change)">
|
||||
<template v-if="e.change">{{ e.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(e.change)) }}</template>
|
||||
</span>
|
||||
</li>
|
||||
<template v-if="meOutside">
|
||||
<li class="sep" aria-hidden="true">⋯</li>
|
||||
<li class="me">
|
||||
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span>
|
||||
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="28" />
|
||||
<b class="who">شما</b>
|
||||
<span class="score">{{ faNum(meOutside.score) }}</span>
|
||||
<span class="chg" :class="chgClass(meOutside.change)">
|
||||
<template v-if="meOutside.change">
|
||||
{{ meOutside.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(meOutside.change)) }}
|
||||
<!-- عکس / آواتار کاربر با حلقه متالیک متناسب با رتبه -->
|
||||
<template v-if="slot.entry">
|
||||
<span
|
||||
:style="{
|
||||
display: 'inline-flex',
|
||||
padding: '2px',
|
||||
borderRadius: '999px',
|
||||
background: '#ffffff',
|
||||
boxShadow: `0 0 0 3px ${slot.ringColor}, 0 4px 10px rgba(0, 0, 0, 0.1)`,
|
||||
}"
|
||||
>
|
||||
<UserAvatar
|
||||
:user-id="slot.entry.userId"
|
||||
:has-avatar="slot.entry.hasAvatar"
|
||||
:name="slot.entry.fullName"
|
||||
:size="slot.avatarSize"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<a-flex align="center" :gap="4" :style="{ maxWidth: '100%' }">
|
||||
<a-typography-text
|
||||
strong
|
||||
:ellipsis="true"
|
||||
:style="{
|
||||
fontSize: slot.rank === 1 ? '13px' : '12px',
|
||||
color: slot.entry.isMe ? token.colorPrimary : token.colorTextHeading,
|
||||
maxWidth: '100%',
|
||||
}"
|
||||
>
|
||||
{{ slot.entry.isMe ? 'شما' : slot.entry.fullName }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
|
||||
<a-tag
|
||||
v-if="slot.entry.isMe"
|
||||
color="success"
|
||||
:bordered="false"
|
||||
:style="{ margin: 0, fontSize: '10px', lineHeight: '18px', padding: '0 6px' }"
|
||||
>
|
||||
شما
|
||||
</a-tag>
|
||||
|
||||
<a-typography-text
|
||||
:style="{
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: slot.rank === 1 ? '#d97706' : token.colorTextSecondary,
|
||||
}"
|
||||
>
|
||||
{{ faNum(slot.entry.score) }} امتیاز
|
||||
</a-typography-text>
|
||||
</template>
|
||||
|
||||
<!-- حالت خالی اگر در تیم جایگاه پر نشده باشد -->
|
||||
<template v-else>
|
||||
<span
|
||||
:style="{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: `${slot.avatarSize}px`,
|
||||
height: `${slot.avatarSize}px`,
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(0, 0, 0, 0.04)',
|
||||
border: '2px dashed #cbd5e1',
|
||||
color: '#94a3b8',
|
||||
}"
|
||||
>
|
||||
<UserOutlined :style="{ fontSize: '20px' }" />
|
||||
</span>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
|
||||
خالی
|
||||
</a-typography-text>
|
||||
<a-typography-text type="secondary" :style="{ fontSize: '11px' }">
|
||||
—
|
||||
</a-typography-text>
|
||||
</template>
|
||||
</a-flex>
|
||||
|
||||
<!-- پله سکوی قهرمانی با ارتفاع اختصاصی و جلوه ۳ بعدی -->
|
||||
<div
|
||||
:style="{
|
||||
width: '100%',
|
||||
height: `${slot.stepHeight}px`,
|
||||
borderRadius: '12px 12px 0 0',
|
||||
background: slot.gradient,
|
||||
border: `1px solid ${slot.borderColor}`,
|
||||
borderBottom: 'none',
|
||||
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.06), inset 0 1px 2px rgba(255, 255, 255, 0.7)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
fontSize: slot.rank === 1 ? '28px' : '22px',
|
||||
fontWeight: 900,
|
||||
color: slot.numberColor,
|
||||
lineHeight: 1,
|
||||
textShadow: '0 1px 1px rgba(255, 255, 255, 0.8)',
|
||||
}"
|
||||
>
|
||||
{{ toFaDigits(slot.rank) }}
|
||||
</span>
|
||||
<span
|
||||
:style="{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: slot.numberColor,
|
||||
opacity: 0.85,
|
||||
marginTop: '2px',
|
||||
}"
|
||||
>
|
||||
{{ slot.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-flex>
|
||||
|
||||
<!-- خط پایه و زمین زیر پلههای سکو -->
|
||||
<div
|
||||
:style="{
|
||||
width: 'calc(100% + 24px)',
|
||||
marginInline: '-12px',
|
||||
height: '10px',
|
||||
background: '#e2e8f0',
|
||||
borderTop: '2px solid #cbd5e1',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- لیست نفرات چهارم به بعد در جدول -->
|
||||
<a-list v-if="rows.length" :data-source="rows" :split="true">
|
||||
<template #renderItem="{ item }">
|
||||
<a-divider v-if="item.sep" :style="{ margin: 0 }">⋯</a-divider>
|
||||
<a-list-item
|
||||
v-else-if="item.entry"
|
||||
:style="item.entry.isMe ? { background: token.colorWarningBg, borderRadius: `${token.borderRadius}px` } : undefined"
|
||||
>
|
||||
<a-flex align="center" :gap="12" :style="{ width: '100%' }">
|
||||
<a-typography-text type="secondary" :style="{ width: '28px', textAlign: 'center', fontWeight: 'bold' }">
|
||||
{{ toFaDigits(item.entry.rank) }}
|
||||
</a-typography-text>
|
||||
<UserAvatar
|
||||
:user-id="item.entry.userId"
|
||||
:has-avatar="item.entry.hasAvatar"
|
||||
:name="item.entry.fullName"
|
||||
:size="32"
|
||||
/>
|
||||
<a-typography-text
|
||||
:strong="item.entry.isMe"
|
||||
:ellipsis="true"
|
||||
:style="{ flex: 1, minWidth: 0 }"
|
||||
>
|
||||
{{ item.entry.isMe ? 'شما' : item.entry.fullName }}
|
||||
</a-typography-text>
|
||||
<a-typography-text :strong="item.entry.isMe">
|
||||
{{ faNum(item.entry.score) }} امتیاز
|
||||
</a-typography-text>
|
||||
<a-typography-text
|
||||
:type="changeType(item.entry.change)"
|
||||
strong
|
||||
:style="{ minWidth: '32px', textAlign: 'end' }"
|
||||
>
|
||||
<template v-if="item.entry.change">
|
||||
{{ item.entry.change > 0 ? '▲' : '▼' }} {{ toFaDigits(Math.abs(item.entry.change)) }}
|
||||
</template>
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li>
|
||||
</ol>
|
||||
</section>
|
||||
</a-list>
|
||||
|
||||
<a-empty
|
||||
v-if="board && !board.items.length"
|
||||
description="هنوز کسی در این بازه امتیاز نگرفته است."
|
||||
/>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 0.75rem 0.9rem 0.5rem;
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.periods {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.periods button {
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.periods button.on {
|
||||
background: #e0e7ff;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.top3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
align-items: end;
|
||||
padding-top: 0.8rem;
|
||||
}
|
||||
|
||||
.t-card {
|
||||
--bg: #e0e7ff;
|
||||
--ring: #a5b4fc;
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 0.4rem 0.7rem;
|
||||
border-radius: 14px;
|
||||
background: var(--bg);
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.t-card.r1 {
|
||||
--bg: #fef3c7;
|
||||
--ring: #f59e0b;
|
||||
padding-bottom: 1rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.t-card.r3 {
|
||||
--bg: #ffedd5;
|
||||
--ring: #fb923c;
|
||||
}
|
||||
|
||||
.t-card.me {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
|
||||
.t-crown {
|
||||
position: absolute;
|
||||
top: -1.55rem;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.t-ava {
|
||||
margin-top: -1.4rem;
|
||||
padding: 3px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px var(--ring);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.t-card.r1 .t-ava {
|
||||
margin-top: -0.4rem;
|
||||
}
|
||||
|
||||
.t-card strong {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.t-card small {
|
||||
font-size: 0.76rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.t-card.r1 small {
|
||||
color: #b45309;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.rows li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.rows li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rows li.me {
|
||||
background: #fef9e7;
|
||||
border-radius: 10px;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.rows li.me .score,
|
||||
.rows li.me .who {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rows li.sep {
|
||||
justify-content: center;
|
||||
color: var(--muted-light);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chg {
|
||||
min-width: 1.8rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 800;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chg.up {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.chg.down {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.score {
|
||||
min-width: 2.8rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.who {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: start;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank {
|
||||
width: 1.6rem;
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
color: #64748b;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.empty {
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.top3 {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.t-card strong {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Component } from 'vue';
|
||||
import {
|
||||
AimOutlined,
|
||||
BankOutlined,
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
ContainerOutlined,
|
||||
CrownOutlined,
|
||||
DollarOutlined,
|
||||
FileTextOutlined,
|
||||
FilterOutlined,
|
||||
FireOutlined,
|
||||
GiftOutlined,
|
||||
MessageOutlined,
|
||||
PhoneOutlined,
|
||||
RiseOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ShoppingOutlined,
|
||||
SlidersOutlined,
|
||||
StarOutlined,
|
||||
SyncOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
/** نگاشت نام آیکونهای باشگاه (txIcon / پاداش / نشان) به آیکونهای Ant Design */
|
||||
const CLUB_ICONS: Record<string, Component> = {
|
||||
trophy: TrophyOutlined,
|
||||
'user-plus': UserAddOutlined,
|
||||
funnel: FilterOutlined,
|
||||
briefcase: ContainerOutlined,
|
||||
phone: PhoneOutlined,
|
||||
calendar: CalendarOutlined,
|
||||
'calendar-sun': CalendarOutlined,
|
||||
document: FileTextOutlined,
|
||||
check: CheckCircleOutlined,
|
||||
refresh: SyncOutlined,
|
||||
dollar: DollarOutlined,
|
||||
chat: MessageOutlined,
|
||||
star: StarOutlined,
|
||||
medal: SafetyCertificateOutlined,
|
||||
gift: GiftOutlined,
|
||||
sliders: SlidersOutlined,
|
||||
building: BankOutlined,
|
||||
trend: RiseOutlined,
|
||||
crown: CrownOutlined,
|
||||
bag: ShoppingOutlined,
|
||||
target: AimOutlined,
|
||||
fire: FireOutlined,
|
||||
users: TeamOutlined,
|
||||
};
|
||||
|
||||
export function clubIcon(name: string | null | undefined): Component {
|
||||
return (name && CLUB_ICONS[name]) || StarOutlined;
|
||||
}
|
||||
@@ -108,7 +108,7 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.7rem;
|
||||
border-radius: 28px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
@@ -204,7 +204,7 @@ onUnmounted(() => {
|
||||
gap: 0.28rem;
|
||||
padding: 0.65rem 0.7rem 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
border-radius: 12px;
|
||||
background: #f7f8fa;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
@@ -267,7 +267,7 @@ onUnmounted(() => {
|
||||
min-height: 2.4rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
border-radius: 10px;
|
||||
background: #ecfdf8;
|
||||
color: #0f766e;
|
||||
font: inherit;
|
||||
@@ -316,7 +316,7 @@ onUnmounted(() => {
|
||||
max-height: min(86dvh, calc(100dvh - 3.5rem));
|
||||
overflow: auto;
|
||||
padding: 0.85rem 0.9rem 0.9rem;
|
||||
border-radius: 24px 24px 0 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -12px 40px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
CalendarOutlined,
|
||||
PlusOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Menu as AMenu,
|
||||
MenuItem as AMenuItem,
|
||||
} from 'ant-design-vue';
|
||||
import { MoreOutlined } from '@ant-design/icons-vue';
|
||||
import { MoreOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
export type ProfileMoreItem = {
|
||||
key: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { PlusOutlined } from '@/icons/ant-bridge';
|
||||
import { Button as AButton } from 'ant-design-vue';
|
||||
import type { PipelineAccent } from '@/lib/pipeline-board';
|
||||
import { PIPELINE_ACCENT } from '@/lib/pipeline-board';
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DeleteOutlined,
|
||||
FlagFilled,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { PlusOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { HolderOutlined, MoreOutlined } from '@ant-design/icons-vue';
|
||||
import { HolderOutlined, MoreOutlined } from '@/icons/ant-bridge';
|
||||
import {
|
||||
Button as AButton,
|
||||
Dropdown as ADropdown,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Button as AButton,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
AppstoreOutlined,
|
||||
AimOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
FundOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
InputNumber as AInputNumber,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CloseOutlined } from '@ant-design/icons-vue';
|
||||
import { CloseOutlined } from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import { ENTITY_TYPE_LABELS } from '@/lib/routines';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SyncOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CheckOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { CheckOutlined, PlusOutlined } from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
import type { RoutineEntityRefDto, RoutineTaskDto } from '@mento/shared';
|
||||
import QuickTaskModal from '@/components/routines/QuickTaskModal.vue';
|
||||
import RoutineTaskRow, { type RoutineTaskRowAction } from '@/components/routines/RoutineTaskRow.vue';
|
||||
@@ -132,7 +132,8 @@ function onCreated(t: RoutineTaskDto) {
|
||||
:style="{ marginTop: '12px' }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading && !items.length">
|
||||
<CrmSkeleton :loading="loading && !items.length">
|
||||
<CrmSkeletonList :rows="4">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
:description="emptyTitle"
|
||||
@@ -185,7 +186,8 @@ function onCreated(t: RoutineTaskDto) {
|
||||
/>
|
||||
</template>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
|
||||
<QuickTaskModal :open="quickOpen" :preset="entity" @close="quickOpen = false" @created="onCreated" />
|
||||
<RoutineTaskModals :actions="actions" />
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
FlagOutlined,
|
||||
MoreOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const AMenuItem = AMenu.Item;
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { provide, toRef } from 'vue';
|
||||
import { CRM_SKELETON_LOADING } from './context';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{ loading: false },
|
||||
);
|
||||
|
||||
provide(CRM_SKELETON_LOADING, toRef(props, 'loading'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crm-skeleton-root" :data-loading="loading ? '1' : '0'">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue';
|
||||
import { Skeleton, theme } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const ASkeletonAvatar = Skeleton.Avatar;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
size?: number;
|
||||
shape?: 'circle' | 'square';
|
||||
}>(),
|
||||
{ size: 40, shape: 'circle' },
|
||||
);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
|
||||
const sizePx = computed(() => props.size ?? token.value.controlHeightLG ?? 40);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-skeleton-avatar
|
||||
v-if="isLoading"
|
||||
active
|
||||
:size="sizePx"
|
||||
:shape="shape"
|
||||
/>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue';
|
||||
import { Skeleton, theme } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const ASkeletonButton = Skeleton.Button;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
round?: boolean;
|
||||
block?: boolean;
|
||||
}>(),
|
||||
{
|
||||
round: false,
|
||||
block: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
|
||||
const widthCss = computed(() =>
|
||||
props.width == null ? '100%' : typeof props.width === 'number' ? `${props.width}px` : props.width,
|
||||
);
|
||||
const heightCss = computed(() =>
|
||||
props.height == null
|
||||
? `${token.value.controlHeight}px`
|
||||
: typeof props.height === 'number'
|
||||
? `${props.height}px`
|
||||
: props.height,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-skeleton-button
|
||||
v-if="isLoading"
|
||||
active
|
||||
:block="block"
|
||||
:shape="round ? 'round' : 'default'"
|
||||
:style="{
|
||||
width: widthCss,
|
||||
minWidth: widthCss,
|
||||
height: heightCss,
|
||||
borderRadius: round ? '999px' : `${token.borderRadius}px`,
|
||||
}"
|
||||
/>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue';
|
||||
import { Card as ACard, Skeleton as ASkeleton } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
/** Approximate body lines inside the card */
|
||||
rows?: number;
|
||||
bordered?: boolean;
|
||||
/** Min height so layout does not jump */
|
||||
minHeight?: number;
|
||||
}>(),
|
||||
{ rows: 4, bordered: false },
|
||||
);
|
||||
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card
|
||||
v-if="isLoading"
|
||||
:bordered="bordered"
|
||||
:style="minHeight ? { minHeight: `${minHeight}px` } : undefined"
|
||||
>
|
||||
<a-skeleton active :title="{ width: '40%' }" :paragraph="{ rows }" />
|
||||
</a-card>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue';
|
||||
import { Skeleton, theme } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const ASkeletonButton = Skeleton.Button;
|
||||
const ASkeletonInput = Skeleton.Input;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
width?: string | number;
|
||||
/** button | input | select — all use controlHeight 36 */
|
||||
shape?: 'button' | 'input' | 'select' | 'default';
|
||||
}>(),
|
||||
{ shape: 'default', width: 120 },
|
||||
);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
|
||||
const widthCss = computed(() =>
|
||||
typeof props.width === 'number' ? `${props.width}px` : props.width,
|
||||
);
|
||||
|
||||
const isField = computed(() => props.shape === 'input' || props.shape === 'select');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-skeleton-input
|
||||
v-if="isLoading && isField"
|
||||
active
|
||||
:style="{
|
||||
width: widthCss,
|
||||
minWidth: widthCss,
|
||||
height: `${token.controlHeight}px`,
|
||||
}"
|
||||
/>
|
||||
<a-skeleton-button
|
||||
v-else-if="isLoading"
|
||||
active
|
||||
:style="{
|
||||
width: widthCss,
|
||||
minWidth: widthCss,
|
||||
height: `${token.controlHeight}px`,
|
||||
borderRadius: `${token.borderRadius}px`,
|
||||
}"
|
||||
/>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue';
|
||||
import { Card as ACard, Flex as AFlex, Skeleton, theme } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const ASkeletonButton = Skeleton.Button;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
bordered?: boolean;
|
||||
}>(),
|
||||
{ bordered: false },
|
||||
);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card v-if="isLoading" :bordered="bordered">
|
||||
<a-flex vertical :gap="token.paddingXS">
|
||||
<a-skeleton-button
|
||||
active
|
||||
:style="{
|
||||
width: '45%',
|
||||
minWidth: 0,
|
||||
height: `${token.fontSizeSM}px`,
|
||||
borderRadius: `${token.borderRadiusSM}px`,
|
||||
}"
|
||||
/>
|
||||
<a-skeleton-button
|
||||
active
|
||||
:style="{
|
||||
width: '60%',
|
||||
minWidth: 0,
|
||||
height: '28px',
|
||||
borderRadius: `${token.borderRadius}px`,
|
||||
}"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue';
|
||||
import { Flex as AFlex, Skeleton as ASkeleton, theme } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
rows?: number;
|
||||
avatar?: boolean;
|
||||
}>(),
|
||||
{ rows: 6, avatar: true },
|
||||
);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-flex v-if="isLoading" vertical :gap="token.paddingSM">
|
||||
<a-skeleton
|
||||
v-for="i in rows"
|
||||
:key="i"
|
||||
active
|
||||
:avatar="avatar ? { size: token.controlHeight, shape: 'circle' } : false"
|
||||
:title="{ width: '40%' }"
|
||||
:paragraph="{ rows: 1, width: ['70%'] }"
|
||||
/>
|
||||
</a-flex>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { toRef } from 'vue';
|
||||
import { Skeleton as ASkeleton } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
rows?: number;
|
||||
width?: string | number | Array<string | number>;
|
||||
}>(),
|
||||
{ rows: 3 },
|
||||
);
|
||||
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-skeleton
|
||||
v-if="isLoading"
|
||||
active
|
||||
:title="false"
|
||||
:paragraph="{ rows, width }"
|
||||
/>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue';
|
||||
import { Card as ACard, Flex as AFlex, Skeleton, theme } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const ASkeletonButton = Skeleton.Button;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
/** Wrap bones in a card (matches most list pages) */
|
||||
card?: boolean;
|
||||
bordered?: boolean;
|
||||
}>(),
|
||||
{ rows: 8, columns: 5, card: true, bordered: false },
|
||||
);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
|
||||
const colWidths = computed(() => {
|
||||
const n = Math.max(1, props.columns);
|
||||
return Array.from({ length: n }, (_, i) => {
|
||||
if (i === 0) return '22%';
|
||||
if (i === n - 1) return '12%';
|
||||
return `${Math.floor(66 / Math.max(1, n - 2))}%`;
|
||||
});
|
||||
});
|
||||
|
||||
const rowHeight = computed(() => token.value.controlHeight + (token.value.paddingXS ?? 8));
|
||||
const headerH = computed(() => Math.round((token.value.fontSizeSM ?? 12) + 4));
|
||||
const cellH = computed(() => token.value.fontSize ?? 14);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card v-if="isLoading && card" :bordered="bordered">
|
||||
<a-flex vertical :gap="token.paddingXS">
|
||||
<a-flex :gap="token.paddingSM" :style="{ marginBottom: `${token.marginXS}px` }">
|
||||
<a-skeleton-button
|
||||
v-for="(w, i) in colWidths"
|
||||
:key="`h-${i}`"
|
||||
active
|
||||
:style="{
|
||||
width: w,
|
||||
minWidth: 0,
|
||||
flex: `0 0 ${w}`,
|
||||
height: `${headerH}px`,
|
||||
borderRadius: `${token.borderRadiusSM}px`,
|
||||
}"
|
||||
/>
|
||||
</a-flex>
|
||||
<a-flex
|
||||
v-for="r in rows"
|
||||
:key="`r-${r}`"
|
||||
:gap="token.paddingSM"
|
||||
align="center"
|
||||
:style="{ minHeight: `${rowHeight}px` }"
|
||||
>
|
||||
<a-skeleton-button
|
||||
v-for="(w, i) in colWidths"
|
||||
:key="`c-${r}-${i}`"
|
||||
active
|
||||
:style="{
|
||||
width: i === 0 ? '85%' : '70%',
|
||||
maxWidth: w,
|
||||
minWidth: 0,
|
||||
flex: `0 0 ${w}`,
|
||||
height: `${cellH}px`,
|
||||
borderRadius: `${token.borderRadiusSM}px`,
|
||||
}"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
<a-flex v-else-if="isLoading" vertical :gap="token.paddingXS">
|
||||
<a-flex :gap="token.paddingSM" :style="{ marginBottom: `${token.marginXS}px` }">
|
||||
<a-skeleton-button
|
||||
v-for="(w, i) in colWidths"
|
||||
:key="`h-${i}`"
|
||||
active
|
||||
:style="{
|
||||
width: w,
|
||||
minWidth: 0,
|
||||
flex: `0 0 ${w}`,
|
||||
height: `${headerH}px`,
|
||||
borderRadius: `${token.borderRadiusSM}px`,
|
||||
}"
|
||||
/>
|
||||
</a-flex>
|
||||
<a-flex
|
||||
v-for="r in rows"
|
||||
:key="`r-${r}`"
|
||||
:gap="token.paddingSM"
|
||||
align="center"
|
||||
:style="{ minHeight: `${rowHeight}px` }"
|
||||
>
|
||||
<a-skeleton-button
|
||||
v-for="(w, i) in colWidths"
|
||||
:key="`c-${r}-${i}`"
|
||||
active
|
||||
:style="{
|
||||
width: i === 0 ? '85%' : '70%',
|
||||
maxWidth: w,
|
||||
minWidth: 0,
|
||||
flex: `0 0 ${w}`,
|
||||
height: `${cellH}px`,
|
||||
borderRadius: `${token.borderRadiusSM}px`,
|
||||
}"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue';
|
||||
import { Skeleton, theme } from 'ant-design-vue';
|
||||
import { useCrmSkeletonLoading } from './context';
|
||||
|
||||
const ASkeletonButton = Skeleton.Button;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
width?: string | number;
|
||||
/** Typography title level approximation: 1≈38, 2≈30, 3≈24, 4≈20, 5≈16 */
|
||||
level?: 1 | 2 | 3 | 4 | 5;
|
||||
}>(),
|
||||
{ level: 3, width: 180 },
|
||||
);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const isLoading = useCrmSkeletonLoading(toRef(props, 'loading'));
|
||||
|
||||
const height = computed(() => {
|
||||
switch (props.level) {
|
||||
case 1:
|
||||
return 38;
|
||||
case 2:
|
||||
return 30;
|
||||
case 3:
|
||||
return 24;
|
||||
case 4:
|
||||
return 20;
|
||||
default:
|
||||
return 16;
|
||||
}
|
||||
});
|
||||
|
||||
const widthCss = computed(() =>
|
||||
typeof props.width === 'number' ? `${props.width}px` : props.width,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-skeleton-button
|
||||
v-if="isLoading"
|
||||
active
|
||||
:style="{
|
||||
width: widthCss,
|
||||
minWidth: widthCss,
|
||||
height: `${height}px`,
|
||||
borderRadius: `${token.borderRadiusSM}px`,
|
||||
}"
|
||||
/>
|
||||
<div v-else :style="{ minHeight: `${height}px` }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { computed, inject, type ComputedRef, type InjectionKey, type Ref } from 'vue';
|
||||
|
||||
export const CRM_SKELETON_LOADING: InjectionKey<Ref<boolean> | ComputedRef<boolean>> =
|
||||
Symbol('crmSkeletonLoading');
|
||||
|
||||
/** Resolve whether a skeleton block should show bones (local override wins). */
|
||||
export function useCrmSkeletonLoading(local?: Ref<boolean | undefined> | ComputedRef<boolean | undefined>) {
|
||||
const injected = inject(CRM_SKELETON_LOADING, null);
|
||||
return computed(() => {
|
||||
if (local?.value !== undefined) return !!local.value;
|
||||
return !!injected?.value;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { default as CrmSkeleton } from './CrmSkeleton.vue';
|
||||
export { default as CrmSkeletonBone } from './CrmSkeletonBone.vue';
|
||||
export { default as CrmSkeletonTitle } from './CrmSkeletonTitle.vue';
|
||||
export { default as CrmSkeletonControl } from './CrmSkeletonControl.vue';
|
||||
export { default as CrmSkeletonParagraph } from './CrmSkeletonParagraph.vue';
|
||||
export { default as CrmSkeletonAvatar } from './CrmSkeletonAvatar.vue';
|
||||
export { default as CrmSkeletonCard } from './CrmSkeletonCard.vue';
|
||||
export { default as CrmSkeletonTable } from './CrmSkeletonTable.vue';
|
||||
export { default as CrmSkeletonKpi } from './CrmSkeletonKpi.vue';
|
||||
export { default as CrmSkeletonList } from './CrmSkeletonList.vue';
|
||||
export { CRM_SKELETON_LOADING, useCrmSkeletonLoading } from './context';
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* جایگزین drop-in برای `@ant-design/icons-vue`.
|
||||
* همان نامهای *Outlined/*Filled را اکسپورت میکند ولی از پک vuesax میکشد.
|
||||
*/
|
||||
import { defineComponent, h } from 'vue';
|
||||
import VsIcon from '@/components/VsIcon.vue';
|
||||
import { ANT_ICON_TO_VUESAX } from './name-map';
|
||||
|
||||
function makeAntIcon(exportName: string) {
|
||||
const vuesax = ANT_ICON_TO_VUESAX[exportName] ?? 'info-circle';
|
||||
return defineComponent({
|
||||
name: exportName,
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
spin: { type: Boolean, default: false },
|
||||
rotate: { type: Number, default: undefined },
|
||||
twoToneColor: { type: String, default: undefined },
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
return () => {
|
||||
const style: Record<string, string> = {};
|
||||
const attrStyle = attrs.style;
|
||||
if (attrStyle && typeof attrStyle === 'object' && !Array.isArray(attrStyle)) {
|
||||
Object.assign(style, attrStyle as Record<string, string>);
|
||||
}
|
||||
if (props.rotate != null) {
|
||||
style.transform = `rotate(${props.rotate}deg)`;
|
||||
}
|
||||
return h(VsIcon, {
|
||||
name: vuesax,
|
||||
map: 'raw',
|
||||
spin: props.spin,
|
||||
class: ['anticon', attrs.class],
|
||||
style: Object.keys(style).length ? style : undefined,
|
||||
// مثل آیکون Ant با font-size والد اسکیل میشود
|
||||
size: '1em',
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function exportAll() {
|
||||
const out: Record<string, ReturnType<typeof makeAntIcon>> = {};
|
||||
for (const name of Object.keys(ANT_ICON_TO_VUESAX)) {
|
||||
out[name] = makeAntIcon(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const icons = exportAll();
|
||||
|
||||
export const AccountBookOutlined = icons.AccountBookOutlined;
|
||||
export const AimOutlined = icons.AimOutlined;
|
||||
export const AlertOutlined = icons.AlertOutlined;
|
||||
export const AppstoreOutlined = icons.AppstoreOutlined;
|
||||
export const ArrowDownOutlined = icons.ArrowDownOutlined;
|
||||
export const ArrowLeftOutlined = icons.ArrowLeftOutlined;
|
||||
export const ArrowRightOutlined = icons.ArrowRightOutlined;
|
||||
export const ArrowUpOutlined = icons.ArrowUpOutlined;
|
||||
export const AudioOutlined = icons.AudioOutlined;
|
||||
export const BankOutlined = icons.BankOutlined;
|
||||
export const BarChartOutlined = icons.BarChartOutlined;
|
||||
export const BellOutlined = icons.BellOutlined;
|
||||
export const BoldOutlined = icons.BoldOutlined;
|
||||
export const BoxPlotOutlined = icons.BoxPlotOutlined;
|
||||
export const BulbOutlined = icons.BulbOutlined;
|
||||
export const CalendarOutlined = icons.CalendarOutlined;
|
||||
export const CameraOutlined = icons.CameraOutlined;
|
||||
export const CarOutlined = icons.CarOutlined;
|
||||
export const CheckCircleOutlined = icons.CheckCircleOutlined;
|
||||
export const CheckOutlined = icons.CheckOutlined;
|
||||
export const ClockCircleOutlined = icons.ClockCircleOutlined;
|
||||
export const CloseCircleOutlined = icons.CloseCircleOutlined;
|
||||
export const CloseOutlined = icons.CloseOutlined;
|
||||
export const CommentOutlined = icons.CommentOutlined;
|
||||
export const ContainerOutlined = icons.ContainerOutlined;
|
||||
export const ControlOutlined = icons.ControlOutlined;
|
||||
export const CopyOutlined = icons.CopyOutlined;
|
||||
export const CreditCardOutlined = icons.CreditCardOutlined;
|
||||
export const CrownOutlined = icons.CrownOutlined;
|
||||
export const CustomerServiceOutlined = icons.CustomerServiceOutlined;
|
||||
export const DeleteOutlined = icons.DeleteOutlined;
|
||||
export const DollarOutlined = icons.DollarOutlined;
|
||||
export const DownOutlined = icons.DownOutlined;
|
||||
export const DownloadOutlined = icons.DownloadOutlined;
|
||||
export const DragOutlined = icons.DragOutlined;
|
||||
export const EditOutlined = icons.EditOutlined;
|
||||
export const EnvironmentOutlined = icons.EnvironmentOutlined;
|
||||
export const ExclamationCircleOutlined = icons.ExclamationCircleOutlined;
|
||||
export const ExpandOutlined = icons.ExpandOutlined;
|
||||
export const ExportOutlined = icons.ExportOutlined;
|
||||
export const EyeInvisibleOutlined = icons.EyeInvisibleOutlined;
|
||||
export const EyeOutlined = icons.EyeOutlined;
|
||||
export const FallOutlined = icons.FallOutlined;
|
||||
export const FileExcelOutlined = icons.FileExcelOutlined;
|
||||
export const FileOutlined = icons.FileOutlined;
|
||||
export const FileTextOutlined = icons.FileTextOutlined;
|
||||
export const FilterOutlined = icons.FilterOutlined;
|
||||
export const FireOutlined = icons.FireOutlined;
|
||||
export const FlagFilled = icons.FlagFilled;
|
||||
export const FlagOutlined = icons.FlagOutlined;
|
||||
export const FullscreenExitOutlined = icons.FullscreenExitOutlined;
|
||||
export const FullscreenOutlined = icons.FullscreenOutlined;
|
||||
export const FundOutlined = icons.FundOutlined;
|
||||
export const GiftOutlined = icons.GiftOutlined;
|
||||
export const HistoryOutlined = icons.HistoryOutlined;
|
||||
export const HolderOutlined = icons.HolderOutlined;
|
||||
export const HomeOutlined = icons.HomeOutlined;
|
||||
export const IdcardOutlined = icons.IdcardOutlined;
|
||||
export const InfoCircleOutlined = icons.InfoCircleOutlined;
|
||||
export const ItalicOutlined = icons.ItalicOutlined;
|
||||
export const LeftOutlined = icons.LeftOutlined;
|
||||
export const LinkOutlined = icons.LinkOutlined;
|
||||
export const LockOutlined = icons.LockOutlined;
|
||||
export const LogoutOutlined = icons.LogoutOutlined;
|
||||
export const MailOutlined = icons.MailOutlined;
|
||||
export const MessageOutlined = icons.MessageOutlined;
|
||||
export const MinusOutlined = icons.MinusOutlined;
|
||||
export const MoreOutlined = icons.MoreOutlined;
|
||||
export const NotificationOutlined = icons.NotificationOutlined;
|
||||
export const NumberOutlined = icons.NumberOutlined;
|
||||
export const PaperClipOutlined = icons.PaperClipOutlined;
|
||||
export const PauseCircleOutlined = icons.PauseCircleOutlined;
|
||||
export const PhoneOutlined = icons.PhoneOutlined;
|
||||
export const PictureOutlined = icons.PictureOutlined;
|
||||
export const PlusOutlined = icons.PlusOutlined;
|
||||
export const PrinterOutlined = icons.PrinterOutlined;
|
||||
export const PushpinOutlined = icons.PushpinOutlined;
|
||||
export const QuestionCircleOutlined = icons.QuestionCircleOutlined;
|
||||
export const ReloadOutlined = icons.ReloadOutlined;
|
||||
export const RightOutlined = icons.RightOutlined;
|
||||
export const RiseOutlined = icons.RiseOutlined;
|
||||
export const RobotOutlined = icons.RobotOutlined;
|
||||
export const SafetyCertificateOutlined = icons.SafetyCertificateOutlined;
|
||||
export const SaveOutlined = icons.SaveOutlined;
|
||||
export const ScheduleOutlined = icons.ScheduleOutlined;
|
||||
export const SearchOutlined = icons.SearchOutlined;
|
||||
export const SelectOutlined = icons.SelectOutlined;
|
||||
export const SendOutlined = icons.SendOutlined;
|
||||
export const SettingOutlined = icons.SettingOutlined;
|
||||
export const ShoppingOutlined = icons.ShoppingOutlined;
|
||||
export const SlidersOutlined = icons.SlidersOutlined;
|
||||
export const SmileOutlined = icons.SmileOutlined;
|
||||
export const SolutionOutlined = icons.SolutionOutlined;
|
||||
export const SoundOutlined = icons.SoundOutlined;
|
||||
export const StarOutlined = icons.StarOutlined;
|
||||
export const StopOutlined = icons.StopOutlined;
|
||||
export const SyncOutlined = icons.SyncOutlined;
|
||||
export const TagOutlined = icons.TagOutlined;
|
||||
export const TeamOutlined = icons.TeamOutlined;
|
||||
export const ThunderboltOutlined = icons.ThunderboltOutlined;
|
||||
export const ToolOutlined = icons.ToolOutlined;
|
||||
export const TrophyOutlined = icons.TrophyOutlined;
|
||||
export const UnorderedListOutlined = icons.UnorderedListOutlined;
|
||||
export const UpOutlined = icons.UpOutlined;
|
||||
export const UploadOutlined = icons.UploadOutlined;
|
||||
export const UserAddOutlined = icons.UserAddOutlined;
|
||||
export const UserOutlined = icons.UserOutlined;
|
||||
export const WalletOutlined = icons.WalletOutlined;
|
||||
export const WarningOutlined = icons.WarningOutlined;
|
||||
export const ZoomInOutlined = icons.ZoomInOutlined;
|
||||
export const ZoomOutOutlined = icons.ZoomOutOutlined;
|
||||
@@ -0,0 +1,6 @@
|
||||
export { getVuesaxSvg, hasVuesax, VUESAX_NAMES, type VuesaxName } from './vuesax';
|
||||
export {
|
||||
ANT_ICON_TO_VUESAX,
|
||||
UI_ICON_TO_VUESAX,
|
||||
resolveVuesaxName,
|
||||
} from './name-map';
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* نامهای قدیمی پروژه → کلید کوتاه پک vuesax-linear.
|
||||
* اگر آیکون دقیق نبود نزدیکترین معادل انتخاب شده.
|
||||
*/
|
||||
|
||||
/** نامهای UiIcon → vuesax */
|
||||
export const UI_ICON_TO_VUESAX: Record<string, string> = {
|
||||
home: 'home',
|
||||
users: 'profile-2user',
|
||||
briefcase: 'briefcase',
|
||||
check: 'tick-circle',
|
||||
box: 'box',
|
||||
chart: 'chart',
|
||||
settings: 'setting-2',
|
||||
search: 'search-normal',
|
||||
sparkle: 'magic-star',
|
||||
bell: 'notification',
|
||||
calendar: 'calendar',
|
||||
'calendar-plus': 'calendar-add',
|
||||
mail: 'sms',
|
||||
megaphone: 'volume-high',
|
||||
send: 'send-2',
|
||||
fire: 'flash',
|
||||
warning: 'warning-2',
|
||||
trend: 'trend-up',
|
||||
phone: 'call',
|
||||
trophy: 'cup',
|
||||
chat: 'messages',
|
||||
dots: 'more',
|
||||
'dots-v': 'more',
|
||||
document: 'document-text',
|
||||
bag: 'bag',
|
||||
clock: 'clock',
|
||||
'user-plus': 'user-add',
|
||||
plus: 'add',
|
||||
chevron: 'arrow-down',
|
||||
star: 'star',
|
||||
pin: 'location',
|
||||
filter: 'filter',
|
||||
funnel: 'filter',
|
||||
lightbulb: 'lamp-on',
|
||||
building: 'buildings',
|
||||
user: 'user',
|
||||
flag: 'flag',
|
||||
refresh: 'refresh',
|
||||
x: 'close-circle',
|
||||
'x-circle': 'close-circle',
|
||||
mic: 'microphone',
|
||||
trash: 'trash',
|
||||
stop: 'stop',
|
||||
'check-plain': 'tick-circle',
|
||||
'calendar-sun': 'calendar-1',
|
||||
'calendar-edit': 'calendar-edit',
|
||||
minus: 'minus',
|
||||
dollar: 'dollar-circle',
|
||||
layers: 'layer',
|
||||
target: 'other:target',
|
||||
pencil: 'edit-2',
|
||||
edit: 'edit',
|
||||
grid: 'element-3',
|
||||
list: 'row-vertical',
|
||||
eye: 'eye',
|
||||
'eye-off': 'eye-slash',
|
||||
lock: 'lock',
|
||||
truck: 'truck-tick',
|
||||
grip: 'element-equal',
|
||||
'apps-plus': 'additem',
|
||||
crown: 'crown',
|
||||
gift: 'gift',
|
||||
medal: 'medal',
|
||||
logout: 'logout',
|
||||
pulse: 'activity',
|
||||
sliders: 'setting-4',
|
||||
camera: 'camera',
|
||||
clip: 'paperclip',
|
||||
download: 'document-download',
|
||||
upload: 'document-upload',
|
||||
'arrow-right': 'arrow-right',
|
||||
'arrow-left': 'arrow-left',
|
||||
'chevron-right': 'arrow-right-1',
|
||||
'chevron-left': 'arrow-left-1',
|
||||
'chevrons-right': 'arrow-right-2',
|
||||
'chevrons-left': 'arrow-left-2',
|
||||
};
|
||||
|
||||
/** کامپوننتهای @ant-design/icons-vue → vuesax */
|
||||
export const ANT_ICON_TO_VUESAX: Record<string, string> = {
|
||||
AccountBookOutlined: 'book',
|
||||
AimOutlined: 'other:target',
|
||||
AlertOutlined: 'warning-2',
|
||||
AppstoreOutlined: 'element-3',
|
||||
ArrowDownOutlined: 'arrow-down',
|
||||
ArrowLeftOutlined: 'arrow-left',
|
||||
ArrowRightOutlined: 'arrow-right',
|
||||
ArrowUpOutlined: 'arrow-up',
|
||||
AudioOutlined: 'volume-high',
|
||||
BankOutlined: 'bank',
|
||||
BarChartOutlined: 'chart-2',
|
||||
BellOutlined: 'notification',
|
||||
BoldOutlined: 'text-bold',
|
||||
BoxPlotOutlined: 'chart-21',
|
||||
BulbOutlined: 'lamp-on',
|
||||
CalendarOutlined: 'calendar',
|
||||
CameraOutlined: 'camera',
|
||||
CarOutlined: 'car',
|
||||
CheckCircleOutlined: 'tick-circle',
|
||||
CheckOutlined: 'tick-circle',
|
||||
ClockCircleOutlined: 'clock',
|
||||
CloseCircleOutlined: 'close-circle',
|
||||
CloseOutlined: 'close-circle',
|
||||
CommentOutlined: 'message-text',
|
||||
ContainerOutlined: 'box',
|
||||
ControlOutlined: 'setting-4',
|
||||
CopyOutlined: 'copy',
|
||||
CreditCardOutlined: 'card',
|
||||
CrownOutlined: 'crown',
|
||||
CustomerServiceOutlined: 'headphone',
|
||||
DeleteOutlined: 'trash',
|
||||
DollarOutlined: 'dollar-circle',
|
||||
DownOutlined: 'arrow-down',
|
||||
DownloadOutlined: 'document-download',
|
||||
DragOutlined: 'element-equal',
|
||||
EditOutlined: 'edit-2',
|
||||
EnvironmentOutlined: 'location',
|
||||
ExclamationCircleOutlined: 'warning-2',
|
||||
ExpandOutlined: 'maximize-4',
|
||||
ExportOutlined: 'export',
|
||||
EyeInvisibleOutlined: 'eye-slash',
|
||||
EyeOutlined: 'eye',
|
||||
FallOutlined: 'trend-down',
|
||||
FileExcelOutlined: 'document-text',
|
||||
FileOutlined: 'document',
|
||||
FileTextOutlined: 'document-text',
|
||||
FilterOutlined: 'filter',
|
||||
FireOutlined: 'flash',
|
||||
FlagFilled: 'flag',
|
||||
FlagOutlined: 'flag',
|
||||
FullscreenExitOutlined: 'maximize-3',
|
||||
FullscreenOutlined: 'maximize-4',
|
||||
FundOutlined: 'chart',
|
||||
GiftOutlined: 'gift',
|
||||
HistoryOutlined: 'clock',
|
||||
HolderOutlined: 'element-equal',
|
||||
HomeOutlined: 'home',
|
||||
IdcardOutlined: 'personalcard',
|
||||
InfoCircleOutlined: 'info-circle',
|
||||
ItalicOutlined: 'text-italic',
|
||||
LeftOutlined: 'arrow-left-1',
|
||||
LinkOutlined: 'link',
|
||||
LockOutlined: 'lock',
|
||||
LogoutOutlined: 'logout',
|
||||
MailOutlined: 'sms',
|
||||
MessageOutlined: 'message',
|
||||
MinusOutlined: 'minus',
|
||||
MoreOutlined: 'more',
|
||||
NotificationOutlined: 'notification-bing',
|
||||
NumberOutlined: 'hashtag',
|
||||
PaperClipOutlined: 'paperclip',
|
||||
PauseCircleOutlined: 'pause',
|
||||
PhoneOutlined: 'call',
|
||||
PictureOutlined: 'gallery',
|
||||
PlusOutlined: 'add',
|
||||
PrinterOutlined: 'printer',
|
||||
PushpinOutlined: 'location',
|
||||
QuestionCircleOutlined: 'message-question',
|
||||
ReloadOutlined: 'refresh-2',
|
||||
RightOutlined: 'arrow-right-1',
|
||||
RiseOutlined: 'trend-up',
|
||||
RobotOutlined: 'cpu',
|
||||
SafetyCertificateOutlined: 'shield-tick',
|
||||
SaveOutlined: 'save-2',
|
||||
ScheduleOutlined: 'calendar-tick',
|
||||
SearchOutlined: 'search-normal',
|
||||
SelectOutlined: 'mouse-square',
|
||||
SendOutlined: 'send-2',
|
||||
SettingOutlined: 'setting-2',
|
||||
ShoppingOutlined: 'shopping-bag',
|
||||
SlidersOutlined: 'setting-4',
|
||||
SmileOutlined: 'emoji-happy',
|
||||
SolutionOutlined: 'clipboard-text',
|
||||
SoundOutlined: 'volume-high',
|
||||
StarOutlined: 'star',
|
||||
StopOutlined: 'stop',
|
||||
SyncOutlined: 'refresh',
|
||||
TagOutlined: 'tag',
|
||||
TeamOutlined: 'profile-2user',
|
||||
ThunderboltOutlined: 'flash',
|
||||
ToolOutlined: 'setting',
|
||||
TrophyOutlined: 'cup',
|
||||
UnorderedListOutlined: 'row-vertical',
|
||||
UpOutlined: 'arrow-up',
|
||||
UploadOutlined: 'document-upload',
|
||||
UserAddOutlined: 'user-add',
|
||||
UserOutlined: 'user',
|
||||
WalletOutlined: 'wallet-2',
|
||||
WarningOutlined: 'warning-2',
|
||||
ZoomInOutlined: 'search-zoom-in',
|
||||
ZoomOutOutlined: 'search-zoom-out',
|
||||
};
|
||||
|
||||
export function resolveVuesaxName(
|
||||
name: string,
|
||||
source: 'ui' | 'ant' | 'raw' = 'raw',
|
||||
): string {
|
||||
if (source === 'ui') return UI_ICON_TO_VUESAX[name] ?? name;
|
||||
if (source === 'ant') return ANT_ICON_TO_VUESAX[name] ?? name;
|
||||
return name;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
||||
import pack from './vuesax-linear.json';
|
||||
|
||||
export type VuesaxName = keyof typeof pack & string;
|
||||
|
||||
const icons = pack as Record<string, string>;
|
||||
|
||||
/** Resolve short or prefixed name → SVG markup (currentColor). */
|
||||
export function getVuesaxSvg(name: string): string | null {
|
||||
const key = name.startsWith('vuesax-linear:')
|
||||
? name.slice('vuesax-linear:'.length)
|
||||
: name;
|
||||
return icons[key] ?? null;
|
||||
}
|
||||
|
||||
export function hasVuesax(name: string): boolean {
|
||||
return getVuesaxSvg(name) != null;
|
||||
}
|
||||
|
||||
export const VUESAX_NAMES = Object.keys(icons) as VuesaxName[];
|
||||
+676
-352
File diff suppressed because it is too large
Load Diff
@@ -145,8 +145,9 @@ html {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -154,7 +155,6 @@ body {
|
||||
font-family: var(--font);
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
img,
|
||||
|
||||
@@ -82,5 +82,16 @@ export const antdTheme = {
|
||||
Form: {
|
||||
itemMarginBottom: 18,
|
||||
},
|
||||
/**
|
||||
* Soft luxury shimmer — cool grey gradient on layout fill.
|
||||
* Consumed by a-skeleton / CrmSkeleton* primitives only.
|
||||
*/
|
||||
Skeleton: {
|
||||
colorFillContent: GRAY_100,
|
||||
colorFill: GRAY_200,
|
||||
borderRadius: 8,
|
||||
paragraphLiHeight: 14,
|
||||
titleHeight: 20,
|
||||
},
|
||||
},
|
||||
} as ThemeConfig;
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
StarOutlined,
|
||||
UploadOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
calendarDaysBetween,
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
toFaDigits,
|
||||
} from '@/lib/jalali';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
import ActivitiesKanban from '@/components/activities/ActivitiesKanban.vue';
|
||||
import ActivityStagesManager from '@/components/activities/ActivityStagesManager.vue';
|
||||
import ActivityTypesManager from '@/components/activities/ActivityTypesManager.vue';
|
||||
@@ -1388,7 +1389,8 @@ onUnmounted(() => {
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="8" :card="false">
|
||||
<a-empty
|
||||
v-if="listEmpty"
|
||||
:description="
|
||||
@@ -1678,7 +1680,8 @@ onUnmounted(() => {
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
</a-spin>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Switch as ASwitch,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
@@ -71,7 +70,8 @@ import {
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
CarOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import type {
|
||||
ActivityBoardDto,
|
||||
ActivityDto,
|
||||
@@ -670,7 +670,8 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-row :gutter="[24, 24]" align="top">
|
||||
<a-col :xs="24" :lg="17" :xl="18">
|
||||
<a-card :bordered="false">
|
||||
@@ -1027,7 +1028,8 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
</a-affix>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-modal
|
||||
v-model:open="newPersonOpen"
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
Card as ACard,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { DownloadOutlined } from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Link: ATypographyLink } = Typography;
|
||||
|
||||
@@ -109,7 +109,8 @@ onMounted(load);
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="5">
|
||||
<a-card v-if="listEmpty && !error" :bordered="false">
|
||||
<a-empty description="پیوستی ثبت نشده." />
|
||||
</a-card>
|
||||
@@ -152,6 +153,7 @@ onMounted(load);
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -30,15 +30,15 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tabs as ATabs,
|
||||
TabPane as ATabPane,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { ReloadOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -526,7 +526,8 @@ onMounted(() => {
|
||||
<a-alert v-if="okMsg" type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="5">
|
||||
<a-card v-if="initialLoading" :bordered="false">
|
||||
<a-typography-text type="secondary">لطفاً صبر کنید.</a-typography-text>
|
||||
</a-card>
|
||||
@@ -724,7 +725,8 @@ onMounted(() => {
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-modal
|
||||
:open="!!purgeItem"
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
MenuItem as AMenuItem,
|
||||
Modal as AModal,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Switch as ASwitch,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
@@ -55,7 +54,8 @@ import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
@@ -613,11 +613,8 @@ watch(trigger, (next, prev) => {
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-spin
|
||||
:spinning="loading"
|
||||
:style="{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }"
|
||||
wrapper-class-name="auto-spin-fill"
|
||||
>
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="8" :min-height="360">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
:style="{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }"
|
||||
@@ -871,7 +868,8 @@ watch(trigger, (next, prev) => {
|
||||
</a-button>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-modal
|
||||
v-model:open="createOpen"
|
||||
|
||||
@@ -32,15 +32,15 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
TabPane as ATabPane,
|
||||
Tabs as ATabs,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { PlusOutlined, ReloadOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const {
|
||||
Title: ATypographyTitle,
|
||||
@@ -404,7 +404,8 @@ onMounted(load);
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="5">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
:description="emptyDescription"
|
||||
@@ -459,7 +460,8 @@ onMounted(load);
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</a-spin>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Flex as AFlex,
|
||||
Progress as AProgress,
|
||||
Row as ARow,
|
||||
Spin as ASpin,
|
||||
Statistic as AStatistic,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
@@ -27,8 +26,9 @@ import {
|
||||
ReloadOutlined,
|
||||
RiseOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import type { CommissionEntryDto } from '@mento/shared';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
|
||||
@@ -184,53 +184,55 @@ onMounted(load);
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty
|
||||
description="پورسانتی ثبت نشده است؛ هنوز هیچ فاکتوری دارای پورسانت در سامانه ثبت نگردیده است."
|
||||
/>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'کارشناس فروش'">
|
||||
<a-typography-text strong>{{ record.userName }}</a-typography-text>
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="6" :card="false">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty
|
||||
description="پورسانتی ثبت نشده است؛ هنوز هیچ فاکتوری دارای پورسانت در سامانه ثبت نگردیده است."
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'شماره و عنوان فاکتور'">
|
||||
<a-flex vertical :gap="0">
|
||||
<RouterLink
|
||||
:to="{ name: 'invoice-detail', params: { id: record.invoiceId } }"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a-typography-link :href="href" @click="navigate">
|
||||
{{ record.invoiceNumber }}
|
||||
</a-typography-link>
|
||||
</RouterLink>
|
||||
<a-typography-text v-if="record.invoiceTitle" type="secondary">
|
||||
{{ record.invoiceTitle }}
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'کارشناس فروش'">
|
||||
<a-typography-text strong>{{ record.userName }}</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'شماره و عنوان فاکتور'">
|
||||
<a-flex vertical :gap="0">
|
||||
<RouterLink
|
||||
:to="{ name: 'invoice-detail', params: { id: record.invoiceId } }"
|
||||
custom
|
||||
v-slot="{ href, navigate }"
|
||||
>
|
||||
<a-typography-link :href="href" @click="navigate">
|
||||
{{ record.invoiceNumber }}
|
||||
</a-typography-link>
|
||||
</RouterLink>
|
||||
<a-typography-text v-if="record.invoiceTitle" type="secondary">
|
||||
{{ record.invoiceTitle }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مبلغ پورسانت'">
|
||||
<a-typography-text strong>
|
||||
{{ formatMoney(record.amount, record.currency) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag :color="record.status === 'posted' ? 'success' : 'default'" :bordered="false">
|
||||
{{ record.status === 'posted' ? 'ثبتشده' : 'باطل' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مبلغ پورسانت'">
|
||||
<a-typography-text strong>
|
||||
{{ formatMoney(record.amount, record.currency) }}
|
||||
</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag :color="record.status === 'posted' ? 'success' : 'default'" :bordered="false">
|
||||
{{ record.status === 'posted' ? 'ثبتشده' : 'باطل' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</a-table>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Breadcrumb as ABreadcrumb,
|
||||
BreadcrumbItem as ABreadcrumbItem,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Modal,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CheckOutlined, DeleteOutlined } from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import { api } from '@/lib/api';
|
||||
import type { CompanyDto, CreateCompanyRequest } from '@mento/shared';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
@@ -10,15 +30,8 @@ import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import CustomerGroupPicker from '@/components/CustomerGroupPicker.vue';
|
||||
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiTextarea,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -181,13 +194,22 @@ async function submit() {
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!id.value || !confirm('این شرکت حذف شود؟ افراد متصل بدون شرکت میمانند.')) return;
|
||||
try {
|
||||
await api.delete(`/companies/${id.value}`);
|
||||
await router.push({ name: 'contacts', query: { tab: 'companies' } });
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود';
|
||||
}
|
||||
if (!id.value) return;
|
||||
Modal.confirm({
|
||||
title: 'حذف شرکت',
|
||||
content: 'این شرکت حذف شود؟ افراد متصل بدون شرکت میمانند.',
|
||||
okText: 'حذف',
|
||||
okType: 'danger',
|
||||
cancelText: 'انصراف',
|
||||
async onOk() {
|
||||
try {
|
||||
await api.delete(`/companies/${id.value}`);
|
||||
await router.push({ name: 'contacts', query: { tab: 'companies' } });
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -202,232 +224,256 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page company-form-page" :class="{ embedded }" dir="rtl">
|
||||
<UiPageHeader
|
||||
<a-flex vertical :gap="16" dir="rtl">
|
||||
<a-flex
|
||||
v-if="!embedded"
|
||||
:title="pageTitle"
|
||||
description="ثبت و ویرایش مشخصات حقوقی، اطلاعات تماس و فیلدهای سفارشی شرکت"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
:gap="12"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<RouterLink :to="{ name: 'contacts', query: { tab: 'companies' } }" class="breadcrumb-link">
|
||||
شرکتها
|
||||
</RouterLink>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span>{{ pageTitle }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<UiButton :loading="saving" @click="submit">
|
||||
<template #icon>
|
||||
<UiIcon name="check-plain" />
|
||||
</template>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<RouterLink :to="{ name: 'contacts', query: { tab: 'companies' } }">
|
||||
شرکتها
|
||||
</RouterLink>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
{{ pageTitle }}
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
ثبت و ویرایش مشخصات حقوقی، اطلاعات تماس و فیلدهای سفارشی شرکت
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button type="primary" :loading="saving" @click="submit">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
ذخیره
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
|
||||
<template #icon>
|
||||
<UiIcon name="trash" />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
|
||||
<a-button
|
||||
v-if="!isNew"
|
||||
danger
|
||||
:disabled="saving"
|
||||
@click="remove"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<div v-if="error" class="crm-alert crm-alert-danger">{{ error }}</div>
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<form class="crm-form" @submit.prevent="submit">
|
||||
<UiCard title="اطلاعات اصلی شرکت" class="section-card">
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="نام شرکت" required class="col-span-2">
|
||||
<UiInput
|
||||
id="name"
|
||||
v-model="name"
|
||||
required
|
||||
minlength="2"
|
||||
placeholder="مثال: دادهپردازی سپهر"
|
||||
/>
|
||||
</UiField>
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-form layout="vertical" @finish="submit">
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">اطلاعات اصلی شرکت</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="نام شرکت" required>
|
||||
<a-input
|
||||
id="name"
|
||||
v-model:value="name"
|
||||
:minlength="2"
|
||||
placeholder="مثال: دادهپردازی سپهر"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="کد شرکت">
|
||||
<a-input id="code" v-model:value="code" placeholder="کد داخلی" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="کد دیدار شرکت">
|
||||
<a-input
|
||||
id="sourceRef"
|
||||
v-model:value="sourceRef"
|
||||
placeholder="مثال: 5277"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="گروههای شرکت">
|
||||
<CustomerGroupPicker v-model="groupIds" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تاریخ ثبت شرکت">
|
||||
<JalaliDateInput id="registeredAt" v-model="registeredAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="برچسبها">
|
||||
<a-input
|
||||
id="company-tags"
|
||||
v-model:value="tagsRaw"
|
||||
placeholder="شرکتی، VIP (با کاما جدا کنید)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="توضیحات شرکت">
|
||||
<a-textarea
|
||||
id="notes"
|
||||
v-model:value="notes"
|
||||
:rows="4"
|
||||
placeholder="توضیحات مهم، حوزههای همکاری..."
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<UiField label="کد شرکت">
|
||||
<UiInput id="code" v-model="code" placeholder="کد داخلی" dir="ltr" />
|
||||
</UiField>
|
||||
<a-card :bordered="false" :style="{ marginTop: '16px' }">
|
||||
<a-divider orientation="right">اطلاعات تماس و آدرس</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تلفن شرکت">
|
||||
<a-input
|
||||
id="phone"
|
||||
v-model:value="phone"
|
||||
type="tel"
|
||||
placeholder="۰۲۱..."
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تلفن های دیگر شرکت">
|
||||
<a-input
|
||||
id="otherPhones"
|
||||
v-model:value="otherPhones"
|
||||
placeholder="با کاما جدا کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ایمیل شرکت">
|
||||
<a-input
|
||||
id="email"
|
||||
v-model:value="email"
|
||||
type="email"
|
||||
placeholder="info@company.ir"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ایمیل های دیگر شرکت">
|
||||
<a-input
|
||||
id="otherEmails"
|
||||
v-model:value="otherEmails"
|
||||
placeholder="با کاما جدا کنید"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شماره فکس">
|
||||
<a-input id="fax" v-model:value="fax" type="tel" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="سایت های شرکت">
|
||||
<a-input
|
||||
id="website"
|
||||
v-model:value="website"
|
||||
placeholder="https://..."
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="استان">
|
||||
<a-input id="province" v-model:value="province" placeholder="مثال: تهران" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شهرستان">
|
||||
<a-input id="city" v-model:value="city" placeholder="مثال: تهران" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="کد پستی">
|
||||
<a-input id="postalCode" v-model:value="postalCode" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="آدرس">
|
||||
<a-input
|
||||
id="address"
|
||||
v-model:value="address"
|
||||
placeholder="نشانی، طبقه، پلاک..."
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<UiField label="کد دیدار شرکت">
|
||||
<UiInput id="sourceRef" v-model="sourceRef" placeholder="مثال: 5277" dir="ltr" />
|
||||
</UiField>
|
||||
<a-card :bordered="false" :style="{ marginTop: '16px' }">
|
||||
<a-divider orientation="right">اطلاعات حقوقی و ثبتی</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شناسه ملی">
|
||||
<a-input
|
||||
id="nationalId"
|
||||
v-model:value="nationalId"
|
||||
placeholder="شناسه ملی ۱۱ رقمی"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شماره اقتصادی">
|
||||
<a-input id="economicCode" v-model:value="economicCode" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="شماره ثبت">
|
||||
<a-input
|
||||
id="registrationNumber"
|
||||
v-model:value="registrationNumber"
|
||||
dir="ltr"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<UiField label="گروههای شرکت" class="col-span-2">
|
||||
<CustomerGroupPicker v-model="groupIds" />
|
||||
</UiField>
|
||||
<CustomFieldsSection entity-type="company" v-model="customFields" />
|
||||
<RelatedTasksPanel
|
||||
v-if="!isNew && id"
|
||||
:entity="{ type: 'company', id, label: name }"
|
||||
/>
|
||||
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
|
||||
|
||||
<UiField label="تاریخ ثبت شرکت">
|
||||
<JalaliDateInput id="registeredAt" v-model="registeredAt" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="برچسبها" class="col-span-2">
|
||||
<UiInput
|
||||
id="company-tags"
|
||||
v-model="tagsRaw"
|
||||
placeholder="شرکتی، VIP (با کاما جدا کنید)"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="توضیحات شرکت" class="col-span-2">
|
||||
<UiTextarea
|
||||
id="notes"
|
||||
v-model="notes"
|
||||
:rows="4"
|
||||
placeholder="توضیحات مهم، حوزههای همکاری..."
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="اطلاعات تماس و آدرس" class="section-card">
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="تلفن شرکت">
|
||||
<UiInput id="phone" v-model="phone" type="tel" placeholder="۰۲۱..." dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="تلفن های دیگر شرکت">
|
||||
<UiInput id="otherPhones" v-model="otherPhones" placeholder="با کاما جدا کنید" dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="ایمیل شرکت">
|
||||
<UiInput
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="info@company.ir"
|
||||
dir="ltr"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="ایمیل های دیگر شرکت">
|
||||
<UiInput id="otherEmails" v-model="otherEmails" placeholder="با کاما جدا کنید" dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="شماره فکس">
|
||||
<UiInput id="fax" v-model="fax" type="tel" dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="سایت های شرکت">
|
||||
<UiInput id="website" v-model="website" placeholder="https://..." dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="استان">
|
||||
<UiInput id="province" v-model="province" placeholder="مثال: تهران" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="شهرستان">
|
||||
<UiInput id="city" v-model="city" placeholder="مثال: تهران" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="کد پستی">
|
||||
<UiInput id="postalCode" v-model="postalCode" dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="آدرس" class="col-span-2">
|
||||
<UiInput id="address" v-model="address" placeholder="نشانی، طبقه، پلاک..." />
|
||||
</UiField>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="اطلاعات حقوقی و ثبتی" class="section-card">
|
||||
<div class="crm-form-grid-2">
|
||||
<UiField label="شناسه ملی">
|
||||
<UiInput id="nationalId" v-model="nationalId" placeholder="شناسه ملی ۱۱ رقمی" dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="شماره اقتصادی">
|
||||
<UiInput id="economicCode" v-model="economicCode" dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="شماره ثبت">
|
||||
<UiInput id="registrationNumber" v-model="registrationNumber" dir="ltr" />
|
||||
</UiField>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<CustomFieldsSection entity-type="company" v-model="customFields" />
|
||||
<RelatedTasksPanel v-if="!isNew && id" :entity="{ type: 'company', id, label: name }" />
|
||||
<EntityTimeline v-if="!isNew && id" entity-type="company" :entity-id="id" />
|
||||
|
||||
<div class="form-bottom-actions">
|
||||
<UiButton type="submit" :loading="saving">
|
||||
<template #icon>
|
||||
<UiIcon name="check-plain" />
|
||||
</template>
|
||||
ذخیره اطلاعات
|
||||
</UiButton>
|
||||
<UiButton variant="ghost" type="button" :disabled="saving" @click="cancel">
|
||||
انصراف
|
||||
</UiButton>
|
||||
<UiButton v-if="!isNew" variant="danger" type="button" :disabled="saving" @click="remove">
|
||||
حذف شرکت
|
||||
</UiButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<a-flex justify="flex-end" :style="{ marginTop: '16px' }">
|
||||
<a-space :size="8" wrap>
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
ذخیره اطلاعات
|
||||
</a-button>
|
||||
<a-button :disabled="saving" @click="cancel">انصراف</a-button>
|
||||
<a-button
|
||||
v-if="!isNew"
|
||||
danger
|
||||
:disabled="saving"
|
||||
@click="remove"
|
||||
>
|
||||
حذف شرکت
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-form>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.company-form-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.company-form-page.embedded {
|
||||
max-width: none;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.embedded-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--color-brand-600);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
margin: 0 var(--sp-2);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
.col-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.form-bottom-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.col-span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.form-bottom-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,13 +46,14 @@ import {
|
||||
PhoneOutlined,
|
||||
PlusOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
import PersonFormView from '@/views/PersonFormView.vue';
|
||||
import CompanyFormView from '@/views/CompanyFormView.vue';
|
||||
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
|
||||
@@ -782,7 +783,8 @@ function peopleCustomRow(record: PersonDto) {
|
||||
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-spin v-if="!error" :spinning="loading" tip="در حال بارگذاری…">
|
||||
<CrmSkeleton v-if="!error" :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="10" :card="false">
|
||||
<template v-if="tab === 'people'">
|
||||
<template v-if="people?.items.length">
|
||||
<a-alert v-if="selectedIds.length" type="info" show-icon>
|
||||
@@ -1042,7 +1044,6 @@ function peopleCustomRow(record: PersonDto) {
|
||||
v-else-if="!loading"
|
||||
description="شخصی ثبت نشده است. اولین مخاطب یا مشتری را اضافه کنید."
|
||||
/>
|
||||
<div v-else :style="{ minHeight: '160px' }" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -1157,9 +1158,9 @@ function peopleCustomRow(record: PersonDto) {
|
||||
v-else-if="!loading"
|
||||
description="شرکتی ثبت نشده است. اولین شرکت یا سازمان را اضافه کنید."
|
||||
/>
|
||||
<div v-else :style="{ minHeight: '160px' }" />
|
||||
</template>
|
||||
</a-spin>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-flex v-if="total" justify="flex-start" :style="{ marginTop: `${token.margin}px` }">
|
||||
<a-pagination
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
FormItem as AFormItem,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Spin as ASpin,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -134,8 +134,9 @@ onMounted(load);
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-card v-if="!loading" :bordered="false">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="6">
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="vertical" @finish="save">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
@@ -218,6 +219,7 @@ onMounted(load);
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -26,11 +26,11 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { PlusOutlined, ReloadOutlined } from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -250,7 +250,8 @@ onMounted(load);
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="6" :avatar="false">
|
||||
<a-empty
|
||||
v-if="!loading && !visible.length"
|
||||
description="فیلدی تعریف نشده است. هیچ فیلد سفارشی برای این موجودیت وجود ندارد."
|
||||
@@ -285,7 +286,8 @@ onMounted(load);
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Select as ASelect,
|
||||
Space,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
@@ -35,7 +34,8 @@ import {
|
||||
FileTextOutlined,
|
||||
PlusOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canAssignToOthers } from '@/lib/assign-access';
|
||||
@@ -607,7 +607,8 @@ onMounted(load);
|
||||
|
||||
<template>
|
||||
<div dir="rtl">
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-flex
|
||||
v-if="!embedded"
|
||||
justify="space-between"
|
||||
@@ -1026,7 +1027,8 @@ onMounted(load);
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-modal
|
||||
v-model:open="newPersonOpen"
|
||||
|
||||
@@ -30,11 +30,11 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tabs as ATabs,
|
||||
TabPane as ATabPane,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -585,7 +585,8 @@ void loadActors();
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="5">
|
||||
<a-card v-if="showDeletesEmpty" :bordered="false">
|
||||
<a-empty description="حذفی ثبت نشده. از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند." />
|
||||
</a-card>
|
||||
@@ -661,7 +662,8 @@ void loadActors();
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-flex
|
||||
v-if="listSource"
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import type { DiscDistributionRow } from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Progress as AProgress,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { ArrowRightOutlined, DownloadOutlined } from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiPageHeader } from '@/components/ui';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const router = useRouter();
|
||||
const rows = ref<DiscDistributionRow[]>([]);
|
||||
const error = ref('');
|
||||
const loading = ref(true);
|
||||
@@ -40,36 +56,76 @@ async function exportExcel() {
|
||||
}
|
||||
}
|
||||
|
||||
const total = () => rows.value.reduce((sum, row) => sum + row.count, 0);
|
||||
const totalCount = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0));
|
||||
|
||||
function percentOf(count: number) {
|
||||
return totalCount.value ? Math.round((count / totalCount.value) * 100) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="توزیع تیپهای DISC" lead="فقط ارزیابیهای تأییدشده و قابل اتکا، در محدودهٔ دسترسی شما" :back-to="{ name: 'contacts' }">
|
||||
<template #actions>
|
||||
<UiButton variant="outline" :disabled="exporting" @click="exportExcel">خروجی اکسل</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-else-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="!rows.length" class="muted">هنوز تیپ قابل اتکایی ثبت نشده است.</p>
|
||||
<ul v-else class="list">
|
||||
<li v-for="row in rows" :key="row.label">
|
||||
<RouterLink :to="{ name: 'contacts', query: { disc: row.label } }">{{ row.label }}</RouterLink>
|
||||
<span>{{ toFaDigits(row.count) }}</span>
|
||||
<i><b :style="{ width: `${total() ? Math.round((row.count / total()) * 100) : 0}%` }" /></i>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">درصدها سهم مشتریان دارای ارزیابی معتبر هستند، نه دقت یک آزمون روانشناسی.</p>
|
||||
</section>
|
||||
</template>
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :span="24">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
توزیع تیپهای DISC
|
||||
</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
فقط ارزیابیهای تأییدشده و قابل اتکا، در محدودهٔ دسترسی شما
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-space wrap>
|
||||
<a-button @click="router.push({ name: 'contacts' })">
|
||||
<template #icon><ArrowRightOutlined /></template>
|
||||
بازگشت
|
||||
</a-button>
|
||||
<a-button :disabled="exporting" :loading="exporting" @click="exportExcel">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
خروجی اکسل
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
|
||||
<style scoped>
|
||||
.page { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
.list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.list li { display: grid; grid-template-columns: 6rem auto 1fr; gap: 0.6rem; align-items: center; }
|
||||
.list i { height: 8px; background: #e2e8f0; border-radius: 99px; overflow: hidden; }
|
||||
.list b { display: block; height: 100%; background: #0f766e; }
|
||||
.muted { color: #64748b; }
|
||||
.error { color: #b91c1c; }
|
||||
</style>
|
||||
<a-col :span="24">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="6">
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-empty
|
||||
v-else-if="!rows.length"
|
||||
description="هنوز تیپ قابل اتکایی ثبت نشده است."
|
||||
/>
|
||||
<a-card v-else :bordered="false">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-row
|
||||
v-for="row in rows"
|
||||
:key="row.label"
|
||||
:gutter="[12, 8]"
|
||||
align="middle"
|
||||
>
|
||||
<a-col :xs="24" :sm="4">
|
||||
<RouterLink :to="{ name: 'contacts', query: { disc: row.label } }">
|
||||
{{ row.label }}
|
||||
</RouterLink>
|
||||
</a-col>
|
||||
<a-col :xs="8" :sm="4">
|
||||
<a-typography-text>{{ toFaDigits(row.count) }}</a-typography-text>
|
||||
</a-col>
|
||||
<a-col :xs="16" :sm="16">
|
||||
<a-progress :percent="percentOf(row.count)" :show-info="false" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-typography-text type="secondary">
|
||||
درصدها سهم مشتریان دارای ارزیابی معتبر هستند، نه دقت یک آزمون روانشناسی.
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
@@ -21,12 +21,12 @@ import {
|
||||
InputNumber as AInputNumber,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
import { DeleteOutlined, EditOutlined } from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
@@ -201,7 +201,8 @@ onMounted(load);
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="6">
|
||||
<a-empty
|
||||
v-if="!loading && !items.length"
|
||||
description="هنوز مقالهای ثبت نشده است. با ثبت اولین سؤال و پاسخ، پایگاه دانش را تکمیل کنید."
|
||||
@@ -240,6 +241,7 @@ onMounted(load);
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -9,13 +9,6 @@ import HomeHero from '@/components/dashboard/HomeHero.vue';
|
||||
import HomeAlertsSection from '@/components/dashboard/HomeAlertsSection.vue';
|
||||
import HomeKpiRow from '@/components/dashboard/HomeKpiRow.vue';
|
||||
import HomeBirthdayStrip from '@/components/dashboard/HomeBirthdayStrip.vue';
|
||||
import HomeSalesTrend from '@/components/dashboard/HomeSalesTrend.vue';
|
||||
import HomeCustomerGrowth from '@/components/dashboard/HomeCustomerGrowth.vue';
|
||||
import HomeDealOutcomeChart from '@/components/dashboard/HomeDealOutcomeChart.vue';
|
||||
import HomeFunnelChart from '@/components/dashboard/HomeFunnelChart.vue';
|
||||
import HomeSourcesChart from '@/components/dashboard/HomeSourcesChart.vue';
|
||||
import HomeLostReasonsChart from '@/components/dashboard/HomeLostReasonsChart.vue';
|
||||
import HomeActivityMixChart from '@/components/dashboard/HomeActivityMixChart.vue';
|
||||
import HomeAiAssistant from '@/components/dashboard/HomeAiAssistant.vue';
|
||||
import HomeRecentActivities from '@/components/dashboard/HomeRecentActivities.vue';
|
||||
import HomeMiniPipeline from '@/components/dashboard/HomeMiniPipeline.vue';
|
||||
@@ -88,26 +81,12 @@ function formatKpiValue(key: string, value: number) {
|
||||
return toFaDigits(value);
|
||||
}
|
||||
|
||||
const emptySalesSummary = {
|
||||
openCount: 0,
|
||||
wonCount: 0,
|
||||
lostCount: 0,
|
||||
openAmount: 0,
|
||||
wonAmount: 0,
|
||||
lostAmount: 0,
|
||||
winRate: 0,
|
||||
};
|
||||
|
||||
function formatChange(value: number | null) {
|
||||
if (value == null) return '';
|
||||
const sign = value >= 0 ? '+' : '';
|
||||
return `${sign}${toFaDigits(value)}٪`;
|
||||
}
|
||||
|
||||
function formatTrendAmount(value: number) {
|
||||
return formatMoney(value, home.value?.currency ?? 'IRR');
|
||||
}
|
||||
|
||||
function clearAsk() {
|
||||
askAnswer.value = '';
|
||||
askError.value = '';
|
||||
@@ -276,60 +255,6 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-200/80 pb-3">
|
||||
<div class="w-1.5 h-6 rounded-full bg-emerald-600"></div>
|
||||
<div>
|
||||
<h2 class="text-lg font-extrabold text-slate-800">تحلیل فروش</h2>
|
||||
<p class="text-xs text-slate-500">روند، نتیجه معاملات و قیف مراحل باز</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div class="lg:col-span-7 min-w-0">
|
||||
<HomeSalesTrend
|
||||
:trend="home?.salesTrend ?? []"
|
||||
:change-percent="home?.kpis?.[0]?.changePercent ?? null"
|
||||
:format-change="formatChange"
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
</div>
|
||||
<div class="lg:col-span-5 min-w-0">
|
||||
<HomeDealOutcomeChart
|
||||
:summary="home?.salesSummary ?? emptySalesSummary"
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div class="lg:col-span-7 min-w-0">
|
||||
<HomeCustomerGrowth :series="home?.customerGrowth ?? []" />
|
||||
</div>
|
||||
<div class="lg:col-span-5 min-w-0">
|
||||
<HomeFunnelChart
|
||||
:funnel="home?.funnel ?? []"
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-2.5 border-b border-slate-200/80 pb-3">
|
||||
<div class="w-1.5 h-6 rounded-full bg-teal-600"></div>
|
||||
<div>
|
||||
<h2 class="text-lg font-extrabold text-slate-800">منابع و عملیات</h2>
|
||||
<p class="text-xs text-slate-500">ورود مشتری، دلایل باخت و ترکیب پیگیریها</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<HomeSourcesChart :sources="home?.sources ?? []" />
|
||||
<HomeLostReasonsChart
|
||||
:reasons="home?.lostReasons ?? []"
|
||||
:format-amount="formatTrendAmount"
|
||||
/>
|
||||
<HomeActivityMixChart :items="home?.activityByType ?? []" />
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
<div class="lg:col-span-6 min-w-0 flex">
|
||||
<HomeRecentActivities class="w-full" :activities="activities" :format-when="formatWhen" />
|
||||
|
||||
@@ -7,21 +7,48 @@ import { formatMoney } from '@/lib/money';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import {
|
||||
UiBadge,
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Statistic as AStatistic,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import {
|
||||
BankOutlined,
|
||||
BoxPlotOutlined,
|
||||
HistoryOutlined,
|
||||
ReloadOutlined,
|
||||
WalletOutlined,
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonKpi, CrmSkeletonTable } from '@/components/skeleton';
|
||||
import type {
|
||||
InventoryStockRowDto,
|
||||
InventorySummaryDto,
|
||||
StockMovementDto,
|
||||
StockMovementType,
|
||||
} from '@mento/shared';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const STOCK_COL_LABELS = ['نام کالا', 'واحد', 'موجودی'] as const;
|
||||
|
||||
registerTableColumns('inventory.stock', [...STOCK_COL_LABELS]);
|
||||
const tableCols = useTableColumns(() => 'inventory.stock');
|
||||
|
||||
const auth = useAuthStore();
|
||||
const canWrite = computed(() => canManageSettings(auth.user?.role));
|
||||
|
||||
@@ -31,7 +58,7 @@ const summary = ref<InventorySummaryDto | null>(null);
|
||||
const movements = ref<StockMovementDto[]>([]);
|
||||
const productId = ref('');
|
||||
const moveType = ref<'purchase' | 'adjustment'>('purchase');
|
||||
const quantity = ref('');
|
||||
const quantity = ref<number | undefined>(undefined);
|
||||
const note = ref('');
|
||||
const saving = ref(false);
|
||||
|
||||
@@ -43,12 +70,59 @@ const typeLabel: Record<StockMovementType, string> = {
|
||||
adjustment: 'اصلاح',
|
||||
};
|
||||
|
||||
const moveTypeOptions = [
|
||||
{ value: 'purchase', label: 'ورود خرید' },
|
||||
{ value: 'adjustment', label: 'اصلاح موجودی (عدد جدید)' },
|
||||
];
|
||||
|
||||
const productOptions = computed(() =>
|
||||
(summary.value?.products ?? []).map((p) => ({
|
||||
value: p.productId,
|
||||
label: p.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const selectedStock = computed(
|
||||
() =>
|
||||
summary.value?.products.find((p) => p.productId === productId.value)
|
||||
?.stockQty ?? 0,
|
||||
);
|
||||
|
||||
const stockColumns = computed<TableColumnsType<InventoryStockRowDto>>(() => {
|
||||
const all: TableColumnsType<InventoryStockRowDto> = [
|
||||
{ title: 'نام کالا', key: 'نام کالا' },
|
||||
{
|
||||
title: 'واحد',
|
||||
key: 'واحد',
|
||||
customRender: ({ record }) => record.unit || 'عدد',
|
||||
},
|
||||
{ title: 'موجودی', key: 'موجودی' },
|
||||
];
|
||||
return all.filter((col) => {
|
||||
const key = String(col.key ?? '');
|
||||
if (!STOCK_COL_LABELS.includes(key as (typeof STOCK_COL_LABELS)[number])) {
|
||||
return true;
|
||||
}
|
||||
return tableCols.isVisible(key);
|
||||
});
|
||||
});
|
||||
|
||||
const movementColumns: TableColumnsType<StockMovementDto> = [
|
||||
{ title: 'کالا', key: 'کالا' },
|
||||
{
|
||||
title: 'نوع',
|
||||
key: 'نوع',
|
||||
customRender: ({ record }) => typeLabel[record.type],
|
||||
},
|
||||
{ title: 'مقدار', key: 'مقدار' },
|
||||
{
|
||||
title: 'تاریخ',
|
||||
key: 'تاریخ',
|
||||
customRender: ({ record }) =>
|
||||
formatJalaliNumeric(new Date(record.occurredAt)),
|
||||
},
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
@@ -94,7 +168,7 @@ async function submitMove() {
|
||||
quantity: sendQty,
|
||||
note: note.value.trim() || undefined,
|
||||
});
|
||||
quantity.value = '';
|
||||
quantity.value = undefined;
|
||||
note.value = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -111,278 +185,217 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page">
|
||||
<UiPageHeader
|
||||
title="انبار"
|
||||
lead="موجودی کالا، ورود خرید و مانده وقتی تهحساب وصل نباشد"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="ghost" type="button" :disabled="loading" @click="load">
|
||||
<UiIcon name="refresh" :size="16" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
<a-flex vertical :gap="16">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">انبار</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
موجودی کالا، ورود خرید و مانده وقتی تهحساب وصل نباشد
|
||||
</a-typography-text>
|
||||
</div>
|
||||
<a-button :disabled="loading" :loading="loading" @click="load">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
بروزرسانی
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="error"
|
||||
closable
|
||||
@close="error = ''"
|
||||
/>
|
||||
|
||||
<CrmSkeleton :loading="loading && !summary">
|
||||
<a-alert
|
||||
v-if="summary && !summary.enabled"
|
||||
type="info"
|
||||
show-icon
|
||||
message="اتصال تهحساب فعال است؛ موجودی و مانده از حسابداری خارجی خوانده میشود."
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="24" :md="8">
|
||||
<CrmSkeletonKpi>
|
||||
<a-card v-if="summary" :bordered="false">
|
||||
<a-statistic
|
||||
title="صندوق (وصولها)"
|
||||
:value="formatMoney(summary.cashTotal, summary.currency)"
|
||||
>
|
||||
<template #prefix><WalletOutlined /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</CrmSkeletonKpi>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<CrmSkeletonKpi>
|
||||
<a-card v-if="summary" :bordered="false">
|
||||
<a-statistic
|
||||
title="مانده طرفحسابها"
|
||||
:value="formatMoney(summary.receivablesTotal, summary.currency)"
|
||||
>
|
||||
<template #prefix><BankOutlined /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</CrmSkeletonKpi>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<CrmSkeletonKpi>
|
||||
<a-card v-if="summary" :bordered="false">
|
||||
<a-statistic title="نام انبار" :value="summary.warehouseName || '—'">
|
||||
<template #prefix><BoxPlotOutlined /></template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</CrmSkeletonKpi>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card v-if="summary && canWrite" :bordered="false" :style="{ marginTop: '16px' }">
|
||||
<a-form layout="vertical" @finish="submitMove">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-form-item label="نوع گردش">
|
||||
<a-select v-model:value="moveType" :options="moveTypeOptions" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-form-item label="محصول">
|
||||
<a-select
|
||||
v-model:value="productId"
|
||||
:options="productOptions"
|
||||
placeholder="انتخاب محصول"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="4">
|
||||
<a-form-item :label="moveType === 'adjustment' ? 'موجودی جدید' : 'تعداد'">
|
||||
<a-input-number
|
||||
v-model:value="quantity"
|
||||
:style="{ width: '100%' }"
|
||||
:placeholder="moveType === 'adjustment' ? 'موجودی جدید' : 'تعداد'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="5">
|
||||
<a-form-item label="توضیح">
|
||||
<a-input v-model:value="note" placeholder="توضیح اختیاری…" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="3">
|
||||
<a-form-item label=" ">
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
:disabled="saving || !productId"
|
||||
:loading="saving"
|
||||
>
|
||||
ثبت گردش
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-row :gutter="[16, 16]" :style="{ marginTop: '16px' }">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<CrmSkeletonTable :rows="8" :columns="3">
|
||||
<a-card v-if="summary" :bordered="false">
|
||||
<template #title>
|
||||
<a-flex align="center" :gap="8">
|
||||
<BoxPlotOutlined />
|
||||
<span>موجودی کالا</span>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template #extra>
|
||||
<TableColumnsMenu
|
||||
v-if="summary.products.length"
|
||||
table-key="inventory.stock"
|
||||
align="end"
|
||||
/>
|
||||
</template>
|
||||
<a-table
|
||||
:columns="stockColumns"
|
||||
:data-source="summary.products"
|
||||
:pagination="false"
|
||||
row-key="productId"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty description="محصولی در انبار تعریف نشده است." />
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'نام کالا'">
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong>{{ record.name }}</a-typography-text>
|
||||
<a-typography-text v-if="record.sku" type="secondary">
|
||||
{{ record.sku }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'موجودی'">
|
||||
<a-tag
|
||||
:color="record.stockQty > 0 ? 'success' : 'error'"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ toFaDigits(String(record.stockQty)) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</CrmSkeletonTable>
|
||||
</a-col>
|
||||
|
||||
<a-col :xs="24" :lg="12">
|
||||
<CrmSkeletonTable :rows="8" :columns="4">
|
||||
<a-card v-if="summary" :bordered="false">
|
||||
<template #title>
|
||||
<a-flex align="center" :gap="8">
|
||||
<HistoryOutlined />
|
||||
<span>گردش اخیر کالا</span>
|
||||
</a-flex>
|
||||
</template>
|
||||
<a-table
|
||||
:columns="movementColumns"
|
||||
:data-source="movements"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #emptyText>
|
||||
<a-empty description="هیچ گردشی ثبت نشده است." />
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'کالا'">
|
||||
<a-flex vertical :gap="0">
|
||||
<a-typography-text strong>{{ record.productName }}</a-typography-text>
|
||||
<a-typography-text v-if="record.note" type="secondary">
|
||||
{{ record.note }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مقدار'">
|
||||
<a-tag
|
||||
:color="record.quantity > 0 ? 'success' : 'error'"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ record.quantity > 0 ? '+' : '' }}{{ toFaDigits(String(record.quantity)) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</CrmSkeletonTable>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<p v-else-if="loading && !summary" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else-if="summary && !summary.enabled" class="notice-box">
|
||||
اتصال تهحساب فعال است؛ موجودی و مانده از حسابداری خارجی خوانده میشود.
|
||||
</div>
|
||||
|
||||
<template v-else-if="summary">
|
||||
<div class="kpis-grid">
|
||||
<UiCard padding="sm" class="kpi-card">
|
||||
<span class="kpi-label">صندوق (وصولها)</span>
|
||||
<strong class="kpi-value">{{ formatMoney(summary.cashTotal, summary.currency) }}</strong>
|
||||
</UiCard>
|
||||
<UiCard padding="sm" class="kpi-card">
|
||||
<span class="kpi-label">مانده طرفحسابها</span>
|
||||
<strong class="kpi-value">{{ formatMoney(summary.receivablesTotal, summary.currency) }}</strong>
|
||||
</UiCard>
|
||||
<UiCard padding="sm" class="kpi-card">
|
||||
<span class="kpi-label">نام انبار</span>
|
||||
<strong class="kpi-value">{{ summary.warehouseName }}</strong>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiCard v-if="canWrite" padding="sm" class="move-form-card">
|
||||
<form class="move-form" @submit.prevent="submitMove">
|
||||
<UiSelect v-model="moveType">
|
||||
<option value="purchase">ورود خرید</option>
|
||||
<option value="adjustment">اصلاح موجودی (عدد جدید)</option>
|
||||
</UiSelect>
|
||||
<UiSelect v-model="productId">
|
||||
<option value="" disabled>انتخاب محصول</option>
|
||||
<option
|
||||
v-for="p in summary.products"
|
||||
:key="p.productId"
|
||||
:value="p.productId"
|
||||
>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
<UiInput
|
||||
v-model="quantity"
|
||||
type="number"
|
||||
:placeholder="moveType === 'adjustment' ? 'موجودی جدید' : 'تعداد'"
|
||||
/>
|
||||
<UiInput v-model="note" placeholder="توضیح اختیاری…" />
|
||||
<UiButton variant="primary" type="submit" :disabled="saving || !productId" :loading="saving">
|
||||
ثبت گردش
|
||||
</UiButton>
|
||||
</form>
|
||||
</UiCard>
|
||||
|
||||
<div class="inventory-grid">
|
||||
<UiCard>
|
||||
<template #header>
|
||||
<div class="section-title">
|
||||
<UiIcon name="box" :size="18" />
|
||||
<h3>موجودی کالا</h3>
|
||||
<TableColumnsMenu
|
||||
v-if="summary.products.length"
|
||||
table-key="inventory.stock"
|
||||
align="end"
|
||||
style="margin-inline-start: auto"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!summary.products.length" class="muted empty-note">محصولی در انبار تعریف نشده است.</p>
|
||||
<table v-else v-table-cols="'inventory.stock'" class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام کالا</th>
|
||||
<th>واحد</th>
|
||||
<th>موجودی</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in summary.products" :key="p.productId">
|
||||
<td>
|
||||
<strong>{{ p.name }}</strong>
|
||||
<small v-if="p.sku" class="sku-hint">{{ p.sku }}</small>
|
||||
</td>
|
||||
<td>{{ p.unit || 'عدد' }}</td>
|
||||
<td>
|
||||
<UiBadge :variant="p.stockQty > 0 ? 'success' : 'danger'" size="sm">
|
||||
{{ toFaDigits(String(p.stockQty)) }}
|
||||
</UiBadge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</UiCard>
|
||||
|
||||
<UiCard>
|
||||
<template #header>
|
||||
<div class="section-title">
|
||||
<UiIcon name="clock" :size="18" />
|
||||
<h3>گردش اخیر کالا</h3>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!movements.length" class="muted empty-note">هیچ گردشی ثبت نشده است.</p>
|
||||
<ul v-else class="moves-list">
|
||||
<li v-for="m in movements" :key="m.id">
|
||||
<div class="move-info">
|
||||
<strong>{{ m.productName }}</strong>
|
||||
<span class="move-type-tag">{{ typeLabel[m.type] }}</span>
|
||||
<small v-if="m.note" class="move-note">{{ m.note }}</small>
|
||||
</div>
|
||||
<div class="move-end">
|
||||
<UiBadge :variant="m.quantity > 0 ? 'success' : 'danger'" size="sm">
|
||||
{{ m.quantity > 0 ? '+' : '' }}{{ toFaDigits(String(m.quantity)) }}
|
||||
</UiBadge>
|
||||
<time class="move-time">{{ formatJalaliNumeric(new Date(m.occurredAt)) }}</time>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</CrmSkeleton>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notice-box {
|
||||
background: var(--color-info-surface);
|
||||
border: 1px solid var(--color-info);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
color: var(--color-info);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.kpis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
display: block;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: var(--fs-lg);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
|
||||
.move-form-card {
|
||||
margin: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.move-form {
|
||||
display: grid;
|
||||
grid-template-columns: 10rem 1fr 8rem 1fr auto;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inventory-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.section-title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sku-hint {
|
||||
display: block;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
font-size: var(--fs-sm);
|
||||
padding: var(--sp-4) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.moves-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.moves-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.move-info {
|
||||
display: grid;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.move-info strong {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--color-text-emphasis);
|
||||
}
|
||||
|
||||
.move-type-tag {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.move-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.move-end {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.move-time {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kpis-grid,
|
||||
.inventory-grid,
|
||||
.move-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
Segmented as ASegmented,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
@@ -42,7 +41,8 @@ import {
|
||||
FileTextOutlined,
|
||||
PlusOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import MoneyInput from '@/components/MoneyInput.vue';
|
||||
import InvoiceDocPreview from '@/components/InvoiceDocPreview.vue';
|
||||
@@ -1033,8 +1033,9 @@ onMounted(load);
|
||||
</a-button>
|
||||
</a-flex>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-form v-if="!loading" layout="vertical" @finish="save">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-form layout="vertical" @finish="save">
|
||||
<a-alert
|
||||
v-if="error"
|
||||
type="error"
|
||||
@@ -1547,7 +1548,8 @@ onMounted(load);
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<InvoiceDocPreview
|
||||
v-model:open="previewOpen"
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
@@ -46,7 +45,8 @@ import {
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
import type {
|
||||
InvoiceDto,
|
||||
PaginatedResponse,
|
||||
@@ -641,87 +641,89 @@ defineExpose({ toggleSelect, pageCount, statusIcon });
|
||||
:style="{ marginTop: '16px' }"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-table
|
||||
v-if="!error"
|
||||
:columns="columns"
|
||||
:data-source="pageItems"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:row-selection="{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: onTableSelectChange,
|
||||
onSelectAll: toggleSelectAll,
|
||||
}"
|
||||
:style="{ marginTop: '16px' }"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'مشتری'">
|
||||
<a-space :size="8">
|
||||
<a-avatar>{{ record.customerName.slice(0, 1) }}</a-avatar>
|
||||
<div>
|
||||
<div>{{ record.customerName }}</div>
|
||||
<a-typography-text v-if="record.personName" type="secondary">
|
||||
{{ record.personName }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-space>
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="9" :card="false">
|
||||
<a-table
|
||||
v-if="!error"
|
||||
:columns="columns"
|
||||
:data-source="pageItems"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:row-selection="{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: onTableSelectChange,
|
||||
onSelectAll: toggleSelectAll,
|
||||
}"
|
||||
:style="{ marginTop: '16px' }"
|
||||
:scroll="{ x: 'max-content' }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'مشتری'">
|
||||
<a-space :size="8">
|
||||
<a-avatar>{{ record.customerName.slice(0, 1) }}</a-avatar>
|
||||
<div>
|
||||
<div>{{ record.customerName }}</div>
|
||||
<a-typography-text v-if="record.personName" type="secondary">
|
||||
{{ record.personName }}
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag :color="statusColor(record.statusKey)" :bordered="false">
|
||||
{{ record.statusLabel }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مبلغ کل'">
|
||||
<span dir="ltr">{{ money(record.amount, record.currency) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-space :size="4" @click.stop>
|
||||
<RouterLink :to="record.href">
|
||||
<a-button type="text">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
<a-dropdown
|
||||
:open="menuId === record.id"
|
||||
@update:open="(v) => (menuId = v ? record.id : null)"
|
||||
>
|
||||
<a-button type="text">
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(info) => onRowMenu(info, record as BillingRow)">
|
||||
<a-menu-item key="open">باز کردن</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'وضعیت'">
|
||||
<a-tag :color="statusColor(record.statusKey)" :bordered="false">
|
||||
{{ record.statusLabel }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'مبلغ کل'">
|
||||
<span dir="ltr">{{ money(record.amount, record.currency) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'عملیات'">
|
||||
<a-space :size="4" @click.stop>
|
||||
<RouterLink :to="record.href">
|
||||
<a-button type="text">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
</a-button>
|
||||
</RouterLink>
|
||||
<a-dropdown
|
||||
:open="menuId === record.id"
|
||||
@update:open="(v) => (menuId = v ? record.id : null)"
|
||||
>
|
||||
<a-button type="text">
|
||||
<template #icon><MoreOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(info) => onRowMenu(info, record as BillingRow)">
|
||||
<a-menu-item key="open">باز کردن</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-table>
|
||||
|
||||
<a-empty
|
||||
v-if="!loading && !pageItems.length && !error"
|
||||
description="سندی یافت نشد."
|
||||
:style="{ marginTop: '24px' }"
|
||||
/>
|
||||
|
||||
<a-flex
|
||||
v-if="filtered.length"
|
||||
justify="flex-end"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<a-pagination
|
||||
v-model:current="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="filtered.length"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
|
||||
:show-total="(total) => `${toFaDigits(total)} مورد · ${toFaDigits(pageCount)} صفحه`"
|
||||
<a-empty
|
||||
v-if="!loading && !pageItems.length && !error"
|
||||
description="سندی یافت نشد."
|
||||
:style="{ marginTop: '24px' }"
|
||||
/>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
|
||||
<a-flex
|
||||
v-if="filtered.length"
|
||||
justify="flex-end"
|
||||
:style="{ marginTop: '16px' }"
|
||||
>
|
||||
<a-pagination
|
||||
v-model:current="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="filtered.length"
|
||||
:show-size-changer="true"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS.map(String)"
|
||||
:show-total="(total) => `${toFaDigits(total)} مورد · ${toFaDigits(pageCount)} صفحه`"
|
||||
/>
|
||||
</a-flex>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
|
||||
@@ -28,13 +28,14 @@ import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { pointerDropIndex } from '@/lib/pipeline-board';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
|
||||
import { CrmSkeletonCard } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
@@ -575,7 +576,7 @@ onMounted(load);
|
||||
|
||||
<div :style="boardShellStyle">
|
||||
<div
|
||||
v-if="loading || moving"
|
||||
v-if="moving"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -589,8 +590,22 @@ onMounted(load);
|
||||
<a-spin />
|
||||
</div>
|
||||
|
||||
<a-flex
|
||||
v-if="loading && !columns.length"
|
||||
:gap="GAP"
|
||||
:style="{ overflowX: 'auto', paddingBottom: `${GAP}px`, margin: 'auto 0' }"
|
||||
>
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
:style="{ width: `${COL_WIDTH}px`, flex: `0 0 ${COL_WIDTH}px` }"
|
||||
>
|
||||
<CrmSkeletonCard :loading="true" :rows="5" :min-height="360" />
|
||||
</div>
|
||||
</a-flex>
|
||||
|
||||
<a-empty
|
||||
v-if="!loading && !columns.length"
|
||||
v-else-if="!loading && !columns.length"
|
||||
description="مرحلهای برای نمایش در مسیر لیدها نیست."
|
||||
:style="{ margin: 'auto' }"
|
||||
>
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
Timeline as ATimeline,
|
||||
@@ -44,7 +43,8 @@ import {
|
||||
PhoneOutlined,
|
||||
PlusOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import { api } from '@/lib/api';
|
||||
import { isBirthDateAnswer, leadNoteFileIsImage, parseLeadFormNotes } from '@/lib/lead-form-notes';
|
||||
import {
|
||||
@@ -568,7 +568,8 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<template v-if="lead">
|
||||
<a-row :gutter="[24, 24]">
|
||||
<a-col :xs="24" :lg="17" :xl="18">
|
||||
@@ -1180,7 +1181,8 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
|
||||
@close="smsOpen = false"
|
||||
/>
|
||||
</template>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
SelectOption as ASelectOption,
|
||||
SelectOptGroup as ASelectOptGroup,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Statistic as AStatistic,
|
||||
Switch as ASwitch,
|
||||
Tabs as ATabs,
|
||||
@@ -58,6 +57,7 @@ import {
|
||||
Upload as AUpload,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import type { UploadProps } from 'ant-design-vue';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
StarOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
@@ -690,7 +690,8 @@ onMounted(load);
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="10">
|
||||
<a-tabs :active-key="tab" @change="onTabChange">
|
||||
<a-tab-pane key="edit" tab="ویرایش فرم">
|
||||
<a-row :gutter="[16, 16]">
|
||||
@@ -1198,7 +1199,8 @@ onMounted(load);
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-modal
|
||||
v-model:open="desktopPreviewOpen"
|
||||
|
||||
@@ -15,17 +15,17 @@ import {
|
||||
ListItem as AListItem,
|
||||
ListItemMeta as AListItemMeta,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -121,7 +121,8 @@ onMounted(load);
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="6">
|
||||
<a-card v-if="listEmpty && !error" :bordered="false">
|
||||
<a-empty
|
||||
description="هنوز فرمی نساختهاید. با ساخت اولین فرم، میتوانید در وبسایت یا شبکههای اجتماعی سرنخ و مشتری جذب کنید."
|
||||
@@ -184,6 +185,7 @@ onMounted(load);
|
||||
</a-list>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
CreateLeadStageRequest,
|
||||
LeadStageDto,
|
||||
UpdateLeadStageRequest,
|
||||
} from '@mento/shared';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
Modal,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -39,6 +59,16 @@ const editConverted = ref(false);
|
||||
const editLost = ref(false);
|
||||
const editUnqualified = ref(false);
|
||||
|
||||
const listEmpty = computed(() => !loading.value && !stages.value.length);
|
||||
|
||||
const columns: TableColumnsType<LeadStageDto> = [
|
||||
{ title: 'رنگ', key: 'color', width: 64 },
|
||||
{ title: 'نام', dataIndex: 'name', key: 'name' },
|
||||
{ title: 'وضعیت', key: 'flags' },
|
||||
{ title: 'لیدها', key: 'leadCount', width: 100 },
|
||||
{ title: 'عملیات', key: 'actions', width: 220 },
|
||||
];
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -110,7 +140,10 @@ function cancelEdit() {
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId.value || !editName.value.trim()) return;
|
||||
if (!editingId.value || !editName.value.trim()) {
|
||||
error.value = 'نام مرحله را وارد کنید.';
|
||||
return Promise.reject(new Error('validation'));
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
@@ -128,21 +161,29 @@ async function saveEdit() {
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ویرایش مرحله ناموفق بود.');
|
||||
return Promise.reject(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStage(stage: LeadStageDto) {
|
||||
if (!confirm(`مرحله «${stage.name}» حذف شود؟`)) return;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/leads/stages/${stage.id}`);
|
||||
okMsg.value = 'مرحله حذف شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف مرحله ناموفق بود.');
|
||||
}
|
||||
function removeStage(stage: LeadStageDto) {
|
||||
Modal.confirm({
|
||||
title: `مرحله «${stage.name}» حذف شود؟`,
|
||||
okText: 'حذف',
|
||||
cancelText: 'انصراف',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
error.value = '';
|
||||
try {
|
||||
await api.delete(`/leads/stages/${stage.id}`);
|
||||
okMsg.value = 'مرحله حذف شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف مرحله ناموفق بود.');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function move(stage: LeadStageDto, dir: -1 | 1) {
|
||||
@@ -167,222 +208,166 @@ onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page">
|
||||
<UiPageHeader
|
||||
title="مراحل لید"
|
||||
lead="تعریف وضعیتهای مسیر لیدها و برچسبهای تبدیل/از دسترفته"
|
||||
/>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<form class="crm-form" @submit.prevent="createStage">
|
||||
<UiCard>
|
||||
<template #header>
|
||||
<div class="card-title">
|
||||
<UiIcon name="plus" :size="18" />
|
||||
<h3>مرحله جدید</h3>
|
||||
</div>
|
||||
</template>
|
||||
<div class="form-grid">
|
||||
<UiField label="نام" required for-id="ls-name">
|
||||
<UiInput id="ls-name" v-model="name" required placeholder="مثلاً تماس اولیه" />
|
||||
</UiField>
|
||||
<UiField label="رنگ" for-id="ls-color">
|
||||
<input id="ls-color" v-model="color" class="color" type="color" />
|
||||
</UiField>
|
||||
<label class="check">
|
||||
<input v-model="showOnBoard" type="checkbox" />
|
||||
نمایش روی مسیر
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="isConverted" type="checkbox" />
|
||||
مرحله تبدیلشده
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="isLost" type="checkbox" />
|
||||
از دسترفته
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="isUnqualified" type="checkbox" />
|
||||
غیرواجد شرایط
|
||||
</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton type="submit" :loading="saving">افزودن مرحله</UiButton>
|
||||
</template>
|
||||
</UiCard>
|
||||
</form>
|
||||
|
||||
<div v-if="stages.length" class="list">
|
||||
<UiCard v-for="stage in stages" :key="stage.id" padding="sm" class="stage-card">
|
||||
<template v-if="editingId === stage.id">
|
||||
<div class="form-grid">
|
||||
<UiField label="نام">
|
||||
<UiInput v-model="editName" />
|
||||
</UiField>
|
||||
<UiField label="رنگ">
|
||||
<input v-model="editColor" class="color" type="color" />
|
||||
</UiField>
|
||||
<label class="check">
|
||||
<input v-model="editShowOnBoard" type="checkbox" />
|
||||
نمایش روی مسیر
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="editConverted" type="checkbox" />
|
||||
تبدیلشده
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="editLost" type="checkbox" />
|
||||
از دسترفته
|
||||
</label>
|
||||
<label class="check">
|
||||
<input v-model="editUnqualified" type="checkbox" />
|
||||
غیرواجد شرایط
|
||||
</label>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<UiButton size="sm" :disabled="saving" @click="saveEdit">ذخیره</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="cancelEdit">انصراف</UiButton>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="row">
|
||||
<span class="dot" :style="{ background: stage.color || 'var(--accent)' }" />
|
||||
<div class="info">
|
||||
<strong>{{ stage.name }}</strong>
|
||||
<span class="muted">
|
||||
{{ stage.showOnBoard ? 'روی مسیر' : 'مخفی از مسیر' }}
|
||||
<template v-if="stage.leadCount != null">
|
||||
· {{ toFaDigits(stage.leadCount) }} لید
|
||||
</template>
|
||||
</span>
|
||||
<span class="flags">
|
||||
<em v-if="stage.isConverted">تبدیل</em>
|
||||
<em v-if="stage.isLost">از دسترفته</em>
|
||||
<em v-if="stage.isUnqualified">غیرواجد</em>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<UiButton size="sm" variant="ghost" @click="move(stage, -1)">↑</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="move(stage, 1)">↓</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="startEdit(stage)">ویرایش</UiButton>
|
||||
<UiButton size="sm" variant="ghost" @click="removeStage(stage)">حذف</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UiCard>
|
||||
<a-flex vertical :gap="16">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">مراحل لید</a-typography-title>
|
||||
<a-typography-text type="secondary">
|
||||
تعریف وضعیتهای مسیر لیدها و برچسبهای تبدیل/از دسترفته
|
||||
</a-typography-text>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading"
|
||||
title="مرحلهای تعریف نشده."
|
||||
description="اولین وضعیت لید را اضافه کنید."
|
||||
/>
|
||||
</section>
|
||||
<a-alert v-if="okMsg" type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<a-card title="مرحله جدید" :bordered="false">
|
||||
<a-form layout="vertical" @finish="createStage">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12" :lg="8">
|
||||
<a-form-item label="نام" name="name" required>
|
||||
<a-input v-model:value="name" placeholder="مثلاً تماس اولیه" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12" :lg="4">
|
||||
<a-form-item label="رنگ" name="color">
|
||||
<a-input v-model:value="color" type="color" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="[16, 8]">
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-checkbox v-model:checked="showOnBoard">نمایش روی مسیر</a-checkbox>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-checkbox v-model:checked="isConverted">مرحله تبدیلشده</a-checkbox>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-checkbox v-model:checked="isLost">از دسترفته</a-checkbox>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="6">
|
||||
<a-checkbox v-model:checked="isUnqualified">غیرواجد شرایط</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item :style="{ marginTop: '16px', marginBottom: 0 }">
|
||||
<a-button type="primary" html-type="submit" :loading="saving">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
افزودن مرحله
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="فهرست مراحل" :bordered="false">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="6" :columns="5" :card="false">
|
||||
<a-empty
|
||||
v-if="listEmpty"
|
||||
description="مرحلهای تعریف نشده. اولین وضعیت لید را اضافه کنید."
|
||||
/>
|
||||
<a-table
|
||||
v-else-if="stages.length"
|
||||
:columns="columns"
|
||||
:data-source="stages"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'color'">
|
||||
<span
|
||||
:style="{
|
||||
display: 'inline-block',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
background: record.color || 'var(--ant-color-primary)',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'flags'">
|
||||
<a-space wrap>
|
||||
<a-tag :bordered="false" :color="record.showOnBoard ? 'processing' : 'default'">
|
||||
{{ record.showOnBoard ? 'روی مسیر' : 'مخفی از مسیر' }}
|
||||
</a-tag>
|
||||
<a-tag v-if="record.isConverted" color="success" :bordered="false">تبدیل</a-tag>
|
||||
<a-tag v-if="record.isLost" color="error" :bordered="false">از دسترفته</a-tag>
|
||||
<a-tag v-if="record.isUnqualified" color="warning" :bordered="false">غیرواجد</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'leadCount'">
|
||||
<a-typography-text v-if="record.leadCount != null" type="secondary">
|
||||
{{ toFaDigits(record.leadCount) }} لید
|
||||
</a-typography-text>
|
||||
<a-typography-text v-else type="secondary">—</a-typography-text>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space wrap>
|
||||
<a-button
|
||||
type="text"
|
||||
:disabled="index === 0"
|
||||
@click="move(record as LeadStageDto, -1)"
|
||||
>
|
||||
<template #icon><ArrowUpOutlined /></template>
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
:disabled="index === stages.length - 1"
|
||||
@click="move(record as LeadStageDto, 1)"
|
||||
>
|
||||
<template #icon><ArrowDownOutlined /></template>
|
||||
</a-button>
|
||||
<a-button type="text" @click="startEdit(record as LeadStageDto)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
ویرایش
|
||||
</a-button>
|
||||
<a-button type="text" danger @click="removeStage(record as LeadStageDto)">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
حذف
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
:open="editingId != null"
|
||||
title="ویرایش مرحله"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
:confirm-loading="saving"
|
||||
destroy-on-close
|
||||
@ok="saveEdit"
|
||||
@cancel="cancelEdit"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="16">
|
||||
<a-form-item label="نام" required>
|
||||
<a-input v-model:value="editName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form-item label="رنگ">
|
||||
<a-input v-model:value="editColor" type="color" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="[16, 8]">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-checkbox v-model:checked="editShowOnBoard">نمایش روی مسیر</a-checkbox>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-checkbox v-model:checked="editConverted">تبدیلشده</a-checkbox>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-checkbox v-model:checked="editLost">از دسترفته</a-checkbox>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-checkbox v-model:checked="editUnqualified">غیرواجد شرایط</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.crm-page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.card-title h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.color {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: var(--control-h);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1 1 10rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.flags em {
|
||||
font-style: normal;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
+194
-160
@@ -9,6 +9,7 @@ import {
|
||||
Card as ACard,
|
||||
Checkbox as ACheckbox,
|
||||
Col as ACol,
|
||||
Divider as ADivider,
|
||||
Dropdown as ADropdown,
|
||||
Empty as AEmpty,
|
||||
Flex as AFlex,
|
||||
@@ -48,8 +49,7 @@ import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
formatJalaliNumeric,
|
||||
@@ -63,6 +63,7 @@ import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
|
||||
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
|
||||
import { CrmSkeleton, CrmSkeletonTable } from '@/components/skeleton';
|
||||
import {
|
||||
filterPhoneContacts,
|
||||
isContactPickerSupported,
|
||||
@@ -88,6 +89,7 @@ const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyPa
|
||||
const { token } = theme.useToken();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = computed(() => !screens.value.md);
|
||||
const emptySimple = AEmpty.PRESENTED_IMAGE_SIMPLE;
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -1136,6 +1138,20 @@ const tintColor = (tint: StageTint) => {
|
||||
return map[tint] ?? 'default';
|
||||
};
|
||||
|
||||
function tintAccent(tint: StageTint) {
|
||||
const t = token.value;
|
||||
const map: Record<StageTint, string> = {
|
||||
mint: t.colorSuccess,
|
||||
teal: t.colorInfo,
|
||||
amber: t.colorWarning,
|
||||
peach: t.colorWarning,
|
||||
sky: t.colorInfo,
|
||||
violet: t.colorPrimary,
|
||||
rose: t.colorError,
|
||||
};
|
||||
return map[tint] ?? t.colorPrimary;
|
||||
}
|
||||
|
||||
const tableColumns = computed<TableColumnsType<LeadDto>>(() => {
|
||||
const all: TableColumnsType<LeadDto> = [
|
||||
{
|
||||
@@ -1181,13 +1197,15 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-row :gutter="[24, 24]" @click="closePopups">
|
||||
<a-col :span="24">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="token.marginSM">
|
||||
<div>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
|
||||
</div>
|
||||
<a-space :size="8" wrap>
|
||||
<!-- wrapper absorbs AppLayout flex:1 so a-row/a-flex children are not stretch-spaced -->
|
||||
<div :style="{ width: '100%' }" @click="closePopups">
|
||||
<a-flex vertical :gap="token.marginSM" :style="{ width: '100%' }">
|
||||
<a-row :gutter="[12, 12]" align="middle">
|
||||
<a-col :xs="24" :md="6" :lg="5">
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">لیدها</a-typography-title>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="18" :lg="19">
|
||||
<a-flex justify="end" wrap="wrap" :gap="8">
|
||||
<a-button :disabled="busy" :loading="pageBusy && busyMessage.includes('خروجی')" @click.stop="exportExcel">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
خروجی اکسل
|
||||
@@ -1225,111 +1243,76 @@ onBeforeUnmount(() => {
|
||||
<template #icon><PlusOutlined /></template>
|
||||
لید جدید
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-card
|
||||
:bordered="true"
|
||||
hoverable
|
||||
:style="{
|
||||
borderColor: !stageId ? token.colorPrimary : undefined,
|
||||
cursor: 'pointer',
|
||||
}"
|
||||
@click="setStageFilter('')"
|
||||
>
|
||||
<a-flex align="center" :gap="12">
|
||||
<a-avatar :style="{ background: token.colorPrimaryBg, color: token.colorPrimary }">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
<div>
|
||||
<a-typography-text type="secondary">همه لیدها</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text strong>{{ formatCount(totalLeadsCount) }}</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col
|
||||
v-for="card in stageCards"
|
||||
:key="card.key"
|
||||
:xs="12"
|
||||
:sm="8"
|
||||
:md="4"
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col :xs="12" :sm="8" :md="4">
|
||||
<a-card
|
||||
hoverable
|
||||
:body-style="{ padding: `${token.paddingSM}px` }"
|
||||
:style="{
|
||||
height: '100%',
|
||||
borderColor: !stageId ? token.colorPrimary : undefined,
|
||||
cursor: 'pointer',
|
||||
}"
|
||||
@click="setStageFilter('')"
|
||||
>
|
||||
<a-card
|
||||
:bordered="true"
|
||||
hoverable
|
||||
:style="{
|
||||
borderColor: isStatActive(card) ? token.colorPrimary : undefined,
|
||||
cursor: card.stageId ? 'pointer' : 'not-allowed',
|
||||
opacity: card.stageId ? 1 : 0.55,
|
||||
}"
|
||||
@click="card.stageId && selectStatCard(card)"
|
||||
>
|
||||
<a-typography-text type="secondary">{{ card.label }}</a-typography-text>
|
||||
<div>
|
||||
<a-typography-text strong>{{ formatCount(card.count) }}</a-typography-text>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-col>
|
||||
|
||||
<a-col :span="24" @click.stop>
|
||||
<a-card :bordered="true">
|
||||
<a-flex justify="space-between" align="center" wrap="wrap" :gap="12">
|
||||
<a-flex align="center" wrap="wrap" :gap="8" flex="1 1 auto">
|
||||
<a-space :size="8">
|
||||
<FilterOutlined />
|
||||
<a-typography-text strong>فیلترها</a-typography-text>
|
||||
</a-space>
|
||||
<a-select
|
||||
:value="sort"
|
||||
style="width: 11rem"
|
||||
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
||||
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
||||
/>
|
||||
<a-input-search
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو..."
|
||||
enter-button="جستجو"
|
||||
style="width: 16rem; max-width: 100%"
|
||||
@search="onSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input-search>
|
||||
<a-select
|
||||
:value="cityFilter || undefined"
|
||||
allow-clear
|
||||
placeholder="همه مناطق"
|
||||
style="width: 9.5rem"
|
||||
:options="cityOptions.map((c) => ({ value: c, label: c }))"
|
||||
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
<a-select
|
||||
:value="sourceFilter || undefined"
|
||||
allow-clear
|
||||
placeholder="همه منابع"
|
||||
style="width: 9.5rem"
|
||||
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
|
||||
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
<a-select
|
||||
:value="stageId || undefined"
|
||||
allow-clear
|
||||
placeholder="همه وضعیت"
|
||||
style="width: 9.5rem"
|
||||
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
|
||||
@update:value="(v) => setStageFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text type="secondary">همه لیدها</a-typography-text>
|
||||
<a-typography-text strong :style="{ color: token.colorPrimary, fontSize: `${token.fontSizeHeading3}px` }">
|
||||
{{ formatCount(totalLeadsCount) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col
|
||||
v-for="card in stageCards"
|
||||
:key="card.key"
|
||||
:xs="12"
|
||||
:sm="8"
|
||||
:md="4"
|
||||
>
|
||||
<a-card
|
||||
hoverable
|
||||
:body-style="{ padding: `${token.paddingSM}px` }"
|
||||
:style="{
|
||||
height: '100%',
|
||||
borderColor: isStatActive(card) ? token.colorPrimary : undefined,
|
||||
cursor: card.stageId ? 'pointer' : 'not-allowed',
|
||||
opacity: card.stageId ? 1 : 0.55,
|
||||
}"
|
||||
@click="card.stageId && selectStatCard(card)"
|
||||
>
|
||||
<a-flex vertical :gap="4">
|
||||
<a-typography-text type="secondary">{{ card.label }}</a-typography-text>
|
||||
<a-typography-text strong :style="{ color: tintAccent(card.tint), fontSize: `${token.fontSizeHeading3}px` }">
|
||||
{{ formatCount(card.count) }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-alert
|
||||
v-if="importJob && importJob.status !== 'done' && importJob.status !== 'completed' && importJob.status !== 'failed'"
|
||||
type="info"
|
||||
show-icon
|
||||
:message="`ورود فایل: ${toFaDigits(importJob.processedCount)} / ${toFaDigits(importJob.totalCount)}`"
|
||||
/>
|
||||
<a-alert v-if="okMsg" type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
|
||||
<a-card @click.stop>
|
||||
<template #title>
|
||||
<a-space :size="8">
|
||||
<FilterOutlined />
|
||||
<span>فیلترها</span>
|
||||
</a-space>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space :size="8" wrap>
|
||||
<TableColumnsMenu table-key="leads" align="end" />
|
||||
<a-radio-group
|
||||
value="list"
|
||||
@@ -1344,65 +1327,111 @@ onBeforeUnmount(() => {
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</template>
|
||||
|
||||
<a-col v-if="selectedIds.length" :span="24" @click.stop>
|
||||
<a-card :bordered="false" :style="{ background: token.colorPrimaryBg }">
|
||||
<a-space :size="8" wrap>
|
||||
<a-typography-text>
|
||||
{{ toFaDigits(selectedIds.length) }} مورد انتخابشده
|
||||
</a-typography-text>
|
||||
<template v-if="canAssignOthers">
|
||||
<a-row :gutter="[12, 12]">
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="5">
|
||||
<a-select
|
||||
v-model:value="assignUserId"
|
||||
placeholder="تخصیص به کارشناس"
|
||||
style="min-width: 12rem"
|
||||
allow-clear
|
||||
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
|
||||
:value="sort"
|
||||
style="width: 100%"
|
||||
:options="Object.entries(sortLabels).map(([value, label]) => ({ value, label }))"
|
||||
@update:value="(v) => setSort(String(v ?? 'updated') as SortKey)"
|
||||
/>
|
||||
<a-button :disabled="!assignUserId || busy" @click="assignSelected">اعمال تخصیص</a-button>
|
||||
</template>
|
||||
<template v-if="isAdmin">
|
||||
<a-select
|
||||
v-model:value="bulkStageId"
|
||||
placeholder="تغییر وضعیت"
|
||||
style="min-width: 12rem"
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12" :md="8" :lg="7">
|
||||
<a-input-search
|
||||
v-model:value="q"
|
||||
allow-clear
|
||||
placeholder="جستجو در لیدها…"
|
||||
enter-button="جستجو"
|
||||
style="width: 100%"
|
||||
@search="onSearch"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="8" :lg="4">
|
||||
<a-select
|
||||
:value="cityFilter || undefined"
|
||||
allow-clear
|
||||
placeholder="همه مناطق"
|
||||
style="width: 100%"
|
||||
:options="cityOptions.map((c) => ({ value: c, label: c }))"
|
||||
@update:value="(v) => setCityFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="8" :lg="4">
|
||||
<a-select
|
||||
:value="sourceFilter || undefined"
|
||||
allow-clear
|
||||
placeholder="همه منابع"
|
||||
style="width: 100%"
|
||||
:options="sourceOptions.map((s) => ({ value: s, label: s }))"
|
||||
@update:value="(v) => setSourceFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="8" :md="8" :lg="4">
|
||||
<a-select
|
||||
:value="stageId || undefined"
|
||||
allow-clear
|
||||
placeholder="همه وضعیت"
|
||||
style="width: 100%"
|
||||
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
|
||||
@update:value="(v) => setStageFilter(v == null ? '' : String(v))"
|
||||
/>
|
||||
<a-button :disabled="!bulkStageId || busy" @click="bulkStageSelected">اعمال وضعیت</a-button>
|
||||
</template>
|
||||
<a-button danger :disabled="busy" @click="archiveSelected">بایگانی</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-col
|
||||
v-if="importJob && importJob.status !== 'done' && importJob.status !== 'completed' && importJob.status !== 'failed'"
|
||||
:span="24"
|
||||
>
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
:message="`ورود فایل: ${toFaDigits(importJob.processedCount)} / ${toFaDigits(importJob.totalCount)}`"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col v-if="okMsg" :span="24">
|
||||
<a-alert type="success" show-icon :message="okMsg" closable @close="okMsg = ''" />
|
||||
</a-col>
|
||||
<a-col v-if="error" :span="24">
|
||||
<a-alert type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
</a-col>
|
||||
<a-card
|
||||
v-if="selectedIds.length"
|
||||
:bordered="false"
|
||||
:style="{
|
||||
background: token.colorPrimaryBg,
|
||||
marginTop: `${token.marginMD}px`,
|
||||
marginBottom: `${token.marginSM}px`,
|
||||
}"
|
||||
>
|
||||
<a-row :gutter="[12, 12]" align="middle">
|
||||
<a-col :xs="24" :md="6">
|
||||
<a-typography-text>
|
||||
{{ toFaDigits(selectedIds.length) }} مورد انتخابشده
|
||||
</a-typography-text>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="18">
|
||||
<a-flex wrap="wrap" :gap="8" justify="end">
|
||||
<template v-if="canAssignOthers">
|
||||
<a-select
|
||||
v-model:value="assignUserId"
|
||||
placeholder="تخصیص به کارشناس"
|
||||
style="min-width: 12rem; flex: 1 1 12rem"
|
||||
allow-clear
|
||||
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
|
||||
/>
|
||||
<a-button :disabled="!assignUserId || busy" @click="assignSelected">اعمال تخصیص</a-button>
|
||||
</template>
|
||||
<template v-if="isAdmin">
|
||||
<a-select
|
||||
v-model:value="bulkStageId"
|
||||
placeholder="تغییر وضعیت"
|
||||
style="min-width: 12rem; flex: 1 1 12rem"
|
||||
allow-clear
|
||||
:options="stages.map((s) => ({ value: s.id, label: s.name }))"
|
||||
/>
|
||||
<a-button :disabled="!bulkStageId || busy" @click="bulkStageSelected">اعمال وضعیت</a-button>
|
||||
</template>
|
||||
<a-button danger :disabled="busy" @click="archiveSelected">بایگانی</a-button>
|
||||
</a-flex>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<a-col :span="24">
|
||||
<a-spin :spinning="loading || pageBusy" :tip="pageBusy ? busyMessage : undefined">
|
||||
<a-divider :style="{ marginTop: `${token.marginSM}px`, marginBottom: `${token.marginSM}px` }" />
|
||||
|
||||
<a-spin :spinning="pageBusy" :tip="busyMessage">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="8" :columns="9" :card="false">
|
||||
<template v-if="data?.items.length">
|
||||
<a-row v-if="isMobile" :gutter="[12, 12]">
|
||||
<a-col v-for="(lead, idx) in data.items" :key="`m-${lead.id}`" :span="24">
|
||||
<a-card
|
||||
:bordered="true"
|
||||
hoverable
|
||||
:style="{
|
||||
borderColor: selected.has(lead.id) ? token.colorPrimary : undefined,
|
||||
@@ -1509,7 +1538,7 @@ onBeforeUnmount(() => {
|
||||
:data-source="data.items"
|
||||
:pagination="false"
|
||||
:row-key="(r: LeadDto) => r.id"
|
||||
:scroll="{ x: 1100 }"
|
||||
:scroll="screens.xl ? undefined : { x: 960 }"
|
||||
:custom-row="(record: LeadDto) => ({ onClick: () => goDetail(record.id) })"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
@@ -1613,7 +1642,9 @@ onBeforeUnmount(() => {
|
||||
|
||||
<a-empty
|
||||
v-else-if="!loading"
|
||||
:image="emptySimple"
|
||||
description="لیدی ثبت نشده است. از ورود فایل استفاده کنید یا لید جدید بسازید."
|
||||
:style="{ paddingBlock: `${token.paddingXS}px` }"
|
||||
>
|
||||
<a-space :size="8">
|
||||
<a-button type="primary" @click="openCreate">ثبت لید جدید</a-button>
|
||||
@@ -1633,8 +1664,10 @@ onBeforeUnmount(() => {
|
||||
</ExcelImportFlow>
|
||||
</a-space>
|
||||
</a-empty>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
</a-spin>
|
||||
</a-col>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="createOpen"
|
||||
@@ -1848,5 +1881,6 @@ onBeforeUnmount(() => {
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</a-row>
|
||||
</a-flex>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
LockOutlined,
|
||||
PhoneOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Link: ATypographyLink } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Switch as ASwitch,
|
||||
Table as ATable,
|
||||
TabPane as ATabPane,
|
||||
@@ -74,7 +73,8 @@ import {
|
||||
StarOutlined,
|
||||
TrophyOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard, CrmSkeletonKpi } from '@/components/skeleton';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
@@ -746,33 +746,36 @@ onBeforeUnmount(() => {
|
||||
message="باشگاه فعلاً غیرفعال است؛ تنظیمات را بررسی کنید."
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col v-for="k in kpis" :key="k.key" :xs="12" :sm="12" :md="6">
|
||||
<a-card :bordered="false" :style="{ height: '100%' }">
|
||||
<a-flex :gap="12" align="flex-start">
|
||||
<component
|
||||
:is="k.icon"
|
||||
:style="{ color: 'var(--ant-color-primary)', fontSize: '20px' }"
|
||||
/>
|
||||
<a-flex vertical :gap="0" :style="{ flex: 1, minWidth: 0 }">
|
||||
<a-typography-text type="secondary">{{ k.label }}</a-typography-text>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
{{ faNum(k.value) }}{{ k.suffix || '' }}
|
||||
</a-typography-title>
|
||||
<a-tag
|
||||
v-if="k.pct != null"
|
||||
:color="(k.pct ?? 0) >= 0 ? 'success' : 'error'"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ trendText(k.pct) }}
|
||||
</a-tag>
|
||||
<CrmSkeletonKpi>
|
||||
<a-card :bordered="false" :style="{ height: '100%' }">
|
||||
<a-flex :gap="12" align="flex-start">
|
||||
<component
|
||||
:is="k.icon"
|
||||
:style="{ color: 'var(--ant-color-primary)', fontSize: '20px' }"
|
||||
/>
|
||||
<a-flex vertical :gap="0" :style="{ flex: 1, minWidth: 0 }">
|
||||
<a-typography-text type="secondary">{{ k.label }}</a-typography-text>
|
||||
<a-typography-title :level="3" :style="{ margin: 0 }">
|
||||
{{ faNum(k.value) }}{{ k.suffix || '' }}
|
||||
</a-typography-title>
|
||||
<a-tag
|
||||
v-if="k.pct != null"
|
||||
:color="(k.pct ?? 0) >= 0 ? 'success' : 'error'"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ trendText(k.pct) }}
|
||||
</a-tag>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-card>
|
||||
</CrmSkeletonKpi>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-card :bordered="false" :style="{ marginTop: '16px' }">
|
||||
<a-tabs v-model:activeKey="activeTab">
|
||||
<a-tab-pane key="levels" tab="سطوح">
|
||||
@@ -1135,7 +1138,8 @@ onBeforeUnmount(() => {
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalOpen"
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Steps as ASteps,
|
||||
Step as AStep,
|
||||
Switch as ASwitch,
|
||||
@@ -71,7 +70,8 @@ import {
|
||||
SmileOutlined,
|
||||
ThunderboltOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonList, CrmSkeletonTable } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
@@ -508,7 +508,8 @@ onUnmounted(() => {
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="6">
|
||||
<a-empty
|
||||
v-if="!loading && !visibleItems.length"
|
||||
description="هنوز قانون پیامی نیست."
|
||||
@@ -553,7 +554,8 @@ onUnmounted(() => {
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</template>
|
||||
|
||||
<!-- Mobile wizard -->
|
||||
@@ -1032,7 +1034,8 @@ onUnmounted(() => {
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonTable :rows="6" :columns="6">
|
||||
<a-card :bordered="false">
|
||||
<a-flex justify="space-between" align="center" :style="{ marginBottom: '12px' }">
|
||||
<a-typography-text strong>قوانین تعریفشده</a-typography-text>
|
||||
@@ -1089,7 +1092,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</CrmSkeletonTable>
|
||||
</CrmSkeleton>
|
||||
</template>
|
||||
|
||||
<a-modal
|
||||
|
||||
@@ -27,13 +27,13 @@ import {
|
||||
ListItem as AListItem,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { ReloadOutlined } from '@/icons/ant-bridge';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
@@ -282,7 +282,8 @@ onBeforeUnmount(() => {
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
|
||||
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-flex vertical :gap="16">
|
||||
<a-card :title="editingId ? 'ویرایش فروشگاه' : 'افزودن فروشگاه'" :bordered="false">
|
||||
<a-typography-paragraph type="secondary">
|
||||
@@ -513,6 +514,7 @@ onBeforeUnmount(() => {
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-flex>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
</a-flex>
|
||||
</template>
|
||||
|
||||
@@ -75,7 +75,8 @@ import {
|
||||
PushpinOutlined,
|
||||
ThunderboltOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
|
||||
const ACheckableTag = ATag.CheckableTag;
|
||||
|
||||
@@ -880,7 +881,8 @@ onBeforeUnmount(() => {
|
||||
</a-flex>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="6">
|
||||
<a-empty
|
||||
v-if="!loading && !notes.length"
|
||||
description="هنوز یادداشتی نیست — یکی اضافه کنید."
|
||||
@@ -1150,7 +1152,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Select as ASelect,
|
||||
Space,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Textarea as ATextarea,
|
||||
Typography,
|
||||
Upload as AUpload,
|
||||
@@ -33,7 +32,8 @@ import {
|
||||
PhoneOutlined,
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonCard } from '@/components/skeleton';
|
||||
import { api } from '@/lib/api';
|
||||
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonGender, PersonMood } from '@mento/shared';
|
||||
import { PERSON_GENDER_LABELS, PERSON_GENDERS, splitPersonHonorific } from '@mento/shared';
|
||||
@@ -466,8 +466,9 @@ onMounted(load);
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<a-form v-if="!loading" layout="vertical" @finish="submit">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonCard :rows="8">
|
||||
<a-form layout="vertical" @finish="submit">
|
||||
<a-card :bordered="false">
|
||||
<a-divider orientation="right">اطلاعات اصلی</a-divider>
|
||||
<a-row :gutter="[16, 0]">
|
||||
@@ -688,7 +689,8 @@ onMounted(load);
|
||||
<a-button v-if="!isNew" danger :disabled="saving" @click="remove">حذف</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</CrmSkeletonCard>
|
||||
</CrmSkeleton>
|
||||
|
||||
<a-upload
|
||||
ref="vcfUploadRef"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
} from '@/icons/ant-bridge';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { CrmSkeletonCard } from '@/components/skeleton';
|
||||
import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
|
||||
|
||||
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
|
||||
@@ -729,7 +730,19 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
|
||||
|
||||
<a-alert v-if="error" type="error" show-icon :message="error" />
|
||||
<a-alert v-if="importMsg" type="success" show-icon :message="importMsg" />
|
||||
<a-spin v-if="loading && !stages.length" tip="در حال بارگذاری مسیر فروش…" />
|
||||
<a-flex
|
||||
v-if="loading && !stages.length"
|
||||
:gap="GAP"
|
||||
:style="{ overflowX: 'auto', paddingBottom: `${GAP}px` }"
|
||||
>
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
:style="{ width: '280px', flex: '0 0 280px' }"
|
||||
>
|
||||
<CrmSkeletonCard :loading="true" :rows="5" :min-height="360" />
|
||||
</div>
|
||||
</a-flex>
|
||||
|
||||
<a-alert
|
||||
v-if="selectMode"
|
||||
|
||||
@@ -22,11 +22,11 @@ import {
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Space as ASpace,
|
||||
Spin as ASpin,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { PlusOutlined } from '@/icons/ant-bridge';
|
||||
import { CrmSkeleton, CrmSkeletonList } from '@/components/skeleton';
|
||||
|
||||
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
||||
|
||||
@@ -206,7 +206,8 @@ onMounted(load);
|
||||
|
||||
<a-divider orientation="right">فهرست کاریزها</a-divider>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<CrmSkeleton :loading="loading">
|
||||
<CrmSkeletonList :rows="5" :avatar="false">
|
||||
<a-empty
|
||||
v-if="listEmpty"
|
||||
description="کاریزی تعریف نشده. اولین مسیر فروش را اضافه کنید."
|
||||
@@ -232,7 +233,8 @@ onMounted(load);
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-spin>
|
||||
</CrmSkeletonList>
|
||||
</CrmSkeleton>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user