feat(web): pipeline settings page and nav entry
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -64,6 +64,7 @@ const salesActive = computed(() =>
|
||||
'/commissions',
|
||||
'/sales/forecast',
|
||||
'/settings/products',
|
||||
'/settings/pipelines',
|
||||
]),
|
||||
);
|
||||
const activitiesActive = computed(() => pathIn(['/activities']));
|
||||
@@ -93,6 +94,7 @@ const settingsActive = computed(() =>
|
||||
'/settings/team',
|
||||
'/settings/audit-logs',
|
||||
'/settings/public-api',
|
||||
'/settings/pipelines',
|
||||
'/settings/custom-fields',
|
||||
'/settings/currencies',
|
||||
'/settings/tahesab',
|
||||
|
||||
@@ -157,6 +157,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'mint',
|
||||
module: 'pipeline',
|
||||
},
|
||||
{
|
||||
to: '/settings/pipelines',
|
||||
title: 'مسیر فروش و کاریزها',
|
||||
desc: 'مدیریت کاریزها و ادغام مسیرهای فروش',
|
||||
icon: 'filter',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'pipeline',
|
||||
},
|
||||
{
|
||||
to: '/settings/products',
|
||||
title: 'محصولات و خدمات',
|
||||
@@ -410,6 +419,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
module: 'teams',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/pipelines',
|
||||
title: 'مسیر فروش و کاریزها',
|
||||
desc: 'مدیریت کاریزها و ادغام مسیرهای فروش',
|
||||
icon: 'filter',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'pipeline',
|
||||
},
|
||||
{
|
||||
to: '/settings/audit-logs',
|
||||
title: 'لاگ فعالیت کاربران',
|
||||
|
||||
@@ -331,6 +331,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/LeadStagesSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/pipelines',
|
||||
name: 'pipeline-settings',
|
||||
component: () => import('@/views/PipelineSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[], module: 'pipeline' },
|
||||
},
|
||||
{
|
||||
path: 'settings/lead-forms',
|
||||
name: 'lead-forms',
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiCard, UiEmptyState, UiField, UiInput, UiModal, UiPageHeader } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import type { PipelineDto } from '@mento/shared';
|
||||
|
||||
type PipelineListItem = PipelineDto & {
|
||||
dealCount?: number | null;
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const okMsg = ref('');
|
||||
const pipelines = ref<PipelineListItem[]>([]);
|
||||
const name = ref('');
|
||||
const mergeOpen = ref(false);
|
||||
const mergeSourceId = ref<string | null>(null);
|
||||
|
||||
const canMerge = computed(() => pipelines.value.length >= 2);
|
||||
const mergeSource = computed(() =>
|
||||
pipelines.value.find((pipeline) => pipeline.id === mergeSourceId.value) ?? null,
|
||||
);
|
||||
|
||||
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<PipelineListItem[]>('/pipelines');
|
||||
pipelines.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری کاریزها ناموفق بود.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPipeline() {
|
||||
const nextName = name.value.trim();
|
||||
if (nextName.length < 2 || saving.value) {
|
||||
error.value = 'نام کاریز را حداقل با ۲ کاراکتر وارد کنید.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.post<PipelineDto>('/pipelines', { name: nextName });
|
||||
name.value = '';
|
||||
okMsg.value = 'کاریز جدید ساخته شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ساخت کاریز ناموفق بود.');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openMerge(pipeline: PipelineListItem) {
|
||||
if (!canMerge.value) return;
|
||||
mergeSourceId.value = pipeline.id;
|
||||
mergeOpen.value = true;
|
||||
}
|
||||
|
||||
function closeMerge() {
|
||||
mergeOpen.value = false;
|
||||
}
|
||||
|
||||
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="createPipeline">
|
||||
<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="pipeline-name">
|
||||
<UiInput id="pipeline-name" v-model="name" required placeholder="مثلاً فروش سازمانی" />
|
||||
</UiField>
|
||||
</div>
|
||||
<template #footer>
|
||||
<UiButton type="submit" :loading="saving">افزودن کاریز</UiButton>
|
||||
</template>
|
||||
</UiCard>
|
||||
</form>
|
||||
|
||||
<div v-if="pipelines.length" class="list">
|
||||
<UiCard v-for="pipeline in pipelines" :key="pipeline.id" padding="sm" class="pipeline-card">
|
||||
<div class="row">
|
||||
<span class="dot" aria-hidden="true" />
|
||||
<div class="info">
|
||||
<div class="title-row">
|
||||
<strong>{{ pipeline.name }}</strong>
|
||||
<em v-if="pipeline.isDefault">پیشفرض</em>
|
||||
</div>
|
||||
<span class="muted">
|
||||
{{ toFaDigits(pipeline.stages.length) }} مرحله
|
||||
<template v-if="pipeline.dealCount != null">
|
||||
· {{ toFaDigits(pipeline.dealCount) }} معامله
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="!canMerge"
|
||||
@click="openMerge(pipeline)"
|
||||
>
|
||||
ادغام
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading"
|
||||
title="کاریزی تعریف نشده."
|
||||
description="اولین مسیر فروش را اضافه کنید."
|
||||
/>
|
||||
|
||||
<UiModal :open="mergeOpen" title="ادغام کاریز" @close="closeMerge">
|
||||
<!-- TODO(Task 4): mount PipelineMergeWizard here with mergeSourceId and close events. -->
|
||||
<p class="modal-copy">
|
||||
ویزارد ادغام در مرحله بعد اضافه میشود.
|
||||
</p>
|
||||
<p v-if="mergeSource" class="muted">
|
||||
کاریز مبدا: {{ mergeSource.name }}
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="closeMerge">بستن</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</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(14rem, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.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;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1 1 12rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-row 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;
|
||||
}
|
||||
|
||||
.modal-copy {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user