-
{{ copy.empty }}
+
+
-
-
-
-
-
-
- {{ copy.listColTitle }}
- مرحله
- مبلغ
- تاریخ
-
-
-
-
-
-
-
-
-
-
-
-
- {{ row.deal.initials }}
-
- {{ row.deal.title }}
- {{ row.deal.description }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
- {{ row.stageTitle }}
-
-
-
-
- {{ money(row.deal).number }}
- {{ money(row.deal).unit }}
-
-
-
-
-
- {{ row.deal.date }}
-
-
-
-
-
-
-
+ {{ record.deal.initials }}
+
+
+
+ {{ record.deal.title }}
+
+
+ {{ record.deal.description }}
+
+
+
+
+
+
+ {{ record.stageTitle }}
+
+
+
+
+ {{ money(record.deal).number }}
+
+ {{ money(record.deal).unit }}
+
+
+
+
+
+
+ {{ record.deal.date }}
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+ {{ row.deal.initials }}
-
- {{ row.deal.title }}
- {{ row.deal.description }}
+ >
+ {{ row.deal.initials }}
+
+
+
+ {{ row.deal.title }}
+
+
+ {{ row.deal.description }}
+
+
+
+
+
+
+
+
+
+
+ {{ row.stageTitle }}
+
+
+
+
+ {{ row.deal.date }}
+
+
+ {{ money(row.deal).number }}
+
+ {{ money(row.deal).unit }}
+
-
-
-
-
-
-
- {{ row.stageTitle }}
-
-
-
-
+
+
+
+
-
-
diff --git a/apps/web/src/components/pipeline/PipelineToolbar.vue b/apps/web/src/components/pipeline/PipelineToolbar.vue
index 8bde4be..4737667 100644
--- a/apps/web/src/components/pipeline/PipelineToolbar.vue
+++ b/apps/web/src/components/pipeline/PipelineToolbar.vue
@@ -1,12 +1,23 @@
-
+
+
+
+
+
-
-
diff --git a/apps/web/src/components/routines/EntityLinkPicker.vue b/apps/web/src/components/routines/EntityLinkPicker.vue
index 71a0a50..3e8f9e9 100644
--- a/apps/web/src/components/routines/EntityLinkPicker.vue
+++ b/apps/web/src/components/routines/EntityLinkPicker.vue
@@ -1,12 +1,22 @@
-
-
- {{ ENTITY_TYPE_LABELS[model.type] }}
- {{ model.label }}
-
-
-
-
+
+
+
+ {{ ENTITY_TYPE_LABELS[model.type] }}
+ {{ model.label }}
+
+
+
+
+
+
-
-
- {{ ENTITY_TYPE_LABELS[t] }}
-
-
-
-
- در حال جستجو…
- موردی یافت نشد
-
- {{ r.label }}
-
-
+
+
+
+
+
+ در حال جستجو…
+
+ موردی یافت نشد
+
+
+ {{ r.label }}
+
+
- ماژول لید/مشتری برای سازمان فعال نیست.
-
+
+
+ ماژول لید/مشتری برای سازمان فعال نیست.
+
+
-
-
diff --git a/apps/web/src/components/routines/QuickTaskModal.vue b/apps/web/src/components/routines/QuickTaskModal.vue
index 387aa06..9db37cd 100644
--- a/apps/web/src/components/routines/QuickTaskModal.vue
+++ b/apps/web/src/components/routines/QuickTaskModal.vue
@@ -8,9 +8,22 @@ import type {
RoutineTaskDto,
RoutineTaskSource,
} from '@mento/shared';
+import {
+ Alert as AAlert,
+ Checkbox as ACheckbox,
+ Col as ACol,
+ FormItem as AFormItem,
+ Input as AInput,
+ Modal as AModal,
+ Row as ARow,
+ Select as ASelect,
+ Space as ASpace,
+ Tag as ATag,
+ Textarea as ATextarea,
+ Typography,
+} from 'ant-design-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import EntityLinkPicker, { type PickedEntity } from '@/components/routines/EntityLinkPicker.vue';
-import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import {
ENTITY_TYPE_LABELS,
@@ -22,6 +35,8 @@ import {
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
+const { Text: ATypographyText } = Typography;
+
const props = withDefaults(
defineProps<{
open: boolean;
@@ -54,7 +69,23 @@ const form = reactive({
const saving = ref(false);
const error = ref('');
-const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
+const priorityOptions = computed(() =>
+ (Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>).map(
+ ([value, label]) => ({ value, label }),
+ ),
+);
+
+const categoryOptions = computed(() => [
+ { value: '', label: 'بدون دسته' },
+ ...(ctx.value?.categories ?? []).map((c) => ({ value: c.id, label: c.name })),
+]);
+
+const assigneeOptions = computed(() =>
+ assignees.value.map((u) => ({
+ value: u.id,
+ label: `${u.fullName}${u.id === auth.user?.id ? ' (خودم)' : ''}`,
+ })),
+);
async function ensureContext() {
if (ctx.value) return;
@@ -105,7 +136,7 @@ async function submit() {
error.value = '';
if (form.title.trim().length < 2) {
error.value = 'عنوان کار را وارد کنید.';
- return;
+ return Promise.reject();
}
const link = props.preset ?? form.entity;
const body: CreateRoutineTaskRequest = {
@@ -128,6 +159,7 @@ async function submit() {
emit('close');
} catch (e) {
error.value = apiError(e, 'ثبت کار ممکن نشد.');
+ return Promise.reject();
} finally {
saving.value = false;
}
@@ -135,119 +167,90 @@ async function submit() {
-
-
-
-
- ثبت کار
- انصراف
-
-
+
+
-
-
diff --git a/apps/web/src/components/routines/RelatedTasksPanel.vue b/apps/web/src/components/routines/RelatedTasksPanel.vue
index ca82945..64153f3 100644
--- a/apps/web/src/components/routines/RelatedTasksPanel.vue
+++ b/apps/web/src/components/routines/RelatedTasksPanel.vue
@@ -1,18 +1,30 @@
-
+
-
-
diff --git a/apps/web/src/components/routines/RoutineTaskModals.vue b/apps/web/src/components/routines/RoutineTaskModals.vue
index 146cc04..6dda801 100644
--- a/apps/web/src/components/routines/RoutineTaskModals.vue
+++ b/apps/web/src/components/routines/RoutineTaskModals.vue
@@ -1,9 +1,19 @@
-
-
+
+ کار لغو شده در محاسبه درصد انجام حساب نمیشود.
+
-
-
- {{ submitLabel }}
-
- انصراف
-
-
+
+
-
-
diff --git a/apps/web/src/components/routines/RoutineTaskRow.vue b/apps/web/src/components/routines/RoutineTaskRow.vue
index 5596142..ccac6eb 100644
--- a/apps/web/src/components/routines/RoutineTaskRow.vue
+++ b/apps/web/src/components/routines/RoutineTaskRow.vue
@@ -1,17 +1,33 @@
-
-
-
-
-
-
-
+ @click.stop
+ @update:checked="emit('toggle', task)"
+ />
-
-
-
{{ task.title }}
-
-
-
{{ task.description }}
+
+
+
+ {{ task.title }}
+
+
+
-
-
+
+
-
-
+
+
- {{ task.assignee.fullName }}
-
-
-
-
-
-
+ {{ task.assignee.fullName }}
+
+
+
+
+
+
+
+ جزئیات
+ شروع کار
+
+ {{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
+
+ تغییر زمان
+
+ انتقال به امروز
+
+ رد کردن
+ لغو کار
+
- جزئیات
- شروع کار
-
- {{ done ? 'ویرایش گزارش نتیجه' : 'انجام با ثبت گزارش' }}
-
- تغییر زمان
- انتقال به امروز
- رد کردن
- لغو کار
-
-
-
+
+
+
-
-
diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue
index 6f21b37..382adc8 100644
--- a/apps/web/src/layouts/AppLayout.vue
+++ b/apps/web/src/layouts/AppLayout.vue
@@ -1084,6 +1084,7 @@ onUnmounted(() => {
.workspace {
min-width: 0;
+ min-height: 100dvh;
display: flex;
flex-direction: column;
position: relative;
@@ -1904,10 +1905,10 @@ onUnmounted(() => {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
- gap: 1rem;
+ gap: 16px;
min-width: 0;
- /* همان فاصله بالای سایدبار شناور (14px) */
- padding: 14px 1.5rem 0.85rem;
+ /* همتراز با پدینگ ۱۶px محتوای workspace */
+ padding: 16px;
position: sticky;
top: 0;
z-index: 20;
@@ -2113,18 +2114,29 @@ onUnmounted(() => {
}
.content {
- padding: 0 1.5rem 1.5rem;
+ flex: 1 1 auto;
+ min-height: 0;
min-width: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
overflow-x: clip;
}
+/* ریشهٔ RouterView ارتفاع workspace را پر کند */
+.content > *:first-child {
+ flex: 1 1 auto;
+ min-height: 0;
+ min-width: 0;
+}
+
@media (max-width: 1280px) and (min-width: 901px) {
.content {
- padding-inline: 1rem;
+ padding: 16px;
}
.desktop-topbar {
- padding-inline: 1rem;
+ padding: 16px;
}
}
@@ -2253,7 +2265,7 @@ onUnmounted(() => {
}
.content {
- padding: 1rem 0.85rem calc(1.25rem + var(--mobile-tabbar-h));
+ padding: 16px 16px calc(16px + var(--mobile-tabbar-h));
}
.notif-layer {
diff --git a/apps/web/src/theme/antdTheme.ts b/apps/web/src/theme/antdTheme.ts
new file mode 100644
index 0000000..cd52a05
--- /dev/null
+++ b/apps/web/src/theme/antdTheme.ts
@@ -0,0 +1,77 @@
+import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context';
+
+/**
+ * Central Ant Design Vue v4 theme for Montact.
+ * Migrated pages must consume this via App ConfigProvider — do not fork in views.
+ *
+ * controlHeight: 36 is the system default (Ant derives SM/LG).
+ * Do not set controlHeight* under components — inherit the token.
+ */
+/** Tailwind gray-50 / gray-200 — layout fill + control/card borders */
+const GRAY_50 = '#f9fafb';
+const GRAY_200 = '#e5e7eb';
+
+export const antdTheme = {
+ token: {
+ colorPrimary: '#10b981',
+ fontFamily: '"IRANSans", Tahoma, sans-serif',
+
+ /** Page / subtle surface fill */
+ colorBgLayout: GRAY_50,
+ colorFillQuaternary: GRAY_50,
+ colorFillAlter: GRAY_50,
+
+ /** Control, card, and divider borders */
+ colorBorder: GRAY_200,
+ colorBorderSecondary: GRAY_200,
+
+ borderRadius: 8,
+ borderRadiusSM: 6,
+ borderRadiusLG: 12,
+
+ /** Default Ant control size for the whole app (buttons, inputs, selects, …). */
+ controlHeight: 36,
+
+ /* Spacing scale: 8, 12, 16, 20, 24 */
+ paddingXXS: 4,
+ paddingXS: 8,
+ paddingSM: 12,
+ padding: 16,
+ paddingMD: 20,
+ paddingLG: 24,
+ paddingXL: 32,
+
+ marginXXS: 4,
+ marginXS: 8,
+ marginSM: 12,
+ margin: 16,
+ marginMD: 20,
+ marginLG: 24,
+ marginXL: 32,
+
+ boxShadow:
+ '0 1px 2px 0 rgba(15, 23, 42, 0.04), 0 1px 6px -1px rgba(15, 23, 42, 0.06), 0 2px 4px 0 rgba(15, 23, 42, 0.04)',
+ boxShadowSecondary:
+ '0 6px 16px 0 rgba(15, 23, 42, 0.08), 0 3px 6px -4px rgba(15, 23, 42, 0.1), 0 9px 28px 8px rgba(15, 23, 42, 0.05)',
+ },
+ components: {
+ Card: {
+ paddingLG: 24,
+ headerBg: 'transparent',
+ },
+ Collapse: {
+ headerBg: GRAY_50,
+ contentBg: GRAY_50,
+ colorBorder: GRAY_200,
+ },
+ Input: {
+ colorBorder: GRAY_200,
+ },
+ Select: {
+ colorBorder: GRAY_200,
+ },
+ Form: {
+ itemMarginBottom: 18,
+ },
+ },
+} as ThemeConfig;
diff --git a/apps/web/src/views/DealFormView.vue b/apps/web/src/views/DealFormView.vue
index 8579c23..dad0ad7 100644
--- a/apps/web/src/views/DealFormView.vue
+++ b/apps/web/src/views/DealFormView.vue
@@ -1,7 +1,41 @@
-
-
-
-
- مسیر فروش
- ›
- {{ pageTitle }}
-
-
-
-
- صدور پیشفاکتور
-
-
- صدور فاکتور
-
-
-
- {{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
-
-
- انصراف
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {{ pageTitle }}
+
+
+
+ مسیر فروش
+ · {{ pageTitle }}
+
+
+
+
+
+ صدور پیشفاکتور
+
+
+ صدور فاکتور
+
+ انصراف
+
+
+ {{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
+
+
+
-
-
-
-
- {{ pageTitle }}
-
-
-
-
+
- در حال بارگذاری…
+
+
+
+
+ اطلاعات اصلی
-
-
-
- انصراف
-
- {{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
-
-
-
- {{ error }}
-
-
-
-
- {{ saving ? 'در حال ذخیره…' : 'ذخیره معامله' }}
-
-
- انصراف
-
-
-
-
+
-
-
+
+
-
-
diff --git a/apps/web/src/views/InvoiceFormView.vue b/apps/web/src/views/InvoiceFormView.vue
index c9d6839..865c3e8 100644
--- a/apps/web/src/views/InvoiceFormView.vue
+++ b/apps/web/src/views/InvoiceFormView.vue
@@ -10,11 +10,42 @@ import {
formatMoney as formatMoneyLib,
} from '@/lib/money';
import { formatLocalDateInput, toFaDigits } from '@/lib/jalali';
+import {
+ Affix as AAffix,
+ Alert as AAlert,
+ Button as AButton,
+ Card as ACard,
+ Col as ACol,
+ Collapse as ACollapse,
+ CollapsePanel as ACollapsePanel,
+ Descriptions as ADescriptions,
+ DescriptionsItem as ADescriptionsItem,
+ Divider as ADivider,
+ Flex as AFlex,
+ Form as AForm,
+ FormItem as AFormItem,
+ Input as AInput,
+ InputNumber as AInputNumber,
+ Row as ARow,
+ Segmented as ASegmented,
+ Select as ASelect,
+ Space as ASpace,
+ Spin as ASpin,
+ Textarea as ATextarea,
+ Typography,
+} from 'ant-design-vue';
+import {
+ ArrowLeftOutlined,
+ CheckOutlined,
+ DeleteOutlined,
+ EyeOutlined,
+ FileTextOutlined,
+ PlusOutlined,
+ UserAddOutlined,
+} from '@ant-design/icons-vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
-import UiIcon from '@/components/UiIcon.vue';
-import BrandName from '@/components/brand/BrandName.vue';
-import { brand } from '@mento/shared';
+import InvoiceDocPreview from '@/components/InvoiceDocPreview.vue';
import { useAuthStore } from '@/stores/auth';
import type {
CreateInvoiceRequest,
@@ -95,8 +126,6 @@ const saving = ref(false);
const paying = ref(false);
const error = ref('');
const mobileStep = ref<1 | 2>(1);
-const showExtra = ref(false);
-const showShipping = ref(false);
const showPayments = ref(false);
const productSearch = ref('');
const validityMode = ref<'days' | 'date'>('days');
@@ -122,6 +151,8 @@ const products = ref
([]);
const lines = ref([]);
const installments = ref([]);
const invoice = ref(null);
+const orgName = ref('');
+const previewOpen = ref(false);
const payAmount = ref('');
const payMethod = ref('transfer');
@@ -301,11 +332,6 @@ function removeLine(key: string) {
lines.value = lines.value.filter((l) => l.key !== key);
}
-function bumpQty(line: LineDraft, delta: number) {
- const next = Math.max(0.01, (Number(line.quantity) || 0) + delta);
- line.quantity = String(Number.isInteger(next) ? next : Number(next.toFixed(2)));
-}
-
function onPickProduct(line: LineDraft) {
const product = products.value.find((p) => p.id === line.productId);
if (!product) return;
@@ -321,12 +347,6 @@ function onPersonChange() {
companyId.value = person?.company?.id ?? '';
}
-function productIcon(line: LineDraft): 'box' | 'briefcase' {
- if (line.kind === 'service') return 'briefcase';
- const product = products.value.find((p) => p.id === line.productId);
- return product?.kind === 'service' ? 'briefcase' : 'box';
-}
-
function toLineInputs(): DealLineItemInput[] {
return lines.value
.filter((l) => l.name.trim() && l.quantity !== '' && l.unitPrice !== '')
@@ -505,6 +525,7 @@ async function load() {
} catch {
/* optional */
}
+ orgName.value = orgRes.data.name || '';
enabledCurrencies.value = orgRes.data.enabledCurrencies?.length
? orgRes.data.enabledCurrencies
: [orgRes.data.baseCurrency || 'IRR'];
@@ -800,8 +821,72 @@ function splitInstallments(count: number) {
});
}
+function lineHasContent(line: LineDraft) {
+ return Boolean(
+ line.name.trim() ||
+ line.productId ||
+ line.description.trim() ||
+ (line.unitPrice !== '' && Number.isFinite(Number(line.unitPrice))),
+ );
+}
+
+const previewLines = computed(() =>
+ lines.value.filter(lineHasContent).map((l) => {
+ const product = products.value.find((p) => p.id === l.productId);
+ const qty = Number(l.quantity);
+ const unit = Number(l.unitPrice);
+ const unitPrice = Number.isFinite(unit) && l.unitPrice !== ''
+ ? unit
+ : Number(product?.unitPrice) || 0;
+ const quantity = Number.isFinite(qty) && l.quantity !== '' ? qty : 1;
+ return {
+ name:
+ l.name.trim() ||
+ product?.name ||
+ (l.productId ? 'محصول انتخابشده' : 'بدون نام'),
+ description: l.description?.trim() || product?.description || undefined,
+ sku: l.sku || product?.sku || undefined,
+ quantity,
+ unitPrice,
+ total: quantity * unitPrice,
+ };
+ }),
+);
+
+const previewSubtotal = computed(() =>
+ previewLines.value.reduce((sum, l) => sum + l.total, 0),
+);
+const previewTaxAmount = computed(() =>
+ Math.round(
+ (Math.max(0, previewSubtotal.value - discountNum.value) * (taxPercentNum.value / 100) +
+ Number.EPSILON) *
+ 100,
+ ) / 100,
+);
+const previewPayable = computed(() => {
+ const signed = Math.round(
+ (previewSubtotal.value - discountNum.value + previewTaxAmount.value + Number.EPSILON) * 100,
+ ) / 100;
+ return docMode.value === 'credit' ? -signed : signed;
+});
+
+const previewCustomer = computed(() => {
+ const p = people.value.find((x) => x.id === personId.value);
+ if (!p) return { name: '—', sub: '' };
+ const full = p.fullName || `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim();
+ return {
+ name: p.company?.name || full || '—',
+ sub: p.company?.name ? full : '',
+ };
+});
+
+const previewStatusLabel = computed(() => {
+ const map = statusOptions.value as Record;
+ return map[status.value] || status.value || '—';
+});
+
function printDoc() {
- window.print();
+ previewOpen.value = true;
}
function goBack() {
@@ -838,1793 +923,640 @@ watch(
},
);
+
+const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
+
+const personSelectOptions = computed(() =>
+ people.value.map((p) => ({ value: p.id, label: personLabel(p) })),
+);
+
+const statusSelectOptions = computed(() =>
+ Object.entries(statusOptions.value).map(([value, label]) => ({ value, label })),
+);
+
+const currencyAntOptions = computed(() =>
+ currencySelectOptions.value.map((o) => ({ value: o.code, label: o.label })),
+);
+
+const productSelectOptions = computed(() =>
+ filteredProducts.value.map((p) => ({ value: p.id, label: p.name })),
+);
+
+const validityDayOptions = computed(() =>
+ [7, 15, 30, 60].map((d) => ({ value: d, label: `${toFaDigits(d)} روز` })),
+);
+
+const methodSelectOptions = computed(() =>
+ (Object.entries(methodLabel) as Array<[PaymentMethod, string]>).map(
+ ([value, label]) => ({ value, label }),
+ ),
+);
+
+const payInstallmentOptions = computed(() => [
+ { value: '', label: 'بدون اتصال به قسط' },
+ ...pendingInstallments.value.map((inst) => ({
+ value: inst.id,
+ label: `قسط ${toFaDigits(inst.position + 1)} — ${formatMoney(inst.amount)}`,
+ })),
+]);
+
+const chequeStatusOptions = [
+ { value: 'pending', label: 'در جریان' },
+ { value: 'cleared', label: 'وصول شد' },
+ { value: 'bounced', label: 'برگشت' },
+];
+
+const docModeSegOptions = computed(() => {
+ const opts: Array<{ value: DocMode; label: string; disabled?: boolean }> = [
+ {
+ value: 'invoice',
+ label: 'فاکتور',
+ disabled: !isNew.value && docMode.value !== 'invoice',
+ },
+ {
+ value: 'quote',
+ label: 'پیش فاکتور',
+ disabled: !isNew.value && docMode.value !== 'quote',
+ },
+ ];
+ if (docMode.value === 'credit') {
+ opts.push({ value: 'credit', label: 'برگشتی', disabled: true });
+ }
+ return opts;
+});
+
+const sideCollapseKeys = ref([]);
+
+const paymentsCollapseKeys = computed({
+ get: () => (showPayments.value ? ['payments'] : []),
+ set: (keys: string[]) => {
+ showPayments.value = keys.includes('payments');
+ },
+});
+
+function setLineQty(line: LineDraft, value: number | string | null) {
+ const n = Number(value);
+ line.quantity = String(Number.isFinite(n) && n > 0 ? n : 1);
+}
+
+function setLineProduct(line: LineDraft, value: unknown) {
+ line.productId = value == null ? '' : String(value);
+ onPickProduct(line);
+}
+
+function onDocModeSeg(value: string | number) {
+ switchDocMode(String(value) as DocMode);
+}
+
onMounted(load);
-
-
-
-
-
-
-
-
-
{{ pageTitle }}
-
{{ pageSubtitle }}
-
+
+
+
+
+
+
{{ pageTitle }}
+
{{ pageSubtitle }}
-
-
-
+
+
+
بازگشت
-
-
+
+
-
در حال بارگذاری…
+
+
+
-
-
-
-
-
{{ number }}
-
-
-
-
-
-
-
-
-
-
-
- محصولات و خدمات
-
-
-
-
-
-
-
-
- #
- محصول
- کد
- قیمت واحد - {{ currencyLabel }}
- تعداد
- مبلغ کل - {{ currencyLabel }}
- عملیات
-
-
-
-
- {{ toFaDigits(idx + 1) }}
-
-
-
- {{ line.sku || '—' }}
-
-
+
+
+
+
+
+
+ افزودن مشتری جدید
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ فیلدی برای تکمیل وجود ندارد.
+
+
+ فیلدی برای تکمیل وجود ندارد.
+
+
+
+ محصولات و خدمات
+
+
+
+
+
+
+ افزودن محصول
+
+
+
+
+ افزودن ردیف
+
+
+
+
+
+
+
+
+
+
+
+ ردیف {{ toFaDigits(idx + 1) }}
+
+
-
-
-
{{ toFaDigits(line.quantity || '0') }}
-
-
-
-
-
- {{ formatMoney(lineTotal(line)) }}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ setLineProduct(line, v)"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setLineQty(line, v)"
+ />
+
+
+
+
+ {{ formatMoney(lineTotal(line)) }}
+
+
+ {{ line.sku || '—' }}
+
+
+
+
+
+
+
-
- + افزودن ردیف جدید
-
+ مبالغ
-
-
- جمع کل
- {{ formatMoney(subtotal) }}
-
-
- تخفیف
-
-
-
- مالیات (%)
-
-
-
- مبلغ مالیات
- {{ formatMoney(taxAmount) }}
-
-
- مبلغ قابل پرداخت
- {{ formatMoney(payableAmount) }}
-
-
-
-
- درصد
-
-
- مبلغ ثابت
-
-
-
- پورسانت کارشناس (%)
-
-
-
- مبلغ پورسانت
-
-
-
- برآورد پورسانت: {{ formatMoney(commissionPreview) }}
-
- · ثبتشده {{ formatMoney(invoice.commission.amount) }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+ (taxPercent = String(v ?? 0))"
+ />
+
+
+
+
+
+
+
+
+
+ (commissionPercent = v == null ? '' : String(v))"
+ />
+
+
+
+
+
+
+
+
-
-
- اقساط و پرداختها
-
-
-
-
-
- جمع: {{ formatMoney(invoice.payableAmount ?? invoice.totalAmount) }}
- وصول: {{ formatMoney(invoice.paidAmount) }}
- مانده: {{ formatMoney(invoice.remainingAmount) }}
-
-
-
-
اقساط
-
-
- ۲ قسط
-
-
- ۳ قسط
-
-
- افزودن قسط
-
-
-
-
-
-
-
-
-
-
-
بدون برنامه اقساط.
+
+
+
+ جمع: {{ formatMoney(invoice?.payableAmount ?? invoice?.totalAmount ?? 0) }}
+
+
+ وصول: {{ formatMoney(invoice?.paidAmount ?? 0) }}
+
+
+ مانده: {{ formatMoney(invoice?.remainingAmount ?? 0) }}
+
+
- ثبت پرداخت
-
-
-
-
-
- {{ formatMoney(p.amount) }}
-
- · {{ methodLabel[p.method as PaymentMethod] || p.method }}
-
- · چک {{ p.cheque.number }} / {{ p.cheque.bank }}
-
- · {{ p.paidAt.slice(0, 10) }}
-
-
-
+ {{ formatMoney(p.amount) }}
+
+ · {{ methodLabel[p.method as PaymentMethod] || p.method }}
+
+ · چک {{ p.cheque.number }} / {{ p.cheque.bank }}
+
+ · {{ p.paidAt.slice(0, 10) }}
+
+
+
حذف
+
+
- حذف
-
-
-
- هنوز پرداختی ثبت نشده.
-
-
-
-
+ هنوز پرداختی ثبت نشده.
+
+
+
-
-
-
-
-
- فاکتور
-
-
- پیش فاکتور
-
-
- برگشتی
-
-
-
-
-
-
- اطلاعات
-
-
-
- مشتری *
-
-
-
- انتخاب مشتری
-
- {{ personLabel(p) }}
-
-
-
-
- + افزودن مشتری جدید
-
-
-
-
- تاریخ *
-
-
-
-
- وضعیت
-
-
-
-
- {{ label }}
-
-
-
-
-
-
- ارز
-
-
-
-
- {{ opt.label }}
-
-
-
-
-
-
-
- اعتبار تا
-
-
-
- مدت اعتبار
-
- {{ toFaDigits(7) }} روز
- {{ toFaDigits(15) }} روز
- {{ toFaDigits(30) }} روز
- {{ toFaDigits(60) }} روز
-
-
-
-
- مهلت پرداخت
-
-
-
-
- یادداشت
-
-
-
-
-
-
-
- اطلاعات تکمیلی
-
-
-
-
- فیلدی برای تکمیل وجود ندارد.
-
-
-
-
- آدرس و ارسال
-
-
-
-
- فیلدی برای تکمیل وجود ندارد.
-
-
-
-
-
-
-
-
-
- ادامه به محصولات →
-
-
-
-
-
-
-
-
-
-
-
-
-
- محصولات و خدمات
- {{ toFaDigits(lines.length) }} مورد
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ toFaDigits(idx + 1) }}
-
+
+ پیشنمایش
+
+
-
-
-
-
- انتخاب محصول…
-
- {{ p.name }}
-
-
-
-
-
- قیمت واحد - {{ currencyLabel }}
-
-
-
-
-
-
- {{ toFaDigits(line.quantity || '0') }}
-
-
-
-
-
-
- مبلغ کل: {{ formatMoney(lineTotal(line)) }}
-
-
+
+ حذف
+
+
+
+ {{ saveLabel }}
+
+
-
- + افزودن محصول دیگر
-
+
+ ذخیره
+ انصراف
+
+
+
-
-
- جمع کل
- {{ formatMoney(subtotal) }}
-
-
- تخفیف
-
-
-
- مالیات (%)
-
-
-
- مبلغ قابل پرداخت
- {{ formatMoney(payableAmount) }}
-
-
-
- پورسانت کارشناس (%)
-
-
-
برآورد: {{ formatMoney(commissionPreview) }}
-
-
-
+
+
+
+
+
+ {{ formatMoney(subtotal) }}
+
+
+ {{ formatMoney(discountNum) }}
+
+
+ {{ formatMoney(taxAmount) }}
+
+
+ {{ formatMoney(payableAmount) }}
+
+
+ {{ formatMoney(commissionPreview) }}
+
+ · ثبتشده {{ formatMoney(invoice.commission.amount) }}
+
+
+
+
+
+
+
+
+
-
-
-
- پیشنمایش
-
-
-
- {{ saving ? 'در حال ذخیره…' : saveLabel }}
-
-
-
-
-
-
{{ error }}
-
-
-
-
-
-
-
- {{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
-
- انصراف
-
-
+
+
-
diff --git a/apps/web/src/views/InvoicesView.vue b/apps/web/src/views/InvoicesView.vue
index bdc0cd9..67cd985 100644
--- a/apps/web/src/views/InvoicesView.vue
+++ b/apps/web/src/views/InvoicesView.vue
@@ -1,6 +1,5 @@
-
-
+
-
-
-
-
-
- افزودن فاکتور
-
-
-
-
-
-
-
-
- افزودن
-
-
-
-
-
-
-
-
- همه
- {{ toFaDigits(counts.all) }}
-
-
- فاکتور
- {{ toFaDigits(counts.invoice) }}
-
-
- پیش فاکتور
- {{ toFaDigits(counts.quote) }}
-
-
- فاکتور برگشتی
- {{ toFaDigits(counts.credit) }}
-
-
-
-
-
-
diff --git a/apps/web/src/views/LeadBoardView.vue b/apps/web/src/views/LeadBoardView.vue
index a773611..5e9cff0 100644
--- a/apps/web/src/views/LeadBoardView.vue
+++ b/apps/web/src/views/LeadBoardView.vue
@@ -1,18 +1,153 @@
-
-
-
-
- فهرست لیدها
+
+
+
+
+ مسیر لیدها
+
+
+ کشیدن و رها کردن برای تغییر وضعیت
+
+
+
+
+
+
+ فهرست لیدها
+
-
+
+
بروزرسانی
-
-
-
+
+
+
-
{{ error }}
-
در حال بارگذاری…
+
-
-
+
-
-
- {{ col.stage.name }}
- {{ toFaDigits(col.total ?? col.leads.length) }}
-
+
+
-
-
-
- {{ lead.title }}
-
- {{ lead.mobile }}
- {{ lead.city }}
- {{ lead.assigneeName }}
-
- پیگیری: {{ formatDate(lead.nextFollowUpAt) }}
-
-
-
خالی
+
+
+
+
+
+
+
+ {{ col.stage.name }}
+
+
+ {{ toFaDigits(col.leads.length) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ leadInitials(lead) }}
+
+
+
+ {{ lead.title }}
+
+
+ {{ leadSubtitle(lead) }}
+
+
+
+
+
+
+
+
+ {{ lead.mobile }}
+
+
+
+ {{ lead.assigneeName }}
+
+
+
+
+
+
+ پیگیری {{ formatDate(lead.nextFollowUpAt) }}
+
+
+
+
+
+
+ خالی
+
+
-
+
-
-
-
+
diff --git a/apps/web/src/views/LeadDetailView.vue b/apps/web/src/views/LeadDetailView.vue
index 06b081d..05b182d 100644
--- a/apps/web/src/views/LeadDetailView.vue
+++ b/apps/web/src/views/LeadDetailView.vue
@@ -1,23 +1,59 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {{ lead?.title || 'جزئیات لید' }}
+
+
+ {{ lead ? `موبایل ${lead.mobile}` : 'در حال بارگذاری…' }}
+
+
+
+
+
+
پیامک
-
-
+
+
+
ویرایش
-
-
+
+
تبدیل به مشتری
-
-
+
بایگانی
-
-
-
-
+
+
+
+ بیشتر
+
+
+
+
-
+
+
+
+
+
+
- {{ okMsg }}
- {{ error }}
- در حال بارگذاری…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ذخیره
+
+ انصراف
+
+
+
+
-
-
-
-
-
-
-
-
-
-
{{ lead.title }}
- {{ lead.stageName }}
-
-
+
+
+
+ {{ lead.stageName }}
+
+
{{ toFaDigits(lead.mobile) }}
-
-
-
-
- کپی شد
-
-
-
-
-
-
-
-
-
- تماس
-
-
-
+ · کپی شد
+
+
+
+ { if (telHref) window.location.href = telHref }"
+ >
+
+ تماس
+
+
+
پیامک
-
-
-
- ویرایش
-
-
-
-
-
-
- تبدیل به مشتری
-
-
-
+
+
+
-
-
-
-
-
نام
- {{ lead.title }}
-
-
-
مالک / مخاطب
- {{ lead.contactName || '—' }}
-
-
-
موبایل
- {{ toFaDigits(lead.mobile) }}
-
-
-
تلفن
- {{ lead.phone ? toFaDigits(lead.phone) : '—' }}
-
-
-
شهر
- {{ lead.city || '—' }}
-
-
-
منطقه
- {{ lead.region || '—' }}
-
-
-
صنف
- {{ lead.trade || '—' }}
-
-
-
اینستاگرام
- {{ lead.instagram || '—' }}
-
-
-
منبع
- {{ lead.source || '—' }}
-
-
-
وضعیت
- {{ lead.stageName }}
-
-
-
کارشناس
- {{ lead.assigneeName || '—' }}
-
-
-
تاریخ تولد
- {{ formatDate(lead.birthDate) }}
-
-
-
تاریخ ثبت
- {{ formatDate(lead.createdAt) }}
-
-
-
روز در مرحله
- {{ toFaDigits(lead.daysInStage) }}
-
-
-
پیگیری بعدی
- {{ formatDate(lead.nextFollowUpAt) }}
-
-
-
آدرسها
-
- {{ addr }}
-
-
-
-
+
+ {{ lead.title }}
+
+ {{ lead.contactName || '—' }}
+
+
+ {{ lead.mobile }}
+
+
+ {{ lead.phone || '—' }}
+
+ {{ lead.city || '—' }}
+
+ {{ lead.region || '—' }}
+
+
+ {{ lead.instagram || '—' }}
+
+ {{ lead.trade || '—' }}
+
+ {{ lead.source || '—' }}
+
+ {{ lead.stageName }}
+
+ {{ lead.assigneeName || '—' }}
+
+
+ {{ formatDate(lead.birthDate) }}
+
+
+ {{ formatDate(lead.createdAt) }}
+
+
+ {{ formatDate(lead.lastContactAt) }}
+
+
+ {{ formatDate(lead.nextFollowUpAt) }}
+
+
+ {{ toFaDigits(lead.daysInStage) }}
+
+
+ {{ addr }}
+
+
+ {{ lead.notes }}
+
+
+
-
-
- {{ lead.notes?.trim() || 'یادداشتی ثبت نشده است.' }}
-
+
+ فعالیتهای مرتبط
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+ {{ item.typeDef?.name || ACTIVITY_TYPE_LABELS[item.type] }}
+ · {{ item.assignee?.fullName || 'بدون مسئول' }}
+ · {{ formatDate(item.dueAt) }}
+ ·
+ {{
+ item.status === 'done'
+ ? 'انجام شد'
+ : item.status === 'canceled'
+ ? 'لغو شده'
+ : 'باز'
+ }}
+
+
+
+
+
+
+
+ جدول زمانی
+
+
+ {{ ev.title }}
+
+
+ {{ formatDate(ev.createdAt) }}
+
+
+
+ {{ ev.body }}
+
+
+
+
+
+ خلاصه فعالیت
+
+
+
+
+
+
+ {{ feedParts(item.at).day }}
+ {{ feedParts(item.at).time }}
+
+
+
+
+
+
+
+ مشاهده همه فعالیتها
+
+
+
+
+
+ ایجاد فعالیت
+
+
+
+
+
-
-
-
-
- فعالیتها
-
-
- ایجاد فعالیت
-
-
-
-
-
-
-
- {{ row.title }}
- {{ row.by }}
-
-
- {{ feedParts(row.at).day }}
- {{ feedParts(row.at).time }}
-
-
-
- هنوز فعالیتی ثبت نشده است.
-
- مشاهده همه فعالیتها
-
-
-
-
+
+
+
+
+ پیامک
+
+
+ ارسال پیامک
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ذخیره
-
- انصراف
-
-
-
-
-
نام {{ lead.title }}
-
مالک/مخاطب {{ lead.contactName || '—' }}
-
موبایل {{ lead.mobile }}
-
تلفن {{ lead.phone || '—' }}
-
شهر {{ lead.city || '—' }}
-
منطقه {{ lead.region || '—' }}
-
اینستاگرام {{ lead.instagram || '—' }}
-
صنف {{ lead.trade || '—' }}
-
منبع {{ lead.source || '—' }}
-
وضعیت {{ lead.stageName }}
-
کارشناس {{ lead.assigneeName || '—' }}
-
تاریخ تولد {{ formatDate(lead.birthDate) }}
-
تاریخ ثبت {{ formatDate(lead.createdAt) }}
-
آخرین تماس {{ formatDate(lead.lastContactAt) }}
-
پیگیری بعدی {{ formatDate(lead.nextFollowUpAt) }}
-
روز در مرحله {{ toFaDigits(lead.daysInStage) }}
-
-
آدرسها
-
- {{ addr }}
-
-
-
-
یادداشت
- {{ lead.notes }}
-
-
-
+
تغییر وضعیت
+
+
+
+
+ اعمال وضعیت
+
-
+
+ تخصیص کارشناس
+
+
+
+
+ تخصیص
+
+
-
-
-
-
- {{ a.title }}
-
-
- {{ a.typeDef?.name || ACTIVITY_TYPE_LABELS[a.type] }}
- · {{ a.assignee?.fullName || 'بدون مسئول' }}
- · {{ formatDate(a.dueAt) }}
- · {{ a.status === 'done' ? 'انجام شد' : a.status === 'canceled' ? 'لغو شده' : 'باز' }}
-
-
-
-
+
ثبت تماس
+
+
+
+
+
+
+
+
+
+
+ ثبت تماس
+
-
-
-
- {{ ev.title }}
- {{ formatDate(ev.createdAt) }}
- {{ ev.body }}
-
-
-
-
-
+
یادداشت
+
+
+
+
+ ثبت یادداشت
+
-
- بستن
-
-
+ پیگیری بعدی
+
+
+
+
+
+
+
+ ذخیره پیگیری
+
+
+
+ خلاصه
+
+
+ {{ lead.stageName }}
+
+
+ {{ lead.assigneeName || '—' }}
+
+
+ {{ formatDate(lead.nextFollowUpAt) }}
+
+
+ {{ toFaDigits(lead.daysInStage) }}
+
+
+
+
+
+
+
+
+
+ پیامک
+
ارسال پیامک
-
-
-
-
-
-
- {{ s.name }}
-
-
-
- اعمال
-
+
-
-
-
- انتخاب…
-
- {{ u.fullName }}
-
-
-
-
+ تغییر وضعیت
+
+
+
+ اعمال وضعیت
+
+
+ تخصیص کارشناس
+
+
+
+
تخصیص
-
-
+
+
-
-
-
-
-
-
-
-
-
-
- ثبت تماس
-
+ ثبت تماس
+
+
+
+
+
+
+
+
+
+ ثبت تماس
-
-
-
-
-
+ یادداشت
+
+
+
+
ثبت یادداشت
-
-
+
-
-
-
-
-
-
-
-
+ پیگیری بعدی
+
+
+
+
+
+
+
ذخیره پیگیری
-
-
-
-
+
+
+
+
narrowMq?.removeEventListener('change', syncNarrow));
@close="smsOpen = false"
/>
-
+
+
+
-
-
diff --git a/apps/web/src/views/LeadsView.vue b/apps/web/src/views/LeadsView.vue
index b2e5b90..6a27fce 100644
--- a/apps/web/src/views/LeadsView.vue
+++ b/apps/web/src/views/LeadsView.vue
@@ -2,6 +2,54 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
+import {
+ Alert as AAlert,
+ Avatar as AAvatar,
+ Button as AButton,
+ Card as ACard,
+ Checkbox as ACheckbox,
+ Col as ACol,
+ Dropdown as ADropdown,
+ Empty as AEmpty,
+ Flex as AFlex,
+ Form as AForm,
+ FormItem as AFormItem,
+ Grid,
+ Input as AInput,
+ InputSearch as AInputSearch,
+ Menu as AMenu,
+ MenuItem as AMenuItem,
+ Modal,
+ Modal as AModal,
+ Pagination as APagination,
+ RadioButton as ARadioButton,
+ RadioGroup as ARadioGroup,
+ Row as ARow,
+ Select as ASelect,
+ Space as ASpace,
+ Spin as ASpin,
+ Table as ATable,
+ Tag as ATag,
+ Typography,
+ Upload as AUpload,
+ message,
+ theme,
+} from 'ant-design-vue';
+import type { TableColumnsType, UploadProps } from 'ant-design-vue';
+import {
+ AppstoreOutlined,
+ DeleteOutlined,
+ EditOutlined,
+ ExportOutlined,
+ FileExcelOutlined,
+ FilterOutlined,
+ MoreOutlined,
+ PhoneOutlined,
+ PlusOutlined,
+ SearchOutlined,
+ UnorderedListOutlined,
+ UserOutlined,
+} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import {
formatJalaliNumeric,
@@ -10,15 +58,7 @@ import {
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
-import {
- UiBusyOverlay,
- UiButton,
- UiDropdownMenu,
- UiEmptyState,
- UiModal,
- UiPageHeader,
-} from '@/components/ui';
-import UiIcon from '@/components/UiIcon.vue';
+import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import PhoneContactsImport from '@/components/PhoneContactsImport.vue';
@@ -41,11 +81,16 @@ import type {
} from '@mento/shared';
import { brand } from '@mento/shared';
+const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
+ Typography;
+const { token } = theme.useToken();
+const screens = Grid.useBreakpoint();
+const isMobile = computed(() => !screens.value.md);
+
const PAGE_SIZE = 10;
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
type StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal';
-type RowIcon = 'building' | 'bag' | 'briefcase' | 'users' | 'target';
/** ردیف آمار صفحه لیدها — ترتیب و نامها مطابق طرح */
const LEAD_STAT_DEFS: Array<{
@@ -121,8 +166,19 @@ const rowMenuId = ref
(null);
const logoMenuId = ref(null);
const logoUrls = ref>({});
const logoBusyId = ref(null);
-const logoFileInput = ref(null);
const logoTargetId = ref(null);
+const leadCols = useTableColumns(() => 'leads');
+const LEAD_COL_LABELS = [
+ 'نام / شرکت',
+ 'شماره تماس',
+ 'شهر / منطقه',
+ 'منبع لید',
+ 'تاریخ ثبت',
+ 'وضعیت',
+ 'مسئول',
+ 'آخرین فعالیت',
+ 'اقدامات',
+] as const;
const assignOpen = ref(false);
const stageOpen = ref(false);
const assignUserId = ref('');
@@ -146,7 +202,6 @@ const createForm = ref({
});
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
-const contactFileInput = ref(null);
const sourceOpen = ref(false);
const pickOpen = ref(false);
const pickDrafts = ref([]);
@@ -190,11 +245,6 @@ const rangeFrom = computed(() =>
const rangeTo = computed(() =>
Math.min(page.value * PAGE_SIZE, total.value),
);
-const allSelected = computed(
- () =>
- Boolean(data.value?.items.length) &&
- data.value!.items.every((l) => selected.value.has(l.id)),
-);
const selectedIds = computed(() => [...selected.value]);
const sortLabels: Record = {
@@ -223,9 +273,6 @@ const stageCards = computed((): LeadStatCard[] => {
});
});
-const pairStatCards = computed(() => stageCards.value.filter((c) => c.slot === 'pair'));
-const soloStatCards = computed(() => stageCards.value.filter((c) => c.slot === 'solo'));
-
const totalLeadsCount = computed(() => {
if (stats.value?.total != null) return stats.value.total;
return stages.value.reduce((sum, s) => sum + (s.leadCount ?? 0), 0) || total.value;
@@ -250,21 +297,6 @@ function selectStatCard(card: LeadStatCard) {
setStageFilter(isStatActive(card) ? '' : card.stageId);
}
-const pageNumbers = computed(() => {
- const totalPages = pageCount.value;
- const current = page.value;
- if (totalPages <= 5) {
- return Array.from({ length: totalPages }, (_, i) => i + 1);
- }
- let start = Math.max(1, current - 2);
- let end = start + 4;
- if (end > totalPages) {
- end = totalPages;
- start = Math.max(1, end - 4);
- }
- return Array.from({ length: end - start + 1 }, (_, i) => start + i);
-});
-
function stageTint(color: string | null | undefined): StageTint {
const c = (color || '').toLowerCase();
if (c === 'amber' || c === 'yellow' || c === 'orange') return 'amber';
@@ -294,13 +326,6 @@ function stageTintForLead(lead: LeadDto): StageTint {
return stageTint(stage?.color);
}
-function rowIcon(lead: LeadDto): RowIcon {
- const key = `${lead.trade || ''}${lead.source || ''}${lead.title}`;
- let hash = 0;
- for (let i = 0; i < key.length; i++) hash = (hash + key.charCodeAt(i) * (i + 1)) % 5;
- return (['building', 'bag', 'briefcase', 'users', 'target'] as const)[hash]!;
-}
-
function initials(name: string | null | undefined) {
if (!name?.trim()) return '؟';
const parts = name.trim().split(/\s+/).filter(Boolean);
@@ -385,16 +410,9 @@ async function loadLogos(items: LeadDto[]) {
function openLogoPicker(leadId: string) {
logoMenuId.value = null;
logoTargetId.value = leadId;
- logoFileInput.value?.click();
}
-async function onLogoFile(ev: Event) {
- const input = ev.target as HTMLInputElement;
- const file = input.files?.[0];
- const leadId = logoTargetId.value;
- input.value = '';
- logoTargetId.value = null;
- if (!file || !leadId) return;
+async function uploadLeadLogo(leadId: string, file: File) {
logoBusyId.value = leadId;
error.value = '';
try {
@@ -416,6 +434,7 @@ async function onLogoFile(ev: Event) {
});
logoUrls.value = { ...logoUrls.value, [leadId]: URL.createObjectURL(blob) };
okMsg.value = `لوگوی «${updated.title}» ذخیره شد.`;
+ message.success(okMsg.value);
} catch (e) {
error.value = axios.isAxiosError(e)
? apiErrorMessage(e, 'آپلود لوگو ناموفق بود.')
@@ -433,30 +452,40 @@ function apiErrorMessage(e: unknown, fallback: string) {
return fallback;
}
-async function removeLeadLogo(leadId: string) {
+function removeLeadLogo(leadId: string) {
logoMenuId.value = null;
- if (!confirm('لوگوی این لید حذف شود؟')) return;
- logoBusyId.value = leadId;
- error.value = '';
- try {
- await api.delete(`/leads/${leadId}/logo`);
- const prev = logoUrls.value[leadId];
- if (prev) URL.revokeObjectURL(prev);
- const next = { ...logoUrls.value };
- delete next[leadId];
- logoUrls.value = next;
- if (data.value) {
- data.value = {
- ...data.value,
- items: data.value.items.map((l) => (l.id === leadId ? { ...l, hasLogo: false } : l)),
- };
- }
- okMsg.value = 'لوگو حذف شد.';
- } catch {
- error.value = 'حذف لوگو ناموفق بود.';
- } finally {
- logoBusyId.value = null;
- }
+ Modal.confirm({
+ title: 'لوگوی این لید حذف شود؟',
+ okText: 'حذف',
+ cancelText: 'انصراف',
+ okButtonProps: { danger: true },
+ async onOk() {
+ logoBusyId.value = leadId;
+ error.value = '';
+ try {
+ await api.delete(`/leads/${leadId}/logo`);
+ const prev = logoUrls.value[leadId];
+ if (prev) URL.revokeObjectURL(prev);
+ const next = { ...logoUrls.value };
+ delete next[leadId];
+ logoUrls.value = next;
+ if (data.value) {
+ data.value = {
+ ...data.value,
+ items: data.value.items.map((l) =>
+ l.id === leadId ? { ...l, hasLogo: false } : l,
+ ),
+ };
+ }
+ okMsg.value = 'لوگو حذف شد.';
+ message.success(okMsg.value);
+ } catch {
+ error.value = 'حذف لوگو ناموفق بود.';
+ } finally {
+ logoBusyId.value = null;
+ }
+ },
+ });
}
function patchQuery(next: Record) {
@@ -500,14 +529,6 @@ function toggleOne(id: string, checked: boolean) {
selected.value = next;
}
-function toggleAll(checked: boolean) {
- if (!checked) {
- selected.value = new Set();
- return;
- }
- selected.value = new Set(data.value?.items.map((l) => l.id) ?? []);
-}
-
const LONG_PRESS_MS = 450;
const LONG_PRESS_MOVE_PX = 12;
let cardPressTimer: ReturnType | null = null;
@@ -688,21 +709,31 @@ function openEdit(lead: LeadDto) {
createOpen.value = true;
}
-async function deleteLead(lead: LeadDto) {
+function deleteLead(lead: LeadDto) {
rowMenuId.value = null;
- if (!confirm(`لید «${lead.title}» حذف (بایگانی) شود؟`)) return;
- busy.value = true;
- error.value = '';
- try {
- await api.post('/leads/archive', { leadIds: [lead.id] });
- okMsg.value = 'لید حذف شد.';
- selected.value = new Set([...selected.value].filter((id) => id !== lead.id));
- await load();
- } catch {
- error.value = 'حذف لید ناموفق بود.';
- } finally {
- busy.value = false;
- }
+ Modal.confirm({
+ title: `لید «${lead.title}» حذف (بایگانی) شود؟`,
+ okText: 'حذف',
+ cancelText: 'انصراف',
+ okButtonProps: { danger: true },
+ async onOk() {
+ busy.value = true;
+ error.value = '';
+ try {
+ await api.post('/leads/archive', { leadIds: [lead.id] });
+ okMsg.value = 'لید حذف شد.';
+ message.success(okMsg.value);
+ selected.value = new Set(
+ [...selected.value].filter((id) => id !== lead.id),
+ );
+ await load();
+ } catch {
+ error.value = 'حذف لید ناموفق بود.';
+ } finally {
+ busy.value = false;
+ }
+ },
+ });
}
function applyContactDraft(draft: PhoneContactDraft) {
@@ -757,7 +788,8 @@ function fillFromPhoneContact() {
sourceOpen.value = true;
return;
}
- contactFileInput.value?.click();
+ sourceOpen.value = true;
+ // help/vcf path opened via nested modal upload
}
async function pickFromDevice() {
@@ -777,7 +809,6 @@ async function pickFromDevice() {
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
- contactFileInput.value?.click();
return;
}
createError.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
@@ -788,14 +819,14 @@ async function pickFromDevice() {
function pickFromVcf() {
sourceOpen.value = false;
- contactFileInput.value?.click();
}
-async function onContactVcfFile(ev: Event) {
- const el = ev.target as HTMLInputElement;
- const file = el.files?.[0];
- el.value = '';
- if (!file) return;
+const beforeContactVcfUpload: UploadProps['beforeUpload'] = (file) => {
+ void onContactVcfFile(file as File);
+ return false;
+};
+
+async function onContactVcfFile(file: File) {
pickingContact.value = true;
createError.value = '';
try {
@@ -899,21 +930,30 @@ async function bulkStageSelected() {
}
}
-async function archiveSelected() {
+function archiveSelected() {
if (!selectedIds.value.length) return;
- if (!confirm(`${toFaDigits(selectedIds.value.length)} لید بایگانی شود؟`)) return;
- busy.value = true;
- error.value = '';
- try {
- await api.post('/leads/archive', { leadIds: selectedIds.value });
- okMsg.value = `${toFaDigits(selectedIds.value.length)} لید بایگانی شد.`;
- selected.value = new Set();
- await load();
- } catch {
- error.value = 'بایگانی ناموفق بود.';
- } finally {
- busy.value = false;
- }
+ const count = selectedIds.value.length;
+ Modal.confirm({
+ title: `${toFaDigits(count)} لید بایگانی شود؟`,
+ okText: 'بایگانی',
+ cancelText: 'انصراف',
+ okButtonProps: { danger: true },
+ async onOk() {
+ busy.value = true;
+ error.value = '';
+ try {
+ await api.post('/leads/archive', { leadIds: selectedIds.value });
+ okMsg.value = `${toFaDigits(count)} لید بایگانی شد.`;
+ message.success(okMsg.value);
+ selected.value = new Set();
+ await load();
+ } catch {
+ error.value = 'بایگانی ناموفق بود.';
+ } finally {
+ busy.value = false;
+ }
+ },
+ });
}
async function exportExcel() {
@@ -1055,7 +1095,51 @@ watch(
},
);
+const tintColor = (tint: StageTint) => {
+ const map: Record = {
+ mint: 'success',
+ teal: 'cyan',
+ amber: 'warning',
+ peach: 'orange',
+ sky: 'processing',
+ violet: 'purple',
+ rose: 'error',
+ };
+ return map[tint] ?? 'default';
+};
+
+const tableColumns = computed>(() => {
+ const all: TableColumnsType = [
+ {
+ title: '',
+ key: 'select',
+ width: 48,
+ },
+ {
+ title: '#',
+ key: 'row',
+ width: 56,
+ customRender: ({ index }) => toFaDigits(rangeFrom.value + index),
+ },
+ { title: 'نام / شرکت', key: 'title', dataIndex: 'title' },
+ { title: 'شماره تماس', key: 'mobile', dataIndex: 'mobile' },
+ { title: 'شهر / منطقه', key: 'city' },
+ { title: 'منبع لید', key: 'source', dataIndex: 'source' },
+ { title: 'تاریخ ثبت', key: 'createdAt', dataIndex: 'createdAt' },
+ { title: 'وضعیت', key: 'stage', dataIndex: 'stageName' },
+ { title: 'مسئول', key: 'assignee', dataIndex: 'assigneeName' },
+ { title: 'آخرین فعالیت', key: 'activity' },
+ { title: 'اقدامات', key: 'actions', width: 140 },
+ ];
+ return all.filter((col) => {
+ const title = String(col.title ?? '');
+ if (!title || title === '#' || col.key === 'select') return true;
+ return leadCols.isVisible(title);
+ });
+});
+
onMounted(async () => {
+ registerTableColumns('leads', [...LEAD_COL_LABELS]);
await loadMeta();
await load();
const jobId = typeof route.query.jobId === 'string' ? route.query.jobId : '';
@@ -1069,553 +1153,21 @@ onBeforeUnmount(() => {
-
-
-
-
-
- خروجی اکسل
- خروجی
-
-
-
-
- ورود از اکسل
- ورود
-
-
-
-
-
-
-
- از مخاطبین گوشی
- گوشی
-
-
-
-
- لید جدید
-
-
-
-
-
-
-
-
-
-
- همه لیدها
- {{ formatCount(totalLeadsCount) }}
-
-
-
-
-
-
- {{ card.label }}
- {{ formatCount(card.count) }}
-
-
-
-
-
- {{ card.label }}
- {{ formatCount(card.count) }}
-
-
-
-
-
-
-
- فیلترها
-
-
-
-
-
-
-
- همه مناطق
- {{ c }}
-
-
- همه منابع
- {{ s }}
-
-
- همه وضعیت
- {{ s.name }}
-
+
+
+
+
+
لیدها
+
+ مدیریت و پیگیری تمام لیدهای ورودی
+
-
-
-
-
-
- {{ sortLabels[sort] }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ toFaDigits(selectedIds.length) }} مورد انتخابشده
-
-
-
- تخصیص
-
-
-
-
-
- تغییر وضعیت
-
-
-
-
-
- بایگانی
-
-
-
-
- ورود فایل: {{ toFaDigits(importJob.processedCount) }} / {{ toFaDigits(importJob.totalCount) }}
-
-
{{ okMsg }}
-
{{ error }}
-
در حال بارگذاری…
-
-
-
-
-
-
- {{ toFaDigits(rangeFrom + idx) }}
-
-
(logoMenuId = v ? `m:${lead.id}` : null)"
- >
-
-
-
-
-
-
-
- {{ lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو' }}
-
-
- حذف لوگو
-
-
-
- {{ lead.title }}
- {{ lead.contactName }}
-
-
(rowMenuId = v ? `m:${lead.id}` : null)"
- >
-
-
-
-
-
- مشاهده
- ویرایش
- تماس
-
- حذف
-
-
-
-
-
-
-
-
-
-
- {{ lead.stageName }}
-
- {{ lead.source }}
-
-
-
-
-
-
-
-
-
-
-
-
-
ثبت لید جدید
+
+
+
+ خروجی اکسل
+
{
@fail="onExcelImportFail"
@settled="onExcelImportSettled"
>
-
- ورود از فایل
+
+
+
+ ورود از اکسل
+
-
-
-
+
+
+
+ از مخاطبین گوشی
+
+
+
+
+ لید جدید
+
+
+
+
-
-
-
- نام / شرکت
-
-
-
- نام مخاطب
-
-
-
-
- موبایل
-
-
- {{ pickingContact ? '…' : 'از مخاطبین' }}
-
-
-
-
-
-
-
- شهر
-
-
-
- منطقه
-
-
-
-
- منبع
-
-
-
- پیج اینستاگرام
-
-
-
-
- آدرسها
- + افزودن آدرس
-
-
-
+
+
+
+
+
+
+
+
+
همه لیدها
+
+
{{ formatCount(totalLeadsCount) }}
+
+
+
+
+
+
+
+ {{ card.label }}
+
+
{{ formatCount(card.count) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ فیلترها
+
+ setSort(String(v ?? 'updated') as SortKey)"
/>
-
- ×
-
-
-
-
-
- وضعیت
-
- {{ s.name }}
-
-
-
- مسئول
-
- بدون مسئول
- {{ u.fullName }}
-
-
-
- {{ createError }}
-
+
+
+ setCityFilter(v == null ? '' : String(v))"
+ />
+ setSourceFilter(v == null ? '' : String(v))"
+ />
+ setStageFilter(v == null ? '' : String(v))"
+ />
+
+
+
+ { if (e?.target?.value === 'board') void router.push({ name: 'leads-board' }) }"
+ >
+
+
+
+
+
+
+
+
+
+
+
-
-
- چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
-
-
-
-
- از دفترچه تلفن
-
-
- از فایل .vcf
-
-
-
- انصراف
+
+
+
+
+ {{ toFaDigits(selectedIds.length) }} مورد انتخابشده
+
+
+
+ اعمال تخصیص
+
+ اعمال وضعیت
+
+ بایگانی
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ toFaDigits(rangeFrom + idx) }}
+
+
+ {{ initials(lead.title) }}
+
+
+
+
+
+ {{ lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو' }}
+
+
+
+ حذف لوگو
+
+
+
+
+
+
{{ lead.title }}
+
+
{{ lead.contactName }}
+
+
+
+
+
+
+
+
+
+ مشاهده
+ ویرایش
+
+ تماس
+
+ حذف
+
+
+
+
+
+
+
+ {{ formatPhone(lead.mobile) }}
+
+
+ {{ [lead.city, lead.region].filter(Boolean).join(' · ') }}
+
+
+
+
+ {{ initials(lead.assigneeName) }}
+ {{ lead.assigneeName }}
+
+ بدون مسئول
+ {{ lastActivity(lead).when }}
+
+
+ {{ lead.stageName }}
+ {{ lead.source }}
+
+
+
+
+
+
+
+
+ toggleOne((record as LeadDto).id, !!e?.target?.checked)"
+ />
+
+
+ {{ (record as LeadDto).title }}
+
+
{{ (record as LeadDto).contactName }}
+
+
+
+ {{ formatPhone((record as LeadDto).mobile) }}
+
+
+ {{ (record as LeadDto).city || '—' }}
+
+
{{ (record as LeadDto).region }}
+
+
+
+ {{ (record as LeadDto).source }}
+ —
+
+
+ {{ formatJalaliNumeric(new Date((record as LeadDto).createdAt)) }}
+
+
+
+ {{ (record as LeadDto).stageName }}
+
+
+
+
+ {{ initials((record as LeadDto).assigneeName) }}
+ {{ (record as LeadDto).assigneeName }}
+
+ —
+
+
+
+
{{ lastActivity(record as LeadDto).label }}
+
+
+ {{ lastActivity(record as LeadDto).when }}
+
+
+
+
+
+
+
+
+
+
+ مشاهده
+ ویرایش
+
+ تماس
+
+ حذف
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setPage(p)"
+ />
+
+ نمایش {{ toFaDigits(rangeFrom) }} تا {{ toFaDigits(rangeTo) }} از
+ {{ formatCount(total) }} لید
+
+
-
-
-
+
+
+ ثبت لید جدید
+
+
+ ورود از فایل
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ pickingContact ? '…' : 'از مخاطبین' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + افزودن آدرس
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
+
+
+
+
+ از دفترچه تلفن
+
+
+ از فایل .vcf
+
+
+
+
+
+
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتبشده الفبایی).
یکی را انتخاب کنید تا موبایل (و در صورت خالی بودن، نام) پر شود.
-
-
-
-
-
-
-
-
- {{ row.fullName.slice(0, 1) }}
-
- {{ row.fullName }}
- {{ draftLine(row) }}
-
-
-
-
- مخاطبی با این جستجو پیدا نشد.
-
- انصراف
- فایل دیگر
-
-
+
+
+
+
+
+
+
+ {{ row.fullName }}
+ {{ draftLine(row) }}
+
+
+
+ مخاطبی با این جستجو پیدا نشد.
+
+
+
+ انصراف
+
+ فایل دیگر
+
+
+
-
- انصراف
-
-
+ انصراف
+
{{
- createBusy
- ? editingLeadId
- ? 'در حال ذخیره…'
- : 'در حال ثبت…'
- : editingLeadId
- ? 'ذخیره تغییرات'
- : 'ثبت لید'
+ editingLeadId
+ ? 'ذخیره تغییرات'
+ : 'ثبت لید'
}}
-
+
-
-
+
+
-
-
diff --git a/apps/web/src/views/PersonFormView.vue b/apps/web/src/views/PersonFormView.vue
index 793b131..3f10955 100644
--- a/apps/web/src/views/PersonFormView.vue
+++ b/apps/web/src/views/PersonFormView.vue
@@ -2,6 +2,37 @@
import { computed, nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
+import {
+ Alert as AAlert,
+ Button as AButton,
+ Card as ACard,
+ Col as ACol,
+ Divider as ADivider,
+ Flex as AFlex,
+ Form as AForm,
+ FormItem as AFormItem,
+ Input as AInput,
+ List as AList,
+ Modal,
+ Modal as AModal,
+ Row as ARow,
+ Select as ASelect,
+ Space,
+ Space as ASpace,
+ Spin as ASpin,
+ Textarea as ATextarea,
+ Typography,
+ Upload as AUpload,
+} from 'ant-design-vue';
+import type { UploadProps } from 'ant-design-vue';
+import {
+ ArrowLeftOutlined,
+ CheckOutlined,
+ DeleteOutlined,
+ PhoneOutlined,
+ PlusOutlined,
+ SearchOutlined,
+} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
@@ -19,17 +50,12 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
-import UiIcon from '@/components/UiIcon.vue';
-import {
- UiButton,
- UiCard,
- UiField,
- UiInput,
- UiModal,
- UiPageHeader,
- UiSelect,
- UiTextarea,
-} from '@/components/ui';
+
+const ASpaceCompact = Space.Compact;
+const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
+ Typography;
+const AListItem = AList.Item;
+const AListItemMeta = AList.Item.Meta;
const props = withDefaults(
defineProps<{
@@ -77,16 +103,21 @@ const loyaltyTierName = ref('');
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
-const contactFileInput = ref
(null);
const sourceOpen = ref(false);
const pickOpen = ref(false);
const pickDrafts = ref([]);
const pickQuery = ref('');
+const vcfInputKey = ref(0);
const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value),
);
+const companyOptions = computed(() => [
+ { value: '', label: 'بدون شرکت' },
+ ...companies.value.map((c) => ({ value: c.id, label: c.name })),
+]);
+
const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
);
@@ -147,9 +178,38 @@ function fillFromPhoneContact() {
sourceOpen.value = true;
return;
}
- contactFileInput.value?.click();
+ vcfInputKey.value += 1;
+ void nextTick(() => triggerVcfPick());
}
+const vcfUploadRef = ref<{ $el?: HTMLElement } | null>(null);
+
+function triggerVcfPick() {
+ const root = vcfUploadRef.value?.$el as HTMLElement | undefined;
+ const input = root?.querySelector('input[type="file"]') as HTMLInputElement | null;
+ input?.click();
+}
+
+const onVcfBeforeUpload: UploadProps['beforeUpload'] = (file) => {
+ void (async () => {
+ pickingContact.value = true;
+ error.value = '';
+ try {
+ const rows = await parseVCardFile(file as File);
+ if (!rows.length) {
+ error.value = 'مخاطبی در فایل پیدا نشد.';
+ return;
+ }
+ await openPickList(rows);
+ } catch {
+ error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
+ } finally {
+ pickingContact.value = false;
+ }
+ })();
+ return false;
+};
+
async function pickFromDevice() {
if (pickingContact.value || saving.value) return;
pickingContact.value = true;
@@ -167,7 +227,7 @@ async function pickFromDevice() {
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
- contactFileInput.value?.click();
+ triggerVcfPick();
return;
}
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
@@ -178,28 +238,7 @@ async function pickFromDevice() {
function pickFromVcf() {
sourceOpen.value = false;
- contactFileInput.value?.click();
-}
-
-async function onContactVcfFile(ev: Event) {
- const el = ev.target as HTMLInputElement;
- const file = el.files?.[0];
- el.value = '';
- if (!file) return;
- pickingContact.value = true;
- error.value = '';
- try {
- const rows = await parseVCardFile(file);
- if (!rows.length) {
- error.value = 'مخاطبی در فایل پیدا نشد.';
- return;
- }
- await openPickList(rows);
- } catch {
- error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
- } finally {
- pickingContact.value = false;
- }
+ triggerVcfPick();
}
function fill(p: PersonDto) {
@@ -334,14 +373,24 @@ async function submit() {
}
}
-async function remove() {
- if (!id.value || !confirm('این شخص حذف شود؟')) return;
- try {
- await api.delete(`/people/${id.value}`);
- await router.push({ name: 'contacts' });
- } catch {
- error.value = 'حذف ناموفق بود';
- }
+function remove() {
+ if (!id.value) return;
+ Modal.confirm({
+ title: 'حذف شخص',
+ content: 'این شخص حذف شود؟',
+ okText: 'حذف',
+ cancelText: 'انصراف',
+ okButtonProps: { danger: true },
+ async onOk() {
+ try {
+ await api.delete(`/people/${id.value}`);
+ await router.push({ name: 'contacts' });
+ } catch {
+ error.value = 'حذف ناموفق بود';
+ return Promise.reject();
+ }
+ },
+ });
}
function cancel() {
@@ -356,498 +405,299 @@ onMounted(load);
-
-
-
+
-
-
+
+
مخاطبین
- ›
- {{ pageTitle }}
-
-
-
-
-
- {{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
-
- انصراف
-
+ · {{ pageTitle }}
+
+
{{ pageTitle }}
+
+
+
+
+ انصراف
+
+
+
حذف
-
-
-
+
+
+
+ ذخیره
+
+
+
- در حال بارگذاری…
-
-
-
-
-
diff --git a/apps/web/src/views/PipelineBoardView.vue b/apps/web/src/views/PipelineBoardView.vue
index fea9e31..328748e 100644
--- a/apps/web/src/views/PipelineBoardView.vue
+++ b/apps/web/src/views/PipelineBoardView.vue
@@ -17,12 +17,28 @@ import {
updateDealStage,
} from '@/lib/pipeline-board';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
-import UiBusyOverlay from '@/components/ui/UiBusyOverlay.vue';
-import UiButton from '@/components/ui/UiButton.vue';
-import UiInput from '@/components/ui/UiInput.vue';
-import UiModal from '@/components/ui/UiModal.vue';
import DealFormView from '@/views/DealFormView.vue';
import SavedFiltersBar from '@/components/SavedFiltersBar.vue';
+import {
+ DeleteOutlined,
+ DownloadOutlined,
+ UploadOutlined,
+} from '@ant-design/icons-vue';
+import {
+ Alert as AAlert,
+ Button as AButton,
+ Flex as AFlex,
+ Form as AForm,
+ FormItem as AFormItem,
+ Input as AInput,
+ Menu as AMenu,
+ MenuItem as AMenuItem,
+ Modal,
+ Modal as AModal,
+ Space as ASpace,
+ Spin as ASpin,
+ theme,
+} from 'ant-design-vue';
import type { ImportContactsResult, PipelineBoardDto, PipelineDto } from '@mento/shared';
import {
DEAL_EXCEL_ALIASES,
@@ -30,6 +46,9 @@ import {
EXCEL_CUSTOM_FIELD_PREFIX,
brand,
} from '@mento/shared';
+import { useAuthStore } from '@/stores/auth';
+import { canManageSettings } from '@/lib/roles';
+import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
const dealExcelFields = DEAL_EXCEL_COLUMNS.filter((c) => c.key !== '_type').map((c) => ({
key: c.key,
@@ -40,9 +59,24 @@ const dealClientReadOptions = {
rawValues: true,
customFieldPrefix: EXCEL_CUSTOM_FIELD_PREFIX,
};
-import { useAuthStore } from '@/stores/auth';
-import { canManageSettings } from '@/lib/roles';
-import { bindMenuDismiss, cardMenuStyle } from '@/lib/anchored-menu';
+
+const { token } = theme.useToken();
+const GAP = 16;
+
+/** پدینگ بیرونی فقط از `.content` شل = ۱۶px */
+const pageStyle = {
+ boxSizing: 'border-box' as const,
+ display: 'flex',
+ flexDirection: 'column' as const,
+ gap: `${GAP}px`,
+ flex: '1 1 auto',
+ minHeight: 0,
+ minWidth: 0,
+ width: '100%',
+ height: '100%',
+ padding: 0,
+ margin: 0,
+};
const route = useRoute();
const router = useRouter();
@@ -62,7 +96,6 @@ const amountMax = ref('');
const showCreate = ref(false);
const newName = ref('');
const dealModal = ref<{ dealId?: string; stageId?: string } | null>(null);
-const deleteStageId = ref(null);
const stageBusy = ref(false);
const busy = ref(false);
const importMsg = ref('');
@@ -383,59 +416,47 @@ async function onAddStage(name: string) {
}
function onDeleteStage(stageId: string) {
- deleteStageId.value = stageId;
+ const title = stages.value.find((s) => s.id === stageId)?.title ?? '';
+ Modal.confirm({
+ title: 'حذف ستون',
+ content: `ستون «${title}» حذف شود؟ اگر معاملهای داخل آن باشد باید اول آنها را جابهجا کنید.`,
+ okText: 'حذف',
+ cancelText: 'انصراف',
+ okButtonProps: { danger: true },
+ async onOk() {
+ if (!boardPipelineId.value || stageBusy.value) return;
+ stageBusy.value = true;
+ error.value = '';
+ try {
+ await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
+ await load();
+ } catch (e) {
+ error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
+ throw e;
+ } finally {
+ stageBusy.value = false;
+ }
+ },
+ });
}
-const deleteStageTitle = computed(() => {
- const id = deleteStageId.value;
- return stages.value.find((s) => s.id === id)?.title ?? '';
-});
-
-async function confirmDeleteStage() {
- const stageId = deleteStageId.value;
- if (!stageId || !boardPipelineId.value || stageBusy.value) return;
- stageBusy.value = true;
- error.value = '';
- try {
- await api.delete(`/pipelines/${boardPipelineId.value}/stages/${stageId}`);
- deleteStageId.value = null;
- await load();
- } catch (e) {
- error.value = apiMessage(e, 'حذف ستون ناموفق بود.');
- } finally {
- stageBusy.value = false;
- }
-}
-
-const deleteDealIds = ref([]);
const dealDeleting = ref(false);
const selectMode = ref(false);
const selectedIds = ref>(new Set());
const selectedCount = computed(() => selectedIds.value.size);
-const deleteDealTitle = computed(() => {
- const ids = deleteDealIds.value;
- if (ids.length !== 1) return '';
+function dealTitleById(dealId: string) {
for (const s of stages.value) {
- const d = s.deals.find((x) => x.id === ids[0]);
+ const d = s.deals.find((x) => x.id === dealId);
if (d) return d.title;
}
return '';
-});
-
-const deleteConfirmCopy = computed(() => {
- const n = deleteDealIds.value.length;
- if (n <= 0) return '';
- if (n === 1) {
- return `معاملهٔ «${deleteDealTitle.value}» حذف شود؟ این کار قابل بازگشت نیست.`;
- }
- return `${toFaDigits(n)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
-});
+}
function askDeleteDeal(dealId: string) {
menuOpenId.value = null;
- deleteDealIds.value = [dealId];
+ confirmDeleteDeals([dealId]);
}
function startSelect(dealId: string) {
@@ -460,31 +481,43 @@ function clearSelect() {
function askBulkDelete() {
if (!selectedIds.value.size) return;
menuOpenId.value = null;
- deleteDealIds.value = [...selectedIds.value];
+ confirmDeleteDeals([...selectedIds.value]);
}
-async function confirmDeleteDeal() {
- const ids = deleteDealIds.value;
+function confirmDeleteDeals(ids: string[]) {
if (!ids.length || dealDeleting.value) return;
- dealDeleting.value = true;
- error.value = '';
- try {
- const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
- const failed = results.filter((r) => r.status === 'rejected').length;
- deleteDealIds.value = [];
- clearSelect();
- await load();
- if (failed) {
- error.value =
- failed === ids.length
- ? 'حذف معاملهها ناموفق بود.'
- : `${toFaDigits(failed)} مورد حذف نشد.`;
- }
- } catch (e) {
- error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
- } finally {
- dealDeleting.value = false;
- }
+ const copy =
+ ids.length === 1
+ ? `معاملهٔ «${dealTitleById(ids[0]!)}» حذف شود؟ این کار قابل بازگشت نیست.`
+ : `${toFaDigits(ids.length)} معامله حذف شود؟ این کار قابل بازگشت نیست.`;
+ Modal.confirm({
+ title: ids.length > 1 ? 'حذف معاملهها' : 'حذف معامله',
+ content: copy,
+ okText: 'حذف',
+ cancelText: 'انصراف',
+ okButtonProps: { danger: true },
+ async onOk() {
+ dealDeleting.value = true;
+ error.value = '';
+ try {
+ const results = await Promise.allSettled(ids.map((id) => api.delete(`/deals/${id}`)));
+ const failed = results.filter((r) => r.status === 'rejected').length;
+ clearSelect();
+ await load();
+ if (failed) {
+ error.value =
+ failed === ids.length
+ ? 'حذف معاملهها ناموفق بود.'
+ : `${toFaDigits(failed)} مورد حذف نشد.`;
+ }
+ } catch (e) {
+ error.value = apiMessage(e, 'حذف معامله ناموفق بود.');
+ throw e;
+ } finally {
+ dealDeleting.value = false;
+ }
+ },
+ });
}
async function onDealSaved() {
@@ -593,6 +626,19 @@ function onMenuDeal(dealId: string) {
});
}
+function onDealMenuClick({ key }: { key: string | number }) {
+ const id = menuOpenId.value;
+ if (!id) return;
+ if (key === 'select') startSelect(id);
+ else if (key === 'view') onOpenDeal(id);
+ else if (key === 'edit') {
+ dealModal.value = { dealId: id };
+ menuOpenId.value = null;
+ } else if (key === 'promote') void promotePriorDeal(id);
+ else if (key === 'delete') askDeleteDeal(id);
+ else menuOpenId.value = null;
+}
+
const unbindMenuDismiss = bindMenuDismiss(
() => menuOpenId.value != null,
() => (menuOpenId.value = null),
@@ -627,102 +673,141 @@ defineExpose({ updateDealStage: applyUpdateDealStage });
-
-
-
-
-
- خروجی اکسل
-
-
-
- ورود از اکسل
-
-
-
-
-
-
-
- {{ error }}
- {{ importMsg }}
- در حال بارگذاری مسیر فروش…
-
-
-
{{ toFaDigits(selectedCount) }} معامله انتخاب شده
-
+
+
- حذف انتخابشدهها
-
-
انصراف از انتخاب
+
+
+
+ خروجی اکسل
+
+
+
+
+ ورود از اکسل
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ حذف انتخابشدهها
+
+ انصراف از انتخاب
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- نام مسیر فروش
-
+
+
-
-
-
-
- {{ saving ? 'در حال ساخت…' : 'ساخت' }}
-
-
- انصراف
-
-
-
+
+
+
-
-
- ستون «{{ deleteStageTitle }}» حذف شود؟ اگر معاملهای داخل آن باشد باید
- اول آنها را جابهجا کنید.
-
-
-
- {{ stageBusy ? 'در حال حذف…' : 'حذف' }}
-
-
- انصراف
-
-
-
-
-
- {{ deleteConfirmCopy }}
-
-
- {{ dealDeleting ? 'در حال حذف…' : 'حذف' }}
-
-
- انصراف
-
-
-
-
-
-
-
+
+
diff --git a/docs/وضعیت-دیزاین-سیستم.md b/docs/وضعیت-دیزاین-سیستم.md
new file mode 100644
index 0000000..1dfe878
--- /dev/null
+++ b/docs/وضعیت-دیزاین-سیستم.md
@@ -0,0 +1,233 @@
+# وضعیت دیزاینسیستم منتکت (Montact CRM)
+
+سند audit وضعیت واقعی لایهٔ UI در `apps/web` — نه آرزو، نه Figma.
+آخرین بهروزرسانی: ۲۶ مهر ۱۴۰۵
+
+**راهنما:**
+- **آماده** = در کد وجود دارد و بهطور منسجم قابل اتکا است
+- **نیمهآماده** = وجود دارد ولی پوشش/انضباط ناقص است
+- **نیست** = در کدبیس پیاده نشده
+
+---
+
+## خلاصهٔ اجرایی
+
+دیزاینسیستم منتکت **نیمهآماده / در حال رشد** است: توکنهای CSS، کلاسهای الگوی `crm-*`، و یک کیت داخلی ۱۸تایی `Ui*` وجود دارد؛ اما API دوگانهٔ لگسی (`.btn` / `.field`)، hardcode گستردهٔ رنگ و فاصله در scoped CSS، نبود Storybook، و نبود چند primitive پرکاربرد مانع «سیستم واقعی» است.
+
+| معیار | مقدار |
+|---|---|
+| امتیاز کلی آمادگی | **حدود ۵۰٪** (بازهٔ واقعبینانه ۴۵–۵۵٪) |
+| استک UI | Vue 3 SFC + Composition API — بدون Tailwind / UI kit شخصثالث |
+| primitives `Ui*` | ۱۸ کامپوننت + barrel |
+| adoption مستقیم در views | **۵۲ از ۷۶** (~۶۸٪) |
+| تم تاریک / چندتم | نیست |
+| کاتالوگ زنده (Storybook و مشابه) | نیست |
+
+**حکم یکخطی:** زبان بصری teal + RTL برای CRM شکل گرفته؛ انضباط و پوشش کامپوننت هنوز به سطح design system کامل نرسیده.
+
+---
+
+## استک و مرز سیستم
+
+```
+tokens (main.css :root)
+ ↓
+utilities / patterns (tokens.css → .crm-*)
+ ↓
+primitives (components/ui/Ui*)
+ ↓
+domain composites (pipeline/, gamification/, nav/, …)
+ ↓
+views + layouts
+```
+
+| لایه | مسیر | نقش |
+|---|---|---|
+| تعریف توکنها | [`apps/web/src/styles/main.css`](../apps/web/src/styles/main.css) | `:root` — رنگ، تایپو، فاصله، سایه، z-index، کنترلها؛ همچنین استایل لگسی `.btn` / `.field` |
+| الگوهای CRM | [`apps/web/src/styles/tokens.css`](../apps/web/src/styles/tokens.css) | utilityهای `.crm-page`, `.crm-card`, `.crm-toolbar`, … (نام فایل «tokens» است؛ تعریف توکن اینجا نیست) |
+| فونت | [`apps/web/src/styles/fonts.css`](../apps/web/src/styles/fonts.css) | IRANSans FaNum خودمیزبان |
+| کیت UI | [`apps/web/src/components/ui/`](../apps/web/src/components/ui/) | ۱۸ SFC + [`index.ts`](../apps/web/src/components/ui/index.ts) |
+| آیکن | [`apps/web/src/components/UiIcon.vue`](../apps/web/src/components/UiIcon.vue) | بیرون از barrel `ui/` |
+| شِل اپ | [`layouts/AppLayout.vue`](../apps/web/src/layouts/AppLayout.vue)، [`layouts/AdmLayout.vue`](../apps/web/src/layouts/AdmLayout.vue) | سایدبار + workspace / ادمین پلتفرم |
+
+**وابستگیهای UI در `package.json` وب:** فقط Vue، Vue Router، Pinia، Axios، ECharts / vue-echarts، xlsx. هیچ Element Plus / Vuetify / Naive / Headless / Tailwind نیست.
+
+---
+
+## ماتریس آمادگی
+
+| لایه | وضعیت | توضیح کوتاه |
+|---|---|---|
+| توکن رنگ / تایپو / فاصله / سایه | **آماده** | پالت teal روی slate؛ scale فونت و spacing و shadow تعریف شده |
+| مقیاس radius | **نیمهآماده** | همهٔ `--radius-*` (جز full) روی ۱۰px قفلاند — سلسلهمراتب واقعی نیست |
+| spacing کامل | **نیمهآماده** | `--sp-1`…`6` و `8` و `10`؛ `--sp-7` / `--sp-9` نیست |
+| الگوی صفحه `crm-*` | **نیمهآماده** | مجموعهٔ خوب برای page/header/card/table/form؛ adoption ناقص |
+| primitives `Ui*` | **نیمهآماده** | ۱۸ قطعهٔ کاربردی؛ چند کنترل پایهٔ فرم/فیدبک غایب |
+| آیکن `UiIcon` | **آماده** | ~۷۰ آیکن stroke، `currentColor`، بدون پکیج خارجی |
+| Layout اپ / ادمین | **آماده** | شِل دسکتاپ + موبایل tabbar؛ ادم جدا |
+| RTL + IRANSans + جلالی | **آماده** | `dir="rtl"`، فونت فارسی، `lib/jalali.ts`، ارقام فارسی |
+| Dark mode / multi-theme | **نیست** | فقط تم روشن |
+| Storybook / Histoire / playground | **نیست** | اسکریپت lint وب هم placeholder است |
+| Checkbox / Switch / Tabs / Toast / Tooltip / Table / DatePicker بهعنوان `Ui*` | **نیست** | معادلها اغلب ad-hoc یا دامنهٔ خاص (`JalaliDateInput` و …) |
+| vue-i18n | **نیست** | متنها فارسی hardcode در template |
+| lint / اجبار توکن (منع hex خام) | **نیست** | رنگ و rem زیاد در scoped CSS تکرار شده |
+
+---
+
+## توکنها (آنچه واقعاً هست)
+
+تعریف در `:root` داخل `main.css`:
+
+| دسته | نمونه |
+|---|---|
+| سطح | `--bg`, `--surface`, `--surface-subtle`, `--surface-hover` |
+| متن / خط | `--ink`, `--ink-secondary`, `--muted`, `--line`, … |
+| accent | `--accent` (#14b8a6) و hover/deep/soft/subtle/contrast |
+| معنایی | `--danger`, `--success`, `--warning`, `--info` (+ soft/deep) |
+| تایپو | `--fs-xs`…`--fs-3xl`, `--fw-*`, `--lh-*`, `--font` |
+| فاصله | `--sp-1`…`--sp-6`, `--sp-8`, `--sp-10` |
+| کنترل | `--control-h-sm/h/md/lg`, `--pad-modal` |
+| سایه / حرکت / z | `--shadow-*`, `--transition-*`, `--z-dropdown`…`--z-toast` |
+| alias لگسی | `--color-primary`, `--color-text`, `--color-border`, … |
+
+**ضعفها:** radius تخت؛ `--fw-extrabold` برابر bold؛ توکنهای محلی در `AppLayout` (مثل `--sidebar-card`) بیرون از `:root` سراسری؛ `CHART_PALETTE` در `UiChart` تا حدی جدا از توکنها.
+
+---
+
+## کاتالوگ کامپوننتهای `Ui*`
+
+منبع: barrel [`components/ui/index.ts`](../apps/web/src/components/ui/index.ts).
+
+| کامپوننت | نقش |
+|---|---|
+| `UiButton` | دکمه — variant / size / loading / block |
+| `UiInput` | ورودی متن — `defineModel`، prefix/suffix |
+| `UiTextarea` | متن چندخطی |
+| `UiSelect` | select بومی با slot برای `` |
+| `UiField` | برچسب + hint + error دور کنترل |
+| `UiCard` | کارت با header / actions / footer |
+| `UiModal` | مودال + قفل اسکرول body |
+| `UiDrawer` | کشو از کنار |
+| `UiBusyOverlay` | پوشش busy / progress |
+| `UiPageHeader` | عنوان صفحه + lead + actions + back |
+| `UiBadge` | نشان وضعیت |
+| `UiPagination` | صفحهبندی + page size |
+| `UiEmptyState` | حالت خالی |
+| `UiSearch` | جستجوی ساده |
+| `UiFilterBar` | نوار جستجو + پنل فیلتر |
+| `UiSegmented` | کنترل segmented |
+| `UiChart` | ECharts + `CHART_PALETTE` |
+| `UiDropdownMenu` | منوی کشویی با trigger slot |
+
+**مرتبط ولی خارج از barrel:** `UiIcon.vue`.
+
+**domain composites (دیزاینسیستم نیستند):** پوشههایی مثل `pipeline/`, `gamification/`, `dashboard/`, `routines/`, `nav/`, `person-profile/` — ترکیب ویژگیمحور روی primitives یا استایل محلی.
+
+---
+
+## پترنهای UI واقعی در پروژه
+
+### ۱) الگوی ترجیحی (هدف فعلی)
+
+```ts
+import { UiButton, UiModal, UiPageHeader } from '@/components/ui';
+```
+
+صفحه با `.crm-page` + `UiPageHeader` / `UiFilterBar` / `UiCard` / جدول `.crm-table*`.
+
+### ۲) الگوی موازی / لگسی
+
+- کلاسهای سراسری `.btn`, `.btn-ghost`, `.field` در `main.css`
+- `` / ` ` خام + scoped CSS در viewهای قدیمیتر یا خاص (Login، Notes، adm، …)
+- import مستقیم فایل بهجای barrel در بعضی viewها (`import UiButton from '@/components/ui/UiButton.vue'`)
+
+### ۳) الگوی غیرمستقیم
+
+بعضی viewها خودشان `ui/` را import نمیکنند ولی childها میکنند (مثلاً `HomeView` → `components/dashboard/*` → `UiChart` / `UiButton`).
+
+### لایهبندی مفهومی
+
+```mermaid
+flowchart TB
+ tokens[CSS_tokens_main_css]
+ utils[crm_utilities_tokens_css]
+ ui[Ui_primitives]
+ domain[Domain_composites]
+ views[Views_and_Layouts]
+ legacy[Legacy_btn_field]
+ tokens --> utils
+ tokens --> ui
+ utils --> views
+ ui --> domain
+ ui --> views
+ domain --> views
+ legacy --> views
+```
+
+---
+
+## Adoption کمی (views)
+
+| متریک | عدد |
+|---|---:|
+| کل viewهای `apps/web/src/views` | ۷۶ |
+| view با import مستقیم از `components/ui` | ۵۲ (~۶۸٪) |
+| view بدون import مستقیم | ۲۴ (~۳۲٪) |
+
+### بدون import مستقیم `ui/` (ممکن است از child یا لگسی استفاده کنند)
+
+- `ActivitiesCalendarView`, `ActivityDetailView`
+- `adm/AdmAdminsView`, `AdmBillingPanel`, `AdmDashboardView`, `AdmLoginView`, `AdmOrganizationsView`, `AdmUsersView`
+- `AttachmentsView`, `ConversationsView`
+- `gamification/GamificationView`, `GamificationAdminView`
+- `HomeView`, `InvoiceFormView`, `LeadFormPublicView`
+- `LoginView`, `NotesView`, `PersonProfileView`, `PortalView`, `ProfileView`
+- `ProjectCardFormView`, `QuoteFormView`, `QuotesView`, `RegisterView`
+
+**نکتهٔ کیفیت:** حتی viewهایی که `Ui*` دارند اغلب هنوز hex/`rem` محلی در `