Initialize the user app and load home banners from the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:45 +03:30
co-authored by Cursor
commit a7f9c66514
190 changed files with 36160 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useCrmStore } from '@/stores/crm'
import { useGoBack } from '@/composables/useGoBack'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import { normalizePhone } from '@/utils/format'
import { validatePhone } from '@/utils/validation'
import {
FORM_VALIDATION_SUMMARY,
clearFieldError,
hasFieldErrors,
type FieldErrors,
} from '@/utils/formErrors'
import { ApiError } from '@/services/http'
const router = useRouter()
const auth = useAuthStore()
const crm = useCrmStore()
const { goBack } = useGoBack('/customers')
const firstName = ref('')
const lastName = ref('')
const phone = ref('')
const businessName = ref('')
const fieldErrors = ref<FieldErrors>({})
const formError = ref('')
const saving = ref(false)
function clearField(key: string) {
fieldErrors.value = clearFieldError(fieldErrors.value, key)
if (!hasFieldErrors(fieldErrors.value)) formError.value = ''
}
async function submit() {
fieldErrors.value = {}
formError.value = ''
if (!auth.isAgent) {
formError.value = 'فقط واسطه می‌تواند مشتری به لیست خود اضافه کند.'
return
}
const normalized = normalizePhone(phone.value)
const errors: FieldErrors = {}
if (!firstName.value.trim()) errors.firstName = 'نام الزامی است.'
if (!lastName.value.trim()) errors.lastName = 'نام خانوادگی الزامی است.'
if (!validatePhone(normalized)) errors.phone = 'شماره موبایل معتبر نیست.'
if (hasFieldErrors(errors)) {
fieldErrors.value = errors
formError.value = FORM_VALIDATION_SUMMARY
return
}
saving.value = true
try {
const created = (await crm.registerCustomer({
firstName: firstName.value.trim(),
lastName: lastName.value.trim(),
phone: normalized,
businessName: businessName.value.trim() || undefined,
})) as { id?: string }
if (created?.id) {
await router.replace({ path: '/customer', query: { id: created.id } })
return
}
await router.replace('/customers')
} catch (e) {
formError.value =
e instanceof ApiError || e instanceof Error
? e.message
: 'ثبت مشتری ناموفق بود.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page customer-create-page">
<PageHeader title="افزودن مشتری" show-back centered @back="goBack" />
<div class="page__scroll customer-create-scroll">
<p class="intro">
مشتری به لیست شما اضافه می‌شود و بعد از ورود با همین شماره، سفارش‌هایش به حساب شما وصل می‌ماند.
</p>
<FormErrorBanner :message="formError" />
<div class="form-card">
<BaseInput
v-model="firstName"
label="نام"
placeholder="نام مشتری"
:error="fieldErrors.firstName"
@update:model-value="clearField('firstName')"
/>
<BaseInput
v-model="lastName"
label="نام خانوادگی"
placeholder="نام خانوادگی"
:error="fieldErrors.lastName"
@update:model-value="clearField('lastName')"
/>
<BaseInput
v-model="phone"
label="شماره موبایل"
type="tel"
inputmode="tel"
dir="ltr"
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
:error="fieldErrors.phone"
@update:model-value="clearField('phone')"
/>
<BaseInput
v-model="businessName"
label="نام فروشگاه یا برند (اختیاری)"
placeholder="مثلاً بوتیک رز"
/>
</div>
</div>
<footer class="footer">
<BaseButton block :loading="saving" :disabled="saving" @click="submit">
ثبت مشتری
</BaseButton>
</footer>
</div>
</template>
<style scoped>
.customer-create-page {
background: var(--color-bg);
}
.customer-create-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 12px;
}
.intro {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
line-height: 1.7;
}
.form-card {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-sm);
}
.footer {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0px));
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
}
</style>
+683
View File
@@ -0,0 +1,683 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import { useAuthStore } from '@/stores/auth'
import type {
CrmCustomer,
CrmMeeting,
CrmOrderSummary,
CustomerTab,
MeetingInteractionType,
} from '@/types/crm'
import { formatNumber } from '@/utils/format'
import { ORDER_STATUS_LABELS, STATUS_BADGE_VARIANT } from '@/utils/constants'
import type { OrderStatus } from '@/types'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconCart from '@/components/icons/IconCart.vue'
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconMail from '@/components/icons/IconMail.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const auth = useAuthStore()
const id = computed(() => String(route.query.id ?? ''))
const tab = computed(() => (String(route.query.tab ?? 'info') as CustomerTab))
const customer = ref<CrmCustomer | null>(null)
const orders = ref<CrmOrderSummary[]>([])
const meetings = ref<CrmMeeting[]>([])
const meetingFilter = ref<'all' | MeetingInteractionType>('all')
const loading = ref(true)
const error = ref<string | null>(null)
const tabs: Array<{ id: CustomerTab; label: string }> = [
{ id: 'info', label: 'اطلاعات' },
{ id: 'orders', label: 'سفارش‌ها' },
{ id: 'reports', label: 'گزارش‌ها و پیگیری‌ها' },
]
async function load() {
if (!id.value) {
customer.value = null
loading.value = false
return
}
loading.value = true
error.value = null
try {
const data = await crm.loadCustomerDetail(id.value)
customer.value = data.customer
orders.value = data.orders
meetings.value = data.meetings
} catch (e) {
customer.value = null
error.value = e instanceof Error ? e.message : 'مشتری یافت نشد.'
} finally {
loading.value = false
}
}
onMounted(load)
watch(id, load)
function setTab(next: CustomerTab) {
router.replace({ path: '/customer', query: { id: id.value, tab: next } })
}
function typeLabel(type: MeetingInteractionType) {
if (type === 'in_person') return 'ملاقات حضوری'
if (type === 'call') return 'تماس'
if (type === 'message') return 'پیام'
return 'یادداشت'
}
const agentName = computed(() =>
customer.value?.agentName || auth.fullName || 'واسطه',
)
const agentCode = computed(() => customer.value?.agentCode || auth.user?.agentCode || '—')
const agentInitial = computed(() => agentName.value.charAt(0))
const meetingFilters: Array<{ id: 'all' | MeetingInteractionType; label: string }> = [
{ id: 'all', label: 'همه' },
{ id: 'in_person', label: 'ملاقات حضوری' },
{ id: 'call', label: 'تماس' },
{ id: 'message', label: 'پیام' },
{ id: 'note', label: 'یادداشت' },
]
const filteredMeetings = computed(() => {
if (meetingFilter.value === 'all') return meetings.value
return meetings.value.filter((m) => m.type === meetingFilter.value)
})
function meetingIconClass(type: MeetingInteractionType) {
if (type === 'in_person') return 'meeting-row__icon--green'
if (type === 'call') return 'meeting-row__icon--blue'
return 'meeting-row__icon--purple'
}
function openOrder(orderId: string) {
if (!orderId) return
router.push({ path: '/order', query: { id: orderId } })
}
function orderStatusLabel(order: CrmOrderSummary) {
const key = order.statusLabel as OrderStatus
return ORDER_STATUS_LABELS[key] || order.statusLabel
}
function orderStatusTone(order: CrmOrderSummary): 'warning' | 'success' | 'info' {
const variant = STATUS_BADGE_VARIANT[order.statusLabel]
if (variant === 'success') return 'success'
if (variant === 'warning' || variant === 'danger') return 'warning'
return order.statusTone || 'info'
}
</script>
<template>
<div v-if="customer" class="page customer-page">
<PageHeader
:title="tab === 'reports' ? 'پروفایل مشتری' : 'جزئیات مشتری'"
show-back
centered
@back="router.push('/customers')"
>
<template v-if="tab === 'info'" #trailing>
<button type="button" class="edit-btn" @click="router.back()">ویرایش</button>
</template>
</PageHeader>
<div class="page__scroll page__scroll--with-nav customer-scroll">
<section v-if="tab !== 'reports'" class="profile-row">
<div class="avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<div class="name-row">
<h2>{{ customer.name }}</h2>
<span class="badge">فعال</span>
</div>
<p dir="ltr">{{ customer.phone }}</p>
</div>
</section>
<section v-else class="profile-banner">
<div class="profile-banner__top">
<div class="avatar avatar--lg" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<h2>{{ customer.name }}</h2>
<p>{{ customer.contactName }}</p>
<p dir="ltr">{{ customer.phone }}</p>
</div>
<span class="badge badge--light">مشتری فعال</span>
</div>
<div class="profile-banner__stats">
<div>
<span>سطح مشتری</span>
<strong>{{ customer.levelLabel }}</strong>
</div>
<div>
<span>تاریخ اولین خرید</span>
<strong>{{ customer.firstPurchaseDate }}</strong>
</div>
<div>
<span>تعداد سفارش</span>
<strong>{{ formatNumber(customer.orderCount) }} سفارش</strong>
</div>
</div>
</section>
<div class="tabs">
<button
v-for="item in tabs"
:key="item.id"
type="button"
class="tab"
:class="{ 'tab--active': tab === item.id }"
@click="setTab(item.id)"
>
{{ item.label }}
</button>
</div>
<template v-if="tab === 'info'">
<section class="stats">
<h3>اطلاعات خرید</h3>
<div class="stats__grid">
<div>
<span>تعداد سفارش</span>
<strong>{{ formatNumber(customer.orderCount) }}</strong>
</div>
<div>
<span>مجموع خرید</span>
<strong>{{ formatNumber(customer.totalPurchaseToman) }}</strong>
<em>تومان</em>
</div>
<div>
<span>میانگین هر سفارش</span>
<strong>{{ formatNumber(customer.averageOrderToman) }}</strong>
<em>تومان</em>
</div>
</div>
</section>
<button type="button" class="agent-card" @click="router.push('/profile')">
<div class="agent-card__avatar">{{ agentInitial }}</div>
<div>
<strong>{{ agentName }}</strong>
<p>کد واسطه: {{ agentCode }}</p>
</div>
<IconChevronLeft :size="18" />
</button>
<section>
<div class="section-head">
<h3>آخرین سفارش‌ها</h3>
<button type="button" class="link" @click="setTab('orders')">مشاهده همه</button>
</div>
<article
v-for="order in orders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div>
<strong>{{ order.title }}</strong>
<p>{{ formatNumber(order.amountToman) }} تومان</p>
</div>
<span class="tone" :class="`tone--${orderStatusTone(order)}`">{{ orderStatusLabel(order) }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
</article>
<p v-if="!orders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</section>
</template>
<template v-else-if="tab === 'orders'">
<article
v-for="order in orders"
:key="order.id"
class="order-row"
role="button"
tabindex="0"
@click="openOrder(order.id)"
@keydown.enter="openOrder(order.id)"
>
<div>
<strong>{{ order.title }}</strong>
<p>{{ order.date }} — {{ formatNumber(order.amountToman) }} تومان</p>
</div>
<span class="tone" :class="`tone--${orderStatusTone(order)}`">{{ orderStatusLabel(order) }}</span>
<IconChevronLeft :size="16" class="order-row__chevron" />
</article>
<p v-if="!orders.length" class="empty-inline">سفارشی ثبت نشده است.</p>
</template>
<template v-else>
<h3 class="block-title">تعاملات و پیگیری‌ها</h3>
<button
type="button"
class="dashed-btn"
@click="router.push({ path: '/meeting-report', query: { customerId: customer.id } })"
>
+ ثبت گزارش ملاقات جدید
</button>
<div class="meeting-filters">
<button
v-for="chip in meetingFilters"
:key="chip.id"
type="button"
class="meeting-filter"
:class="{ 'meeting-filter--active': meetingFilter === chip.id }"
@click="meetingFilter = chip.id"
>
{{ chip.label }}
</button>
</div>
<article v-for="item in filteredMeetings" :key="item.id" class="meeting-row">
<div class="meeting-row__icon" :class="meetingIconClass(item.type)">
<IconClipboardEmpty v-if="item.type === 'in_person'" :size="16" />
<IconPhone v-else-if="item.type === 'call'" :size="16" />
<IconMail v-else-if="item.type === 'message'" :size="16" />
<IconEditPen v-else :size="16" />
</div>
<div class="meeting-row__body">
<strong>{{ typeLabel(item.type) }}</strong>
<p>{{ item.description || item.title }}</p>
</div>
<span>{{ item.date }}</span>
</article>
<p v-if="!filteredMeetings.length" class="empty-inline">
گزارشی برای این فیلتر ثبت نشده است.
</p>
</template>
</div>
<footer v-if="tab === 'info'" class="cta-bar">
<button type="button" class="cta" @click="router.push('/categories')">
<IconCart :size="20" color="#fff" />
سفارش جدید برای این مشتری
</button>
</footer>
</div>
<div v-else-if="loading" class="page empty">
<PageSkeleton variant="detail" />
</div>
<div v-else class="page empty">
<p>{{ error || 'مشتری یافت نشد.' }}</p>
</div>
</template>
<style scoped>
.customer-page {
background: var(--color-bg);
}
.customer-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 14px;
}
.profile-row {
display: flex;
gap: 12px;
align-items: center;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 1.4rem;
font-weight: 700;
}
.avatar--lg {
width: 56px;
height: 56px;
}
.name-row {
display: flex;
align-items: center;
gap: 8px;
}
.name-row h2,
.profile-banner h2 {
margin: 0;
font-size: 1.1rem;
}
.profile-row p,
.profile-banner p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.badge {
background: var(--color-primary-soft);
color: var(--color-primary);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-weight: 700;
}
.badge--light {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
.profile-banner {
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-lg);
padding: 16px;
}
.profile-banner__top {
display: grid;
grid-template-columns: 56px 1fr auto;
gap: 10px;
align-items: start;
}
.profile-banner__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.profile-banner__stats span {
display: block;
font-size: 0.7rem;
opacity: 0.85;
}
.profile-banner__stats strong {
display: block;
margin-top: 4px;
font-size: 0.8rem;
}
.tabs {
display: flex;
gap: 12px;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.tab {
border: none;
background: transparent;
padding: 10px 2px;
color: var(--color-text-muted);
white-space: nowrap;
cursor: pointer;
}
.tab--active {
color: var(--color-text);
font-weight: 700;
box-shadow: inset 0 -3px 0 var(--color-text);
}
.stats,
.agent-card,
.order-row,
.meeting-row {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
box-shadow: var(--shadow-sm);
}
.stats h3,
.section-head h3,
.block-title {
margin: 0 0 10px;
font-size: 0.95rem;
}
.stats__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
text-align: center;
}
.stats__grid span,
.stats__grid em {
display: block;
font-size: 0.7rem;
color: var(--color-text-muted);
font-style: normal;
}
.stats__grid strong {
display: block;
margin: 4px 0;
font-size: 0.9rem;
}
.agent-card {
width: 100%;
border: none;
display: grid;
grid-template-columns: 44px 1fr 20px;
gap: 10px;
align-items: center;
text-align: start;
cursor: pointer;
}
.agent-card__avatar {
width: 44px;
height: 44px;
border-radius: 999px;
background: var(--color-primary-soft);
color: var(--color-primary);
display: grid;
place-items: center;
font-weight: 700;
}
.agent-card p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.link {
border: none;
background: transparent;
color: var(--color-info);
cursor: pointer;
font-size: 0.8rem;
}
.order-row,
.meeting-row {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.order-row {
cursor: pointer;
}
.order-row__chevron {
color: var(--color-text-muted);
flex-shrink: 0;
}
.meeting-row__body {
flex: 1;
min-width: 0;
}
.order-row p,
.meeting-row p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.empty-inline {
margin: 8px 0 0;
text-align: center;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.tone {
border-radius: 999px;
padding: 4px 10px;
font-size: 0.72rem;
white-space: nowrap;
}
.tone--warning {
background: var(--color-warning-light);
color: #b45309;
}
.tone--success {
background: var(--color-success-light);
color: var(--color-success);
}
.tone--info {
background: var(--color-info-light);
color: var(--color-info);
}
.edit-btn {
border: none;
background: transparent;
color: var(--color-info, #2563eb);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
padding: 0 4px;
white-space: nowrap;
}
.meeting-filters {
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 8px;
padding-bottom: 4px;
}
.meeting-filter {
border: 1px solid var(--color-border);
background: var(--color-surface);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.72rem;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
}
.meeting-filter--active {
border-color: var(--color-primary);
background: var(--color-primary-soft);
color: var(--color-primary);
font-weight: 700;
}
.dashed-btn {
width: 100%;
border: 1.5px dashed var(--color-primary);
background: transparent;
color: var(--color-primary);
border-radius: var(--radius-md);
padding: 14px;
font-weight: 700;
cursor: pointer;
margin-bottom: 8px;
}
.meeting-row__icon {
width: 36px;
height: 36px;
border-radius: 999px;
flex-shrink: 0;
display: grid;
place-items: center;
}
.meeting-row__icon--green {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.meeting-row__icon--blue {
background: #e0f2fe;
color: #0369a1;
}
.meeting-row__icon--purple {
background: #ede9fe;
color: #6d28d9;
}
.cta-bar {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0));
background: linear-gradient(transparent, var(--color-bg) 30%);
}
.cta {
width: 100%;
min-height: 52px;
border: none;
border-radius: 14px;
background: var(--color-primary);
color: #fff;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.empty {
padding: 40px;
text-align: center;
}
</style>
+413
View File
@@ -0,0 +1,413 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import type { CrmCustomer, CustomerStatus } from '@/types/crm'
import { formatNumber } from '@/utils/format'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import SearchField from '@/components/ui/SearchField.vue'
import IconFilter from '@/components/icons/IconFilter.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconPlus from '@/components/icons/IconPlus.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const q = ref(String(route.query.q ?? ''))
const status = computed(() => String(route.query.status ?? 'all') as CustomerStatus | 'all')
const customers = ref<CrmCustomer[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const filtered = computed(() => customers.value)
const activeCount = computed(() => customers.value.filter((c) => c.status === 'active').length)
const totalPurchase = computed(() =>
customers.value.reduce((sum, c) => sum + c.totalPurchaseToman, 0),
)
const tabs: Array<{ id: 'all' | CustomerStatus; label: string }> = [
{ id: 'all', label: 'همه' },
{ id: 'active', label: 'فعال' },
{ id: 'inactive', label: 'غیرفعال' },
{ id: 'new', label: 'جدید' },
]
async function load() {
loading.value = true
error.value = null
try {
customers.value = await crm.loadCustomers({
q: q.value || undefined,
status: status.value === 'all' ? undefined : status.value,
})
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری مشتریان ناموفق بود.'
customers.value = []
} finally {
loading.value = false
}
}
onMounted(load)
watch([status, q], load)
function setStatus(id: string) {
router.replace({
path: '/customers',
query: {
...(id !== 'all' ? { status: id } : {}),
...(q.value ? { q: q.value } : {}),
},
})
}
function onSearch(value: string) {
q.value = value
router.replace({
path: '/customers',
query: {
...(status.value !== 'all' ? { status: status.value } : {}),
...(value ? { q: value } : {}),
},
})
}
function statusLabel(s: CustomerStatus) {
if (s === 'active') return 'فعال'
if (s === 'inactive') return 'غیرفعال'
return 'جدید'
}
function onRegisterCustomer() {
router.push('/customers/new')
}
function cycleStatusFilter() {
const order = ['all', 'active', 'inactive', 'new'] as const
const idx = order.indexOf(status.value as (typeof order)[number])
const next = order[(idx < 0 ? 0 : idx + 1) % order.length] ?? 'all'
setStatus(next)
}
</script>
<template>
<div class="page customers-page">
<PageHeader title="مشتریان من" show-back centered @back="router.back()">
<template #trailing>
<button
type="button"
class="nav-add-btn"
aria-label="ثبت مشتری"
@click="onRegisterCustomer"
>
<IconPlus :size="22" />
</button>
</template>
</PageHeader>
<div class="page__scroll page__scroll--with-nav customers-scroll">
<div class="toolbar">
<SearchField
:model-value="q"
placeholder="جستجو بر اساس نام یا موبایل..."
@update:model-value="onSearch"
/>
<button type="button" class="filter-btn" aria-label="فیلتر وضعیت" @click="cycleStatusFilter">
<IconFilter :size="18" />
</button>
</div>
<section class="summary-card">
<div class="summary-card__icon" aria-hidden="true">
<IconProfile :size="22" color="#fff" />
</div>
<div class="summary-card__cols">
<div>
<span>تعداد مشتری فعال</span>
<strong>{{ formatNumber(activeCount) }}</strong>
</div>
<span class="summary-card__divider" aria-hidden="true" />
<div>
<span>مجموع خرید (ریال)</span>
<strong>{{ formatNumber(totalPurchase) }}</strong>
<em>تومان</em>
</div>
</div>
</section>
<div class="tabs" role="tablist">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
role="tab"
class="tab"
:class="{ 'tab--active': status === tab.id }"
:aria-selected="status === tab.id"
@click="setStatus(tab.id)"
>
<span class="tab__text">{{ tab.label }}</span>
</button>
</div>
<PageSkeleton v-if="loading" variant="list" />
<p v-else-if="error">{{ error }}</p>
<button
v-for="item in filtered"
:key="item.id"
type="button"
class="customer-card"
@click="router.push({ path: '/customer', query: { id: item.id } })"
>
<div
class="customer-card__avatar"
:style="{ background: item.avatarColor }"
>
{{ item.avatarLetter }}
</div>
<div class="customer-card__body">
<div class="customer-card__top">
<strong>{{ item.name }}</strong>
<span
class="status"
:class="item.status === 'active' ? 'status--ok' : 'status--muted'"
>
{{ statusLabel(item.status) }}
</span>
</div>
<p dir="ltr">{{ item.phone }}</p>
<p>مجموع خرید: {{ formatNumber(item.totalPurchaseToman) }} تومان</p>
</div>
<IconChevronLeft :size="18" class="chevron" />
</button>
<section class="promo">
<div>
<strong>افزایش مشتریان = افزایش سطح شما</strong>
<p>با جذب مشتریان بیشتر به سطح VIP برسید</p>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.customers-page {
background: var(--color-bg);
}
.customers-scroll {
padding: 8px var(--page-padding) 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.toolbar {
display: grid;
grid-template-columns: 1fr 44px;
gap: 8px;
align-items: center;
}
.filter-btn {
width: 44px;
height: 44px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
display: grid;
place-items: center;
cursor: pointer;
}
.summary-card {
display: flex;
gap: 12px;
align-items: center;
background: var(--color-primary);
color: #fff;
border-radius: var(--radius-lg);
padding: 16px;
}
.nav-add-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--color-text);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.summary-card__icon {
width: 44px;
height: 44px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
flex-shrink: 0;
display: grid;
place-items: center;
}
.summary-card__cols {
flex: 1;
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: stretch;
}
.summary-card__divider {
width: 1px;
align-self: stretch;
background: rgba(255, 255, 255, 0.35);
}
.summary-card__cols span,
.summary-card__cols em {
display: block;
font-size: 0.75rem;
font-style: normal;
opacity: 0.85;
}
.summary-card__cols > div {
text-align: center;
}
.summary-card__cols strong {
display: block;
margin-top: 4px;
font-size: 1.05rem;
}
.tabs {
display: flex;
width: 100%;
align-items: stretch;
border-bottom: 1px solid var(--color-border);
}
.tab {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-end;
min-width: 0;
appearance: none;
-webkit-appearance: none;
border: 0;
background: none;
padding: 10px 0 0;
color: #9ca3af;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 400;
line-height: 1.5;
cursor: pointer;
}
.tab__text {
position: relative;
display: inline-block;
padding-bottom: 11px;
}
.tab--active {
color: var(--color-text);
font-weight: 700;
}
.tab--active .tab__text::after {
content: '';
position: absolute;
right: 0;
left: 0;
bottom: -1px;
height: 3px;
background: var(--color-primary);
border-radius: 2px 2px 0 0;
}
.customer-card {
display: grid;
grid-template-columns: 48px 1fr 20px;
gap: 10px;
align-items: center;
border: none;
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 12px;
text-align: start;
cursor: pointer;
box-shadow: var(--shadow-sm);
}
.customer-card__avatar {
width: 48px;
height: 48px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 700;
color: #111;
}
.customer-card__top {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
.customer-card__body p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.status {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 999px;
}
.status--ok {
background: var(--color-primary-soft);
color: var(--color-primary);
}
.status--muted {
background: #eef0ef;
color: var(--color-text-muted);
}
.chevron {
color: var(--color-text-muted);
}
.promo {
background: var(--color-primary-soft);
border-radius: var(--radius-md);
padding: 14px;
}
.promo strong {
display: block;
margin-bottom: 4px;
}
.promo p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
</style>
+430
View File
@@ -0,0 +1,430 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCrmStore } from '@/stores/crm'
import type { CrmCustomer, MeetingInteractionType } from '@/types/crm'
import PageHeader from '@/components/layout/PageHeader.vue'
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
import IconCheckCircleOutline from '@/components/icons/IconCheckCircleOutline.vue'
import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconPhone from '@/components/icons/IconPhone.vue'
import IconMail from '@/components/icons/IconMail.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
const route = useRoute()
const router = useRouter()
const crm = useCrmStore()
const customerId = computed(() => String(route.query.customerId ?? ''))
const customer = ref<CrmCustomer | null>(null)
const loading = ref(true)
const saving = ref(false)
const error = ref<string | null>(null)
const interactionType = ref<MeetingInteractionType>('in_person')
const date = ref('')
const time = ref('')
const subject = ref('')
const content = ref('')
const reminderOn = ref(true)
const reminderTitle = ref('')
const reminderDate = ref('')
const reminderTime = ref('')
const reminderNote = ref('')
const types: Array<{ id: MeetingInteractionType; label: string; icon: 'clipboard' | 'phone' | 'mail' | 'pen' }> = [
{ id: 'in_person', label: 'ملاقات حضوری', icon: 'clipboard' },
{ id: 'call', label: 'تماس تلفنی', icon: 'phone' },
{ id: 'message', label: 'پیام', icon: 'mail' },
{ id: 'note', label: 'یادداشت', icon: 'pen' },
]
onMounted(async () => {
if (!customerId.value) {
loading.value = false
error.value = 'مشتری مشخص نشده است.'
return
}
try {
const data = await crm.loadCustomerDetail(customerId.value)
customer.value = data.customer
} catch (e) {
error.value = e instanceof Error ? e.message : 'بارگذاری مشتری ناموفق بود.'
} finally {
loading.value = false
}
})
async function submit() {
if (!customerId.value || !subject.value.trim() || !date.value.trim()) {
error.value = 'موضوع و تاریخ الزامی است.'
return
}
saving.value = true
error.value = null
try {
await crm.submitMeeting({
customerId: customerId.value,
type: interactionType.value,
title: subject.value.trim(),
subject: subject.value.trim(),
content: content.value.trim() || undefined,
description: content.value.trim() || undefined,
date: date.value.trim(),
time: time.value.trim() || undefined,
createReminder: reminderOn.value,
reminderTitle: reminderOn.value
? reminderTitle.value.trim() || subject.value.trim()
: undefined,
reminderDate: reminderOn.value
? reminderDate.value.trim() || date.value.trim()
: undefined,
reminderTime: reminderOn.value
? reminderTime.value.trim() || time.value.trim() || '10:00'
: undefined,
reminderNote: reminderOn.value
? reminderNote.value.trim() || undefined
: undefined,
})
router.push({
path: '/customer',
query: { id: customerId.value, tab: 'reports' },
})
} catch (e) {
error.value = e instanceof Error ? e.message : 'ثبت گزارش ناموفق بود.'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="page meeting-page">
<PageHeader title="ثبت گزارش ملاقات" show-back centered @back="router.back()">
<template #trailing>
<button
type="button"
class="submit-icon-btn"
aria-label="ثبت گزارش"
:disabled="saving"
@click="submit"
>
<IconCheckCircleOutline :size="22" />
</button>
</template>
</PageHeader>
<div class="page__scroll meeting-scroll">
<PageSkeleton v-if="loading" variant="form" />
<p v-else-if="error" class="form-error">{{ error }}</p>
<section v-if="customer" class="customer-card">
<div class="avatar" :style="{ background: customer.avatarColor }">
{{ customer.avatarLetter }}
</div>
<div>
<strong>{{ customer.name }}</strong>
<p>{{ customer.contactName }}</p>
<p dir="ltr">{{ customer.phone }}</p>
</div>
</section>
<section>
<h3>نوع تعامل</h3>
<div class="type-grid">
<button
v-for="item in types"
:key="item.id"
type="button"
class="type-btn"
:class="{ 'type-btn--active': interactionType === item.id }"
@click="interactionType = item.id"
>
<IconClipboardEmpty v-if="item.icon === 'clipboard'" :size="22" />
<IconPhone v-else-if="item.icon === 'phone'" :size="22" />
<IconMail v-else-if="item.icon === 'mail'" :size="22" />
<IconEditPen v-else :size="22" />
<span>{{ item.label }}</span>
</button>
</div>
</section>
<section>
<h3>تاریخ و زمان</h3>
<div class="two-cols">
<label class="field">
<span>تاریخ</span>
<input v-model="date" type="text" />
</label>
<label class="field">
<span>ساعت</span>
<input v-model="time" type="text" />
</label>
</div>
</section>
<label class="field">
<span>موضوع گزارش</span>
<input v-model="subject" type="text" />
</label>
<label class="field">
<span>محتوای گزارش</span>
<textarea v-model="content" rows="5" maxlength="1000" />
<em>{{ content.length.toLocaleString('fa-IR') }}/۱۰۰۰</em>
</label>
<section>
<h3>تصاویر و فایل‌ها</h3>
<div class="files">
<div class="file-thumb" />
<div class="file-thumb" />
<button type="button" class="file-add" aria-label="افزودن فایل">+</button>
</div>
</section>
<section class="reminder-box">
<div class="reminder-box__head">
<h3>یادآور</h3>
<button
type="button"
class="toggle"
:class="{ 'toggle--on': reminderOn }"
:aria-pressed="reminderOn"
@click="reminderOn = !reminderOn"
>
<span />
</button>
</div>
<template v-if="reminderOn">
<label class="field">
<span>عنوان یادآور</span>
<input v-model="reminderTitle" type="text" />
</label>
<div class="two-cols">
<label class="field">
<span>تاریخ یادآور</span>
<input v-model="reminderDate" type="text" />
</label>
<label class="field">
<span>ساعت یادآور</span>
<input v-model="reminderTime" type="text" />
</label>
</div>
<label class="field">
<span>یادداشت یادآور (اختیاری)</span>
<input v-model="reminderNote" type="text" />
</label>
</template>
</section>
</div>
<footer class="footer">
<button type="button" class="submit" :disabled="saving" @click="submit">
{{ saving ? 'در حال ثبت...' : 'ثبت گزارش و یادآور' }}
</button>
</footer>
</div>
</template>
<style scoped>
.meeting-page {
background: var(--color-bg);
}
.meeting-scroll {
padding: 8px var(--page-padding) 100px;
display: flex;
flex-direction: column;
gap: 16px;
}
.customer-card {
display: flex;
gap: 12px;
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px;
}
.avatar {
width: 52px;
height: 52px;
border-radius: 999px;
display: grid;
place-items: center;
font-weight: 700;
}
.customer-card p {
margin: 2px 0 0;
color: var(--color-text-muted);
font-size: 0.8rem;
}
h3 {
margin: 0 0 8px;
font-size: 0.9rem;
}
.type-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.submit-icon-btn {
width: 40px;
height: 40px;
border: none;
background: transparent;
color: var(--color-primary);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
}
.submit-icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.type-btn {
border: 1.5px solid var(--color-border);
background: var(--color-surface);
border-radius: 12px;
min-height: 72px;
padding: 8px 6px;
font-size: 0.72rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.type-btn--active {
border-color: var(--color-primary);
border-width: 2px;
color: var(--color-primary);
font-weight: 700;
background: var(--color-primary-soft);
}
.two-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.85rem;
}
.field input,
.field textarea {
border: 1px solid var(--color-border);
border-radius: 12px;
background: #f5f5f5;
padding: 12px;
font: inherit;
resize: vertical;
}
.field em {
font-style: normal;
color: var(--color-text-muted);
font-size: 0.75rem;
align-self: flex-end;
}
.files {
display: flex;
gap: 8px;
}
.file-thumb,
.file-add {
width: 64px;
height: 64px;
border-radius: 12px;
}
.file-thumb {
background: linear-gradient(145deg, #4b5563, #1f2937);
}
.file-add {
border: 1.5px dashed var(--color-border);
background: transparent;
font-size: 1.4rem;
color: var(--color-text-muted);
cursor: pointer;
}
.reminder-box {
background: var(--color-surface);
border-radius: var(--radius-md);
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.reminder-box__head {
display: flex;
justify-content: space-between;
align-items: center;
}
.toggle {
width: 46px;
height: 28px;
border: none;
border-radius: 999px;
background: #d1d5db;
padding: 3px;
cursor: pointer;
}
.toggle span {
display: block;
width: 22px;
height: 22px;
border-radius: 999px;
background: #fff;
transition: transform 0.15s;
}
.toggle--on {
background: var(--color-primary);
}
.toggle--on span {
transform: translateX(-18px);
}
.footer {
position: sticky;
bottom: 0;
padding: 12px var(--page-padding) calc(12px + env(safe-area-inset-bottom, 0));
background: var(--color-surface);
border-top: 1px solid var(--color-border);
}
.submit {
width: 100%;
min-height: 52px;
border: none;
border-radius: 14px;
background: var(--color-primary);
color: #fff;
font-weight: 700;
cursor: pointer;
}
</style>