feat(web): migrate CRM screens to Ant Design Vue with central theme

Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-18 19:39:59 +03:30
co-authored by Cursor
parent ab538b8304
commit 4abab55a6b
40 changed files with 7529 additions and 11795 deletions
+96 -129
View File
@@ -13,7 +13,20 @@ import {
readMappedRowsClient,
type ClientExcelReadOptions,
} from '@/lib/excel-client';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
import {
Col as ACol,
Flex as AFlex,
Modal as AModal,
Progress as AProgress,
Row as ARow,
Select as ASelect,
Spin as ASpin,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = defineProps<{
previewPath: string;
@@ -48,7 +61,6 @@ const emit = defineEmits<{
settled: [];
}>();
const input = ref<HTMLInputElement | null>(null);
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
const mapping = reactive<Record<string, string>>({});
@@ -77,11 +89,6 @@ const busyProgress = computed(() =>
phase.value === 'import' ? progressPct.value : null,
);
function pick() {
if (props.disabled || submitting.value) return;
input.value?.click();
}
function reset() {
pending.value = null;
preview.value = null;
@@ -367,11 +374,13 @@ function apiMessage(e: unknown, fallback: string) {
return `${fallback} (کد ${status})`;
}
async function onFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
void handleExcelFile(file as File);
return false;
};
async function handleExcelFile(file: File) {
if (props.disabled || submitting.value) return;
emit('start');
submitting.value = true;
phase.value = 'preview';
@@ -413,7 +422,6 @@ async function onFile(ev: Event) {
}
phase.value = 'idle';
submitting.value = false;
// Allow parent toolbar while user maps columns.
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
@@ -443,126 +451,85 @@ async function confirmMapping() {
</script>
<template>
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
<a-upload
:show-upload-list="false"
:disabled="disabled || submitting"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
:before-upload="beforeUpload"
@click.stop
>
<slot :loading="submitting" :disabled="disabled || submitting" />
<input
ref="input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
hidden
:disabled="disabled || submitting"
@change="onFile"
@click.stop
/>
</span>
</a-upload>
<UiBusyOverlay
<a-modal
:open="busyOpen"
:message="busyMessage"
:progress="busyProgress"
nest
/>
:footer="null"
:closable="false"
:mask-closable="false"
:keyboard="false"
centered
>
<a-flex vertical align="center" :gap="16">
<a-spin />
<a-typography-text>{{ busyMessage }}</a-typography-text>
<a-progress
v-if="busyProgress != null"
:percent="busyProgress"
style="width: 100%"
/>
</a-flex>
</a-modal>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
<a-modal
:open="!!preview"
title="تطبیق ستون‌های اکسل"
:confirm-loading="submitting"
ok-text="ورود فایل"
cancel-text="انصراف"
:ok-button-props="{ disabled: !canImport }"
@ok="confirmMapping"
@cancel="closeMapper"
>
<a-typography-paragraph type="secondary">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</p>
<p v-if="preview?.largeFile" class="large-hint">
</a-typography-paragraph>
<a-typography-paragraph v-if="preview?.largeFile" type="secondary">
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود.
</p>
<div class="rows">
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
<div class="source">
<strong>{{ col.header }}</strong>
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
</div>
<UiSelect
:model-value="mapping[col.header] ?? ''"
:disabled="submitting"
@update:model-value="setColumnField(col.header, $event)"
>
<option value="">نادیده بگیر</option>
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
{{ field.label }}{{ field.required ? ' *' : '' }}
</option>
</UiSelect>
</div>
</div>
<p v-if="!canImport" class="hint">فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.</p>
<template #footer>
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
<UiButton :disabled="!canImport" :loading="submitting" @click="confirmMapping">
ورود فایل
</UiButton>
</template>
</UiModal>
</a-typography-paragraph>
<a-flex vertical :gap="12">
<a-row
v-for="col in preview?.columns ?? []"
:key="col.header"
:gutter="[16, 8]"
align="middle"
>
<a-col :xs="24" :md="12">
<a-typography-text strong>{{ col.header }}</a-typography-text>
<div v-if="col.samples.length">
<a-typography-text type="secondary">
نمونه: {{ col.samples.join('، ') }}
</a-typography-text>
</div>
</a-col>
<a-col :xs="24" :md="12">
<a-select
:value="mapping[col.header] ?? ''"
:disabled="submitting"
style="width: 100%"
:options="[
{ value: '', label: 'نادیده بگیر' },
...(preview?.fields ?? []).map((field) => ({
value: field.key,
label: field.required ? `${field.label} *` : field.label,
})),
]"
@update:value="(v) => setColumnField(col.header, String(v ?? ''))"
/>
</a-col>
</a-row>
</a-flex>
<a-typography-text v-if="!canImport" type="danger">
فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.
</a-typography-text>
</a-modal>
</template>
<style scoped>
.excel-import {
display: inline-flex;
}
.excel-import.isBusy {
pointer-events: none;
opacity: 0.7;
}
.lead {
margin: 0 0 1rem;
color: var(--muted);
line-height: 1.7;
}
.large-hint {
margin: -0.35rem 0 1rem;
padding: 0.55rem 0.75rem;
border-radius: 10px;
background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent);
color: var(--fg, inherit);
font-size: 0.88rem;
line-height: 1.6;
}
.rows {
display: grid;
gap: 0.75rem;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 0.75rem;
align-items: center;
}
.source {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.source strong {
font-size: 0.95rem;
}
.source small {
color: var(--muted);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.85rem;
}
@media (max-width: 700px) {
.row {
grid-template-columns: 1fr;
}
}
</style>