Add standalone Leads module for prospect outreach before customers.
Import large Excel lists with duplicate checks, assign and kanban follow-up, convert to Person when ready; also fix OTP mobile mask order and SMS brand name to منتکت. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,7 +46,16 @@ const loyaltyActive = computed(() => pathIn(['/loyalty']));
|
||||
const reportsActive = computed(() => pathIn(['/reports']));
|
||||
const customersActive = computed(() => pathIn(['/contacts']));
|
||||
const salesActive = computed(() =>
|
||||
pathIn(['/pipeline', '/deals', '/quotes', '/invoices', '/commissions', '/sales/forecast', '/settings/products']),
|
||||
pathIn([
|
||||
'/pipeline',
|
||||
'/leads',
|
||||
'/deals',
|
||||
'/quotes',
|
||||
'/invoices',
|
||||
'/commissions',
|
||||
'/sales/forecast',
|
||||
'/settings/products',
|
||||
]),
|
||||
);
|
||||
const activitiesActive = computed(() => pathIn(['/activities']));
|
||||
const notesActive = computed(() => route.path === '/notes' || route.path.startsWith('/notes/'));
|
||||
@@ -73,6 +82,7 @@ const settingsActive = computed(() =>
|
||||
'/settings/team',
|
||||
'/settings/audit-logs',
|
||||
'/settings/public-api',
|
||||
'/settings/lead-stages',
|
||||
'/settings/custom-fields',
|
||||
'/settings/currencies',
|
||||
'/settings/tahesab',
|
||||
|
||||
@@ -116,6 +116,22 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'mint',
|
||||
module: 'pipeline',
|
||||
},
|
||||
{
|
||||
to: '/leads',
|
||||
title: 'لیدها',
|
||||
desc: 'فهرست سرنخها، تخصیص و پیگیری',
|
||||
icon: 'user-plus',
|
||||
tint: 'sky',
|
||||
module: 'leads',
|
||||
},
|
||||
{
|
||||
to: '/leads/board',
|
||||
title: 'کاریز لیدها',
|
||||
desc: 'نمای کانبان مراحل لید',
|
||||
icon: 'filter',
|
||||
tint: 'violet',
|
||||
module: 'leads',
|
||||
},
|
||||
{
|
||||
to: '/settings/products',
|
||||
title: 'محصولات و خدمات',
|
||||
@@ -387,6 +403,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
module: 'public_api',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/lead-stages',
|
||||
title: 'تنظیمات مراحل لید',
|
||||
desc: 'وضعیتها و برچسبهای کاریز لید',
|
||||
icon: 'layers',
|
||||
tint: 'violet',
|
||||
admin: true,
|
||||
module: 'leads',
|
||||
},
|
||||
{
|
||||
to: '/settings/custom-fields',
|
||||
title: 'فیلدهای سفارشی',
|
||||
|
||||
@@ -182,6 +182,21 @@ export const router = createRouter({
|
||||
name: 'pipeline',
|
||||
component: () => import('@/views/PipelineBoardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'leads',
|
||||
name: 'leads',
|
||||
component: () => import('@/views/LeadsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'leads/board',
|
||||
name: 'leads-board',
|
||||
component: () => import('@/views/LeadBoardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'leads/:id',
|
||||
name: 'lead-detail',
|
||||
component: () => import('@/views/LeadDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
name: 'projects',
|
||||
@@ -239,6 +254,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/MessageAutomationsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/lead-stages',
|
||||
name: 'lead-stages',
|
||||
component: () => import('@/views/LeadStagesSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/lead-forms',
|
||||
name: 'lead-forms',
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiEmptyState, UiPageHeader } from '@/components/ui';
|
||||
import type { LeadBoardColumnDto, LeadDto } from '@mento/shared';
|
||||
|
||||
const loading = ref(false);
|
||||
const moving = ref(false);
|
||||
const error = ref('');
|
||||
const columns = ref<LeadBoardColumnDto[]>([]);
|
||||
const draggingId = ref<string | null>(null);
|
||||
const dropStageId = ref<string | null>(null);
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
return formatJalaliNumeric(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<LeadBoardColumnDto[]>('/leads/board');
|
||||
columns.value = data;
|
||||
} catch (e) {
|
||||
error.value =
|
||||
axios.isAxiosError(e) && !e.response
|
||||
? 'اتصال به API برقرار نشد.'
|
||||
: 'بارگذاری کاریز لیدها ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(lead: LeadDto, event: DragEvent) {
|
||||
draggingId.value = lead.id;
|
||||
event.dataTransfer?.setData('text/plain', lead.id);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
event.preventDefault();
|
||||
dropStageId.value = stageId;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
async function onDrop(stageId: string, event: DragEvent) {
|
||||
event.preventDefault();
|
||||
const leadId =
|
||||
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
if (!leadId) return;
|
||||
|
||||
const fromCol = columns.value.find((c) =>
|
||||
c.leads.some((l) => l.id === leadId),
|
||||
);
|
||||
if (!fromCol || fromCol.stage.id === stageId) return;
|
||||
|
||||
const lead = fromCol.leads.find((l) => l.id === leadId);
|
||||
if (!lead) return;
|
||||
|
||||
const prev = columns.value.map((c) => ({
|
||||
...c,
|
||||
leads: [...c.leads],
|
||||
}));
|
||||
|
||||
columns.value = columns.value.map((c) => {
|
||||
if (c.stage.id === fromCol.stage.id) {
|
||||
return { ...c, leads: c.leads.filter((l) => l.id !== leadId) };
|
||||
}
|
||||
if (c.stage.id === stageId) {
|
||||
return {
|
||||
...c,
|
||||
leads: [{ ...lead, stageId, stageName: c.stage.name }, ...c.leads],
|
||||
};
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
moving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/leads/${leadId}/move`, { stageId });
|
||||
} catch {
|
||||
columns.value = prev;
|
||||
error.value = 'جابهجایی لید ناموفق بود.';
|
||||
} finally {
|
||||
moving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader title="کاریز لیدها" lead="کشیدن و رها کردن برای تغییر وضعیت">
|
||||
<template #actions>
|
||||
<RouterLink :to="{ name: 'leads' }" class="link-btn">
|
||||
<UiButton variant="ghost" size="sm">فهرست لیدها</UiButton>
|
||||
</RouterLink>
|
||||
<UiButton variant="ghost" size="sm" :disabled="loading || moving" @click="load">
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else-if="columns.length" class="board">
|
||||
<section
|
||||
v-for="col in columns"
|
||||
:key="col.stage.id"
|
||||
class="column"
|
||||
:class="{ drop: dropStageId === col.stage.id }"
|
||||
@dragover="onDragOver(col.stage.id, $event)"
|
||||
@drop="onDrop(col.stage.id, $event)"
|
||||
>
|
||||
<header class="col-head">
|
||||
<span
|
||||
class="dot"
|
||||
:style="{ background: col.stage.color || 'var(--accent)' }"
|
||||
/>
|
||||
<h2>{{ col.stage.name }}</h2>
|
||||
<em>{{ toFaDigits(col.leads.length) }}</em>
|
||||
</header>
|
||||
|
||||
<div class="cards">
|
||||
<article
|
||||
v-for="lead in col.leads"
|
||||
:key="lead.id"
|
||||
class="card"
|
||||
:class="{ dragging: draggingId === lead.id }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(lead, $event)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<RouterLink class="title" :to="{ name: 'lead-detail', params: { id: lead.id } }">
|
||||
{{ lead.title }}
|
||||
</RouterLink>
|
||||
<p v-if="lead.mobile" class="line" dir="ltr">{{ lead.mobile }}</p>
|
||||
<p v-if="lead.city" class="line">{{ lead.city }}</p>
|
||||
<p v-if="lead.assigneeName" class="line muted">{{ lead.assigneeName }}</p>
|
||||
<p v-if="lead.nextFollowUpAt" class="line muted">
|
||||
پیگیری: {{ formatDate(lead.nextFollowUpAt) }}
|
||||
</p>
|
||||
</article>
|
||||
<p v-if="!col.leads.length" class="empty-col">خالی</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading"
|
||||
title="مرحلهای برای کاریز نیست."
|
||||
description="در تنظیمات مراحل لید، نمایش روی کاریز را فعال کنید."
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.board {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
align-items: flex-start;
|
||||
min-height: 24rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 0 0 16.5rem;
|
||||
background: color-mix(in srgb, var(--surface-2, #f8fafc) 85%, #fff);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
max-height: calc(100vh - 12rem);
|
||||
}
|
||||
|
||||
.column.drop {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem 0.75rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.col-head h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.col-head em {
|
||||
font-style: normal;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cards {
|
||||
padding: 0.55rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
padding: 0.65rem 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.line {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.empty-col {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-col {
|
||||
margin: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,466 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
UiSelect,
|
||||
} from '@/components/ui';
|
||||
import type {
|
||||
ConvertLeadResult,
|
||||
LeadDto,
|
||||
LeadEventDto,
|
||||
LeadStageDto,
|
||||
OrgUserDto,
|
||||
} from '@mento/shared';
|
||||
|
||||
type LeadDetail = LeadDto & { events: LeadEventDto[] };
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => canManageSettings(auth.user?.role));
|
||||
|
||||
const id = computed(() => String(route.params.id));
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const okMsg = ref('');
|
||||
const lead = ref<LeadDetail | null>(null);
|
||||
const stages = ref<LeadStageDto[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
|
||||
const stageId = ref('');
|
||||
const assigneeId = ref('');
|
||||
const callOutcome = ref('');
|
||||
const callBody = ref('');
|
||||
const callFollowUp = ref('');
|
||||
const noteBody = ref('');
|
||||
const followUpAt = ref('');
|
||||
const followUpBody = ref('');
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) return '—';
|
||||
try {
|
||||
return formatJalaliNumeric(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
if (Array.isArray(msg)) return msg.join('، ');
|
||||
if (msg) return msg;
|
||||
if (!e.response) return 'اتصال به API برقرار نشد.';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [leadRes, stagesRes, usersRes] = await Promise.all([
|
||||
api.get<LeadDetail>(`/leads/${id.value}`),
|
||||
api.get<LeadStageDto[]>('/leads/stages'),
|
||||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||||
]);
|
||||
lead.value = leadRes.data;
|
||||
stages.value = stagesRes.data;
|
||||
users.value = usersRes.data.filter((u) => u.isActive !== false);
|
||||
stageId.value = leadRes.data.stageId;
|
||||
assigneeId.value = leadRes.data.assigneeId ?? '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری لید ناموفق بود.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStage() {
|
||||
if (!stageId.value || stageId.value === lead.value?.stageId) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/leads/${id.value}/move`, { stageId: stageId.value });
|
||||
okMsg.value = 'وضعیت بهروز شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر وضعیت ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignLead() {
|
||||
if (!assigneeId.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/leads/assign', {
|
||||
leadIds: [id.value],
|
||||
assigneeId: assigneeId.value,
|
||||
});
|
||||
okMsg.value = 'کارشناس تخصیص داده شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تخصیص ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logCall() {
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(`/leads/${id.value}/call`, {
|
||||
outcome: callOutcome.value || undefined,
|
||||
body: callBody.value || undefined,
|
||||
nextFollowUpAt: callFollowUp.value || undefined,
|
||||
});
|
||||
callOutcome.value = '';
|
||||
callBody.value = '';
|
||||
callFollowUp.value = '';
|
||||
okMsg.value = 'تماس ثبت شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ثبت تماس ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addNote() {
|
||||
if (!noteBody.value.trim()) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(`/leads/${id.value}/notes`, { body: noteBody.value.trim() });
|
||||
noteBody.value = '';
|
||||
okMsg.value = 'یادداشت ثبت شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ثبت یادداشت ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setFollowUp() {
|
||||
if (!followUpAt.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(`/leads/${id.value}/follow-up`, {
|
||||
nextFollowUpAt: new Date(followUpAt.value).toISOString(),
|
||||
body: followUpBody.value || undefined,
|
||||
});
|
||||
followUpAt.value = '';
|
||||
followUpBody.value = '';
|
||||
okMsg.value = 'پیگیری تنظیم شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تنظیم پیگیری ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function convertLead() {
|
||||
if (!confirm('این لید به مشتری تبدیل شود؟')) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<ConvertLeadResult>(`/leads/${id.value}/convert`, {
|
||||
createIfMissing: true,
|
||||
});
|
||||
okMsg.value = data.createdPerson
|
||||
? 'لید تبدیل و شخص جدید ساخته شد.'
|
||||
: 'لید به مشتری موجود متصل شد.';
|
||||
await load();
|
||||
if (data.personId) {
|
||||
void router.push({ name: 'person-detail', params: { id: data.personId } });
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تبدیل به مشتری ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveLead() {
|
||||
if (!confirm('این لید بایگانی شود؟')) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/leads/archive', { leadIds: [id.value] });
|
||||
okMsg.value = 'لید بایگانی شد.';
|
||||
void router.push({ name: 'leads' });
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بایگانی ناموفق بود.');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
:title="lead?.title || 'جزئیات لید'"
|
||||
:lead="lead ? `موبایل ${lead.mobile}` : 'در حال بارگذاری…'"
|
||||
:back-to="{ name: 'leads' }"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton
|
||||
v-if="lead && !lead.convertedPersonId"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
:disabled="busy"
|
||||
@click="convertLead"
|
||||
>
|
||||
تبدیل به مشتری
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="lead && !lead.archivedAt"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="busy"
|
||||
@click="archiveLead"
|
||||
>
|
||||
بایگانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<template v-else-if="lead">
|
||||
<div class="layout">
|
||||
<div class="main">
|
||||
<UiCard title="اطلاعات لید">
|
||||
<dl class="meta">
|
||||
<div><dt>نام</dt><dd>{{ lead.title }}</dd></div>
|
||||
<div><dt>مالک/مخاطب</dt><dd>{{ lead.contactName || '—' }}</dd></div>
|
||||
<div><dt>موبایل</dt><dd dir="ltr">{{ lead.mobile }}</dd></div>
|
||||
<div><dt>تلفن</dt><dd dir="ltr">{{ lead.phone || '—' }}</dd></div>
|
||||
<div><dt>شهر</dt><dd>{{ lead.city || '—' }}</dd></div>
|
||||
<div><dt>منطقه</dt><dd>{{ lead.region || '—' }}</dd></div>
|
||||
<div><dt>صنف</dt><dd>{{ lead.trade || '—' }}</dd></div>
|
||||
<div><dt>منبع</dt><dd>{{ lead.source || '—' }}</dd></div>
|
||||
<div><dt>وضعیت</dt><dd>{{ lead.stageName }}</dd></div>
|
||||
<div><dt>کارشناس</dt><dd>{{ lead.assigneeName || '—' }}</dd></div>
|
||||
<div><dt>آخرین تماس</dt><dd>{{ formatDate(lead.lastContactAt) }}</dd></div>
|
||||
<div><dt>پیگیری بعدی</dt><dd>{{ formatDate(lead.nextFollowUpAt) }}</dd></div>
|
||||
<div><dt>روز در مرحله</dt><dd>{{ toFaDigits(lead.daysInStage) }}</dd></div>
|
||||
<div v-if="lead.notes" class="full">
|
||||
<dt>یادداشت</dt>
|
||||
<dd>{{ lead.notes }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="جدول زمانی">
|
||||
<ul v-if="lead.events.length" class="timeline">
|
||||
<li v-for="ev in lead.events" :key="ev.id">
|
||||
<strong>{{ ev.title }}</strong>
|
||||
<span class="muted">{{ formatDate(ev.createdAt) }}</span>
|
||||
<p v-if="ev.body">{{ ev.body }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<UiEmptyState
|
||||
v-else
|
||||
title="رویدادی ثبت نشده."
|
||||
description="تماس، یادداشت یا پیگیری اضافه کنید."
|
||||
/>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<aside class="side">
|
||||
<UiCard title="تغییر وضعیت">
|
||||
<UiField label="وضعیت">
|
||||
<UiSelect v-model="stageId">
|
||||
<option v-for="s in stages" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiButton size="sm" :disabled="busy" @click="changeStage">اعمال</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="isAdmin" title="تخصیص کارشناس">
|
||||
<UiField label="کارشناس">
|
||||
<UiSelect v-model="assigneeId">
|
||||
<option value="">انتخاب…</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</UiSelect>
|
||||
</UiField>
|
||||
<UiButton size="sm" :disabled="busy || !assigneeId" @click="assignLead">
|
||||
تخصیص
|
||||
</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="ثبت تماس">
|
||||
<UiField label="نتیجه">
|
||||
<UiInput v-model="callOutcome" placeholder="مثلاً پاسخ داد / اشغال" />
|
||||
</UiField>
|
||||
<UiField label="توضیح">
|
||||
<UiInput v-model="callBody" placeholder="خلاصه مکالمه" />
|
||||
</UiField>
|
||||
<UiField label="پیگیری بعدی">
|
||||
<input v-model="callFollowUp" class="native" type="datetime-local" />
|
||||
</UiField>
|
||||
<UiButton size="sm" :disabled="busy" @click="logCall">ثبت تماس</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="یادداشت">
|
||||
<UiField label="متن">
|
||||
<UiInput v-model="noteBody" placeholder="یادداشت…" />
|
||||
</UiField>
|
||||
<UiButton size="sm" :disabled="busy || !noteBody.trim()" @click="addNote">
|
||||
ثبت یادداشت
|
||||
</UiButton>
|
||||
</UiCard>
|
||||
|
||||
<UiCard title="پیگیری بعدی">
|
||||
<UiField label="زمان">
|
||||
<input v-model="followUpAt" class="native" type="datetime-local" />
|
||||
</UiField>
|
||||
<UiField label="توضیح">
|
||||
<UiInput v-model="followUpBody" placeholder="اختیاری" />
|
||||
</UiField>
|
||||
<UiButton size="sm" :disabled="busy || !followUpAt" @click="setFollowUp">
|
||||
ذخیره پیگیری
|
||||
</UiButton>
|
||||
</UiCard>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(16rem, 20rem);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.main,
|
||||
.side {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meta .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.meta dt {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.meta dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.timeline li {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding-bottom: 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.timeline li:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline p {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.native {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.65rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.side :deep(.ui-card) {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
import { 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';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const okMsg = ref('');
|
||||
const stages = ref<LeadStageDto[]>([]);
|
||||
|
||||
const name = ref('');
|
||||
const color = ref('#14b8a6');
|
||||
const showOnBoard = ref(true);
|
||||
const isConverted = ref(false);
|
||||
const isLost = ref(false);
|
||||
const isUnqualified = ref(false);
|
||||
|
||||
const editingId = ref<string | null>(null);
|
||||
const editName = ref('');
|
||||
const editColor = ref('#14b8a6');
|
||||
const editShowOnBoard = ref(true);
|
||||
const editConverted = ref(false);
|
||||
const editLost = ref(false);
|
||||
const editUnqualified = ref(false);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
if (Array.isArray(msg)) return msg.join('، ');
|
||||
if (msg) return msg;
|
||||
if (!e.response) return 'اتصال به API برقرار نشد.';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<LeadStageDto[]>('/leads/stages');
|
||||
stages.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری مراحل ناموفق بود.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createStage() {
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'نام مرحله را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
const body: CreateLeadStageRequest = {
|
||||
name: name.value.trim(),
|
||||
color: color.value || undefined,
|
||||
showOnBoard: showOnBoard.value,
|
||||
isConverted: isConverted.value,
|
||||
isLost: isLost.value,
|
||||
isUnqualified: isUnqualified.value,
|
||||
};
|
||||
await api.post('/leads/stages', body);
|
||||
name.value = '';
|
||||
color.value = '#14b8a6';
|
||||
showOnBoard.value = true;
|
||||
isConverted.value = false;
|
||||
isLost.value = false;
|
||||
isUnqualified.value = false;
|
||||
okMsg.value = 'مرحله افزوده شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ثبت مرحله ناموفق بود.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(stage: LeadStageDto) {
|
||||
editingId.value = stage.id;
|
||||
editName.value = stage.name;
|
||||
editColor.value = stage.color || '#14b8a6';
|
||||
editShowOnBoard.value = stage.showOnBoard;
|
||||
editConverted.value = stage.isConverted;
|
||||
editLost.value = stage.isLost;
|
||||
editUnqualified.value = stage.isUnqualified;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId.value || !editName.value.trim()) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const body: UpdateLeadStageRequest = {
|
||||
name: editName.value.trim(),
|
||||
color: editColor.value,
|
||||
showOnBoard: editShowOnBoard.value,
|
||||
isConverted: editConverted.value,
|
||||
isLost: editLost.value,
|
||||
isUnqualified: editUnqualified.value,
|
||||
};
|
||||
await api.patch(`/leads/stages/${editingId.value}`, body);
|
||||
editingId.value = null;
|
||||
okMsg.value = 'مرحله ذخیره شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(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, 'حذف مرحله ناموفق بود.');
|
||||
}
|
||||
}
|
||||
|
||||
async function move(stage: LeadStageDto, dir: -1 | 1) {
|
||||
const idx = stages.value.findIndex((s) => s.id === stage.id);
|
||||
const swap = idx + dir;
|
||||
if (idx < 0 || swap < 0 || swap >= stages.value.length) return;
|
||||
const next = [...stages.value];
|
||||
const [item] = next.splice(idx, 1);
|
||||
next.splice(swap, 0, item);
|
||||
stages.value = next;
|
||||
try {
|
||||
await api.patch('/leads/stages/reorder', {
|
||||
stageIds: next.map((s) => s.id),
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'مرتبسازی ناموفق بود.');
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading"
|
||||
title="مرحلهای تعریف نشده."
|
||||
description="اولین وضعیت لید را اضافه کنید."
|
||||
/>
|
||||
</section>
|
||||
</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>
|
||||
@@ -0,0 +1,831 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import {
|
||||
UiButton,
|
||||
UiEmptyState,
|
||||
UiPageHeader,
|
||||
UiPagination,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type {
|
||||
LeadDto,
|
||||
LeadImportJobDto,
|
||||
LeadStageDto,
|
||||
OrgUserDto,
|
||||
PaginatedResponse,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const isAdmin = computed(() => canManageSettings(auth.user?.role));
|
||||
|
||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const okMsg = ref('');
|
||||
const data = ref<PaginatedResponse<LeadDto> | null>(null);
|
||||
const stages = ref<LeadStageDto[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
const sortOpen = ref(false);
|
||||
const filterOpen = ref(false);
|
||||
const assignOpen = ref(false);
|
||||
const stageOpen = ref(false);
|
||||
const assignUserId = ref('');
|
||||
const bulkStageId = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const importJob = ref<LeadImportJobDto | null>(null);
|
||||
let importTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const page = computed(() => {
|
||||
const raw = Number(route.query.page);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
||||
});
|
||||
const sort = computed<SortKey>(() => {
|
||||
const s = route.query.sort;
|
||||
if (s === 'newest' || s === 'follow_up' || s === 'last_contact') return s;
|
||||
return 'updated';
|
||||
});
|
||||
const stageId = computed(() =>
|
||||
typeof route.query.stageId === 'string' ? route.query.stageId : '',
|
||||
);
|
||||
const assigneeId = computed(() =>
|
||||
typeof route.query.assigneeId === 'string' ? route.query.assigneeId : '',
|
||||
);
|
||||
const unassigned = computed(
|
||||
() => route.query.unassigned === '1' || route.query.unassigned === 'true',
|
||||
);
|
||||
|
||||
const total = computed(() => data.value?.total ?? 0);
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)));
|
||||
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<SortKey, string> = {
|
||||
updated: 'آخرین بهروزرسانی',
|
||||
newest: 'جدیدترین',
|
||||
follow_up: 'پیگیری بعدی',
|
||||
last_contact: 'آخرین تماس',
|
||||
};
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '—';
|
||||
try {
|
||||
return formatJalaliNumeric(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function closePopups() {
|
||||
sortOpen.value = false;
|
||||
filterOpen.value = false;
|
||||
assignOpen.value = false;
|
||||
stageOpen.value = false;
|
||||
}
|
||||
|
||||
function patchQuery(next: Record<string, string | undefined>) {
|
||||
const query = { ...route.query, ...next };
|
||||
for (const key of Object.keys(query)) {
|
||||
if (query[key] == null || query[key] === '') delete query[key];
|
||||
}
|
||||
void router.replace({ name: 'leads', query });
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
patchQuery({ q: q.value.trim() || undefined, page: undefined });
|
||||
}
|
||||
|
||||
function setSort(next: SortKey) {
|
||||
sortOpen.value = false;
|
||||
patchQuery({ sort: next === 'updated' ? undefined : next, page: undefined });
|
||||
}
|
||||
|
||||
function setStageFilter(id: string) {
|
||||
filterOpen.value = false;
|
||||
patchQuery({ stageId: id || undefined, page: undefined });
|
||||
}
|
||||
|
||||
function setAssigneeFilter(id: string) {
|
||||
filterOpen.value = false;
|
||||
patchQuery({
|
||||
assigneeId: id || undefined,
|
||||
unassigned: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function setUnassigned(on: boolean) {
|
||||
filterOpen.value = false;
|
||||
patchQuery({
|
||||
unassigned: on ? '1' : undefined,
|
||||
assigneeId: undefined,
|
||||
page: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function setPage(next: number) {
|
||||
if (next < 1 || next > pageCount.value) return;
|
||||
patchQuery({ page: next === 1 ? undefined : String(next) });
|
||||
}
|
||||
|
||||
function toggleOne(id: string, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
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) ?? []);
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const [stagesRes, usersRes] = await Promise.all([
|
||||
api.get<LeadStageDto[]>('/leads/stages'),
|
||||
api.get<OrgUserDto[]>('/users').catch(() => ({ data: [] as OrgUserDto[] })),
|
||||
]);
|
||||
stages.value = stagesRes.data;
|
||||
users.value = usersRes.data.filter((u) => u.isActive !== false);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
closePopups();
|
||||
try {
|
||||
const { data: res } = await api.get<PaginatedResponse<LeadDto>>('/leads', {
|
||||
params: {
|
||||
q: q.value || undefined,
|
||||
page: page.value,
|
||||
limit: PAGE_SIZE,
|
||||
sort: sort.value === 'updated' ? undefined : sort.value,
|
||||
stageId: stageId.value || undefined,
|
||||
assigneeId: assigneeId.value || undefined,
|
||||
unassigned: unassigned.value ? '1' : undefined,
|
||||
},
|
||||
});
|
||||
data.value = res;
|
||||
selected.value = new Set(
|
||||
[...selected.value].filter((id) => res.items.some((l) => l.id === id)),
|
||||
);
|
||||
} catch (e) {
|
||||
error.value =
|
||||
axios.isAxiosError(e) && !e.response
|
||||
? 'اتصال به API برقرار نشد.'
|
||||
: 'بارگذاری لیدها ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignSelected() {
|
||||
if (!assignUserId.value || !selectedIds.value.length) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/leads/assign', {
|
||||
leadIds: selectedIds.value,
|
||||
assigneeId: assignUserId.value,
|
||||
});
|
||||
okMsg.value = `${toFaDigits(selectedIds.value.length)} لید تخصیص داده شد.`;
|
||||
selected.value = new Set();
|
||||
assignOpen.value = false;
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'تخصیص ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkStageSelected() {
|
||||
if (!bulkStageId.value || !selectedIds.value.length) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/leads/bulk-stage', {
|
||||
leadIds: selectedIds.value,
|
||||
stageId: bulkStageId.value,
|
||||
});
|
||||
okMsg.value = `وضعیت ${toFaDigits(selectedIds.value.length)} لید بهروز شد.`;
|
||||
selected.value = new Set();
|
||||
stageOpen.value = false;
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'تغییر وضعیت گروهی ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async 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;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data: blob } = await api.get<Blob>('/leads/export', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${brand.filePrefix}-leads.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
error.value = 'خروجی اکسل ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopImportPoll() {
|
||||
if (importTimer) {
|
||||
clearInterval(importTimer);
|
||||
importTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollImport(jobId: string) {
|
||||
try {
|
||||
const { data: job } = await api.get<LeadImportJobDto>(`/leads/imports/${jobId}`);
|
||||
importJob.value = job;
|
||||
if (job.status === 'done' || job.status === 'failed' || job.status === 'completed') {
|
||||
stopImportPoll();
|
||||
okMsg.value = `ورود: موفق ${toFaDigits(job.successCount)} · تکراری ${toFaDigits(job.duplicateCount)} · خطا ${toFaDigits(job.errorCount)}`;
|
||||
await load();
|
||||
}
|
||||
} catch {
|
||||
stopImportPoll();
|
||||
error.value = 'پیگیری وضعیت ورود ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
function startImportPoll(jobId: string) {
|
||||
stopImportPoll();
|
||||
void pollImport(jobId);
|
||||
importTimer = setInterval(() => void pollImport(jobId), 1500);
|
||||
}
|
||||
|
||||
async function onImportFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const { data: job } = await api.post<LeadImportJobDto>('/leads/import/file', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
importJob.value = job;
|
||||
startImportPoll(job.id);
|
||||
} catch {
|
||||
error.value = 'ورود فایل ناموفق بود.';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(id: string) {
|
||||
void router.push({ name: 'lead-detail', params: { id } });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
route.query.q,
|
||||
route.query.page,
|
||||
route.query.sort,
|
||||
route.query.stageId,
|
||||
route.query.assigneeId,
|
||||
route.query.unassigned,
|
||||
],
|
||||
() => {
|
||||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||||
void load();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMeta();
|
||||
await load();
|
||||
const jobId = typeof route.query.jobId === 'string' ? route.query.jobId : '';
|
||||
if (jobId) startImportPoll(jobId);
|
||||
});
|
||||
|
||||
onBeforeUnmount(stopImportPoll);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page" @click="closePopups">
|
||||
<UiPageHeader title="لیدها" lead="مدیریت سرنخها، تخصیص و پیگیری">
|
||||
<template #actions>
|
||||
<RouterLink class="link-btn" :to="{ name: 'leads-board' }">
|
||||
<UiButton variant="ghost" size="sm">کاریز لیدها</UiButton>
|
||||
</RouterLink>
|
||||
<UiButton
|
||||
v-if="isAdmin"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="busy"
|
||||
@click.stop="exportExcel"
|
||||
>
|
||||
خروجی اکسل
|
||||
</UiButton>
|
||||
<UiButton
|
||||
v-if="isAdmin"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="busy"
|
||||
@click.stop="fileInput?.click()"
|
||||
>
|
||||
ورود از فایل
|
||||
</UiButton>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div class="toolbar">
|
||||
<form class="search" @submit.prevent="onSearch">
|
||||
<UiIcon name="search" :size="16" />
|
||||
<input v-model="q" type="search" placeholder="جستجو نام، موبایل، شهر…" />
|
||||
</form>
|
||||
<div class="toolbar-tools">
|
||||
<div class="pop">
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
@click.stop="sortOpen = !sortOpen; filterOpen = false"
|
||||
>
|
||||
<UiIcon name="trend" :size="16" />
|
||||
<span class="tool-label">مرتبسازی</span>
|
||||
</button>
|
||||
<ul v-if="sortOpen" class="menu">
|
||||
<li v-for="(label, key) in sortLabels" :key="key">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: sort === key }"
|
||||
@click.stop="setSort(key as SortKey)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pop">
|
||||
<button
|
||||
class="tool"
|
||||
type="button"
|
||||
@click.stop="filterOpen = !filterOpen; sortOpen = false"
|
||||
>
|
||||
<UiIcon name="filter" :size="16" />
|
||||
<span class="tool-label">فیلتر</span>
|
||||
</button>
|
||||
<ul v-if="filterOpen" class="menu wide">
|
||||
<li class="menu-label">وضعیت</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: !stageId }"
|
||||
@click.stop="setStageFilter('')"
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="s in stages" :key="s.id">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: stageId === s.id }"
|
||||
@click.stop="setStageFilter(s.id)"
|
||||
>
|
||||
{{ s.name }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="menu-label">کارشناس</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: !assigneeId && !unassigned }"
|
||||
@click.stop="setAssigneeFilter('')"
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: unassigned }"
|
||||
@click.stop="setUnassigned(true)"
|
||||
>
|
||||
بدون کارشناس
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="u in users" :key="u.id">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: assigneeId === u.id }"
|
||||
@click.stop="setAssigneeFilter(u.id)"
|
||||
>
|
||||
{{ u.fullName }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedIds.length" class="bulk-bar" @click.stop>
|
||||
<span>{{ toFaDigits(selectedIds.length) }} مورد انتخابشده</span>
|
||||
<template v-if="isAdmin">
|
||||
<div class="pop">
|
||||
<UiButton size="sm" variant="ghost" @click="assignOpen = !assignOpen">
|
||||
تخصیص
|
||||
</UiButton>
|
||||
<div v-if="assignOpen" class="menu panel">
|
||||
<select v-model="assignUserId">
|
||||
<option value="">انتخاب کارشناس…</option>
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
<UiButton size="sm" :disabled="!assignUserId || busy" @click="assignSelected">
|
||||
اعمال
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pop">
|
||||
<UiButton size="sm" variant="ghost" @click="stageOpen = !stageOpen">
|
||||
تغییر وضعیت
|
||||
</UiButton>
|
||||
<div v-if="stageOpen" class="menu panel">
|
||||
<select v-model="bulkStageId">
|
||||
<option value="">انتخاب وضعیت…</option>
|
||||
<option v-for="s in stages" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
<UiButton size="sm" :disabled="!bulkStageId || busy" @click="bulkStageSelected">
|
||||
اعمال
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<UiButton size="sm" variant="ghost" :disabled="busy" @click="archiveSelected">
|
||||
بایگانی
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<p v-if="importJob && importJob.status !== 'done' && importJob.status !== 'completed' && importJob.status !== 'failed'" class="ok">
|
||||
ورود فایل: {{ toFaDigits(importJob.processedCount) }} / {{ toFaDigits(importJob.totalCount) }}
|
||||
</p>
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<template v-else-if="data?.items.length">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
@click.stop
|
||||
@change="toggleAll(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</th>
|
||||
<th>نام</th>
|
||||
<th>موبایل</th>
|
||||
<th>شهر</th>
|
||||
<th>وضعیت</th>
|
||||
<th>کارشناس</th>
|
||||
<th>آخرین تماس</th>
|
||||
<th>پیگیری بعدی</th>
|
||||
<th>منبع</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="lead in data.items"
|
||||
:key="lead.id"
|
||||
class="row-click"
|
||||
@click="goDetail(lead.id)"
|
||||
>
|
||||
<td class="check-col" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selected.has(lead.id)"
|
||||
@change="toggleOne(lead.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ lead.title }}</strong>
|
||||
<span v-if="lead.contactName" class="sub">{{ lead.contactName }}</span>
|
||||
</td>
|
||||
<td dir="ltr">{{ lead.mobile || '—' }}</td>
|
||||
<td>{{ lead.city || '—' }}</td>
|
||||
<td>
|
||||
<span class="stage-pill">{{ lead.stageName }}</span>
|
||||
</td>
|
||||
<td>{{ lead.assigneeName || '—' }}</td>
|
||||
<td>{{ formatDate(lead.lastContactAt) }}</td>
|
||||
<td>{{ formatDate(lead.nextFollowUpAt) }}</td>
|
||||
<td>{{ lead.source || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<UiPagination
|
||||
v-if="total"
|
||||
:page="page"
|
||||
:total-pages="pageCount"
|
||||
:total-items="total"
|
||||
@update:page="setPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading"
|
||||
title="لیدی ثبت نشده است."
|
||||
description="از ورود فایل استفاده کنید یا لید جدید بسازید."
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-tools {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1 1 14rem;
|
||||
min-width: 180px;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tool {
|
||||
min-height: var(--control-h);
|
||||
height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.pop {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
inset-inline-end: 0;
|
||||
z-index: 20;
|
||||
margin: 0;
|
||||
padding: 0.35rem;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
|
||||
min-width: 11rem;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.menu.wide {
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.menu.panel {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem;
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.menu button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: start;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.menu button.on,
|
||||
.menu button:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
padding: 0.35rem 0.55rem 0.15rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.menu select {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.5rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
text-align: start;
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
background: color-mix(in srgb, var(--surface-2, #f8fafc) 80%, #fff);
|
||||
}
|
||||
|
||||
.row-click {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row-click:hover {
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, #fff);
|
||||
}
|
||||
|
||||
.check-col {
|
||||
width: 2.25rem;
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.stage-pill {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tool-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user