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
+24 -326
View File
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { matchesSearch } from '@/lib/search';
import { computed } from 'vue';
import { Select as ASelect } from 'ant-design-vue';
type PickUser = { id: string; fullName: string };
@@ -21,335 +19,35 @@ const props = withDefaults(
const emit = defineEmits<{
'update:modelValue': [ids: string[]];
/** پس از بستن لیست یا حذف یک نفر، اگر انتخاب تغییر کرده باشد */
change: [ids: string[]];
}>();
const open = ref(false);
const query = ref('');
const root = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null);
let snapshot: string[] = [];
const byId = computed(() => new Map(props.users.map((u) => [u.id, u])));
const selected = computed(() =>
props.modelValue.map((id) => byId.value.get(id) ?? { id, fullName: '—' }),
const options = computed(() =>
props.users.map((u) => ({ value: u.id, label: u.fullName })),
);
const filtered = computed(() => {
if (!query.value.trim()) return props.users;
return props.users.filter((u) => matchesSearch(query.value, [u.fullName]));
});
const canRemove = computed(() => props.modelValue.length > props.min);
function sameSet(a: string[], b: string[]) {
return a.length === b.length && a.every((id) => b.includes(id));
function onUpdate(value: unknown) {
const ids = Array.isArray(value) ? value.map(String) : [];
if (ids.length < props.min && props.modelValue.length >= props.min) {
emit('update:modelValue', [...props.modelValue]);
return;
}
emit('update:modelValue', ids);
emit('change', ids);
}
function openList() {
if (props.disabled) return;
snapshot = [...props.modelValue];
query.value = '';
open.value = true;
void nextTick(() => searchInput.value?.focus());
}
function closeList() {
if (!open.value) return;
open.value = false;
if (!sameSet(snapshot, props.modelValue)) emit('change', [...props.modelValue]);
}
function toggle(id: string) {
const has = props.modelValue.includes(id);
if (has && !canRemove.value) return;
const next = has ? props.modelValue.filter((x) => x !== id) : [...props.modelValue, id];
emit('update:modelValue', next);
}
function remove(id: string) {
if (props.disabled || !canRemove.value) return;
const next = props.modelValue.filter((x) => x !== id);
emit('update:modelValue', next);
if (!open.value) emit('change', next);
}
function onDocClick(e: MouseEvent) {
if (open.value && root.value && !root.value.contains(e.target as Node)) closeList();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') closeList();
}
onMounted(() => document.addEventListener('mousedown', onDocClick));
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick));
</script>
<template>
<div ref="root" class="ums" :class="{ disabled }" @keydown="onKey">
<div class="chips">
<span v-for="u in selected" :key="u.id" class="chip">
<UserAvatar :name="u.fullName" :user-id="u.id" :size="22" />
<span class="chip-name">{{ u.fullName }}</span>
<button
v-if="canRemove && !disabled"
type="button"
class="chip-x"
:aria-label="`حذف ${u.fullName}`"
@click.stop="remove(u.id)"
>
<UiIcon name="x" :size="12" />
</button>
</span>
<span v-if="!selected.length" class="empty">{{ emptyLabel }}</span>
<button
type="button"
class="add"
:disabled="disabled"
:aria-expanded="open"
@click.stop="open ? closeList() : openList()"
>
<UiIcon name="user-plus" :size="14" />
{{ addLabel }}
</button>
</div>
<div v-if="open" class="pop" @click.stop>
<label class="search">
<UiIcon name="search" :size="14" />
<input ref="searchInput" v-model="query" type="search" placeholder="جستجوی نام…" />
</label>
<ul class="list">
<li v-for="u in filtered" :key="u.id">
<button
type="button"
class="row"
:class="{ on: modelValue.includes(u.id) }"
@click="toggle(u.id)"
>
<span class="check" aria-hidden="true">
<UiIcon v-if="modelValue.includes(u.id)" name="check-plain" :size="12" />
</span>
<UserAvatar :name="u.fullName" :user-id="u.id" :size="26" />
<span class="row-name">{{ u.fullName }}</span>
</button>
</li>
<li v-if="!filtered.length" class="none">کاربری پیدا نشد.</li>
</ul>
<div class="foot">
<span>{{ modelValue.length.toLocaleString('fa-IR') }} نفر انتخاب شده</span>
<button type="button" class="done" @click="closeList">تأیید</button>
</div>
</div>
</div>
<a-select
mode="multiple"
allow-clear
show-search
option-filter-prop="label"
:value="modelValue"
:options="options"
:disabled="disabled"
:placeholder="emptyLabel"
style="width: 100%"
@update:value="onUpdate"
/>
</template>
<style scoped>
.ums {
position: relative;
width: 100%;
min-width: 0;
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.18rem 0.25rem;
padding-inline-end: 0.5rem;
border-radius: 999px;
background: #f0fdfa;
border: 1px solid #ccfbf1;
color: #0f172a;
font-size: 0.82rem;
font-weight: 600;
max-width: 100%;
}
.chip-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-x {
width: 20px;
height: 20px;
border: 0;
border-radius: 999px;
background: transparent;
color: #64748b;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.chip-x:hover {
background: #fee2e2;
color: #b91c1c;
}
.empty {
color: #94a3b8;
font-size: 0.82rem;
font-weight: 500;
}
.add {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 30px;
padding: 0 0.7rem;
border: 1px dashed #5eead4;
border-radius: 999px;
background: #fff;
color: #0f766e;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.add:hover:not(:disabled) {
background: #f0fdfa;
}
.add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pop {
position: absolute;
top: calc(100% + 6px);
inset-inline-start: 0;
z-index: 30;
width: min(18rem, calc(100vw - 2rem));
background: #fff;
border-radius: var(--radius);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16);
border: 1px solid #f1f5f9;
display: grid;
overflow: hidden;
}
.search {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0.5rem;
padding: 0 0.6rem;
height: 2.2rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
color: #94a3b8;
}
.search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
font: inherit;
font-size: 0.85rem;
background: transparent;
color: #0f172a;
}
.list {
list-style: none;
margin: 0;
padding: 0 0.35rem 0.35rem;
max-height: 15rem;
overflow-y: auto;
}
.row {
width: 100%;
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.42rem 0.5rem;
border: 0;
border-radius: 10px;
background: none;
font: inherit;
font-size: 0.86rem;
font-weight: 500;
color: #0f172a;
text-align: start;
cursor: pointer;
}
.row:hover {
background: #f8fafc;
}
.row.on {
background: #f0fdfa;
font-weight: 700;
}
.check {
width: 18px;
height: 18px;
flex-shrink: 0;
border-radius: 5px;
border: 1.5px solid #cbd5e1;
display: grid;
place-items: center;
color: #fff;
}
.row.on .check {
background: #14b8a6;
border-color: #14b8a6;
}
.row-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.none {
padding: 0.75rem;
color: #94a3b8;
font-size: 0.84rem;
text-align: center;
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-top: 1px solid #f1f5f9;
font-size: 0.78rem;
color: #64748b;
}
.done {
border: 0;
border-radius: 8px;
background: #14b8a6;
color: #fff;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
padding: 0.35rem 0.9rem;
cursor: pointer;
}
</style>