Initialize the user app and load home banners from the API.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user