Replace standalone customer create and meeting report pages with overlays, add TimeInput, remove opaque input backgrounds app-wide, improve desktop layouts/skeletons, and refresh shopping, customer, and reminder screens.
403 lines
9.8 KiB
Vue
403 lines
9.8 KiB
Vue
<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, CrmReminder, MeetingInteractionType } from '@/types/crm'
|
||
import PageHeader from '@/components/layout/PageHeader.vue'
|
||
import PageSkeleton from '@/components/ui/PageSkeleton.vue'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const crm = useCrmStore()
|
||
const auth = useAuthStore()
|
||
const id = computed(() => String(route.query.id ?? ''))
|
||
const reminder = ref<CrmReminder | null>(null)
|
||
const customer = ref<CrmCustomer | null>(null)
|
||
const history = ref<CrmMeeting[]>([])
|
||
const loading = ref(true)
|
||
const saving = ref(false)
|
||
const error = ref<string | null>(null)
|
||
|
||
async function load() {
|
||
if (!id.value) {
|
||
reminder.value = null
|
||
loading.value = false
|
||
return
|
||
}
|
||
loading.value = true
|
||
error.value = null
|
||
try {
|
||
const data = (await crm.loadReminderDetail(id.value)) as {
|
||
reminder: CrmReminder
|
||
customer: CrmCustomer
|
||
history: CrmMeeting[]
|
||
}
|
||
reminder.value = data.reminder
|
||
customer.value = data.customer
|
||
history.value = data.history
|
||
} catch (e) {
|
||
reminder.value = null
|
||
error.value = e instanceof Error ? e.message : 'یادآور یافت نشد.'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
watch(id, load)
|
||
|
||
function typeLabel(type: MeetingInteractionType) {
|
||
if (type === 'in_person') return 'ملاقات حضوری'
|
||
if (type === 'call') return 'تماس تلفنی'
|
||
if (type === 'message') return 'پیام'
|
||
return 'یادداشت'
|
||
}
|
||
|
||
async function complete() {
|
||
if (!reminder.value || reminder.value.completed) return
|
||
saving.value = true
|
||
error.value = null
|
||
try {
|
||
await crm.markReminderComplete(reminder.value.id)
|
||
reminder.value = { ...reminder.value, completed: true }
|
||
router.push('/reminders?filter=today')
|
||
} catch (e) {
|
||
error.value = e instanceof Error ? e.message : 'تکمیل یادآور ناموفق بود.'
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
const agentName = computed(() => auth.fullName || 'واسطه')
|
||
const interactionCounts = computed(() => {
|
||
const list = history.value
|
||
return {
|
||
total: list.length,
|
||
inPerson: list.filter((m) => m.type === 'in_person').length,
|
||
call: list.filter((m) => m.type === 'call').length,
|
||
other: list.filter((m) => m.type === 'message' || m.type === 'note').length,
|
||
}
|
||
})
|
||
|
||
const chartBars = computed(() => {
|
||
const bars = Array.from({ length: 6 }, () => ({ a: 0, b: 0, c: 0 }))
|
||
history.value.forEach((meeting, index) => {
|
||
const bar = bars[index % 6]
|
||
if (!bar) return
|
||
if (meeting.type === 'in_person') bar.a += 1
|
||
else if (meeting.type === 'call') bar.b += 1
|
||
else bar.c += 1
|
||
})
|
||
const max = Math.max(1, ...bars.map((b) => b.a + b.b + b.c))
|
||
return bars.map((b) => ({
|
||
a: `${(b.a / max) * 100}%`,
|
||
b: `${(b.b / max) * 100}%`,
|
||
c: `${(b.c / max) * 100}%`,
|
||
}))
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="reminder && customer" class="page reminder-detail">
|
||
<PageHeader title="جزئیات یادآور" show-back centered @back="router.back()" />
|
||
|
||
<div class="page__scroll detail-scroll">
|
||
<p v-if="error" class="form-error">{{ error }}</p>
|
||
<div class="layout">
|
||
<section class="panel">
|
||
<h2>جزئیات یادآور</h2>
|
||
<div class="field">
|
||
<span>عنوان</span>
|
||
<strong>{{ reminder.title }} — {{ reminder.description }}</strong>
|
||
</div>
|
||
<div class="field">
|
||
<span>مشتری</span>
|
||
<div class="customer-line">
|
||
<div class="avatar" :style="{ background: customer.avatarColor }">
|
||
{{ customer.avatarLetter }}
|
||
</div>
|
||
<div>
|
||
<strong>{{ customer.name }}</strong>
|
||
<p>{{ customer.contactName }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<span>فروشنده حامل حضوری</span>
|
||
<strong>{{ agentName }}</strong>
|
||
</div>
|
||
<div class="field">
|
||
<span>تاریخ و زمان</span>
|
||
<strong>{{ reminder.date }} — {{ reminder.time }}</strong>
|
||
</div>
|
||
<div class="field">
|
||
<span>یادداشت</span>
|
||
<strong>{{ reminder.note || '—' }}</strong>
|
||
</div>
|
||
<div class="actions">
|
||
<button type="button" class="btn-outline" @click="router.back()">ویرایش</button>
|
||
<button
|
||
type="button"
|
||
class="btn-primary"
|
||
:disabled="saving || reminder.completed"
|
||
@click="complete"
|
||
>
|
||
{{ reminder.completed ? 'تکمیلشده' : saving ? '...' : 'تکمیل شد' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>تاریخچه تعاملات</h2>
|
||
<article v-for="item in history" :key="item.id" class="history-row">
|
||
<div>
|
||
<strong>{{ typeLabel(item.type) }}</strong>
|
||
<p>{{ item.description }}</p>
|
||
</div>
|
||
<span>{{ item.date }}</span>
|
||
</article>
|
||
<button
|
||
type="button"
|
||
class="see-all"
|
||
@click="router.push({ path: '/customer', query: { id: customer.id, tab: 'reports' } })"
|
||
>
|
||
مشاهده همه
|
||
</button>
|
||
</section>
|
||
|
||
<section class="panel chart-panel">
|
||
<div class="chart-panel__head">
|
||
<h2>نمودار فعالیت</h2>
|
||
<span>تعاملات ثبتشده</span>
|
||
</div>
|
||
<div class="bars">
|
||
<div v-for="(bar, index) in chartBars" :key="index" class="bar">
|
||
<span v-if="bar.c !== '0%'" class="seg seg--c" :style="{ height: bar.c }" />
|
||
<span v-if="bar.b !== '0%'" class="seg seg--b" :style="{ height: bar.b }" />
|
||
<span v-if="bar.a !== '0%'" class="seg seg--a" :style="{ height: bar.a }" />
|
||
</div>
|
||
</div>
|
||
<div class="stats-grid">
|
||
<div><span>کل تعاملات</span><strong>{{ interactionCounts.total.toLocaleString('fa-IR') }} مورد</strong></div>
|
||
<div><span>ملاقات حضوری</span><strong>{{ interactionCounts.inPerson.toLocaleString('fa-IR') }} مورد</strong></div>
|
||
<div><span>تماس تلفنی</span><strong>{{ interactionCounts.call.toLocaleString('fa-IR') }} مورد</strong></div>
|
||
<div><span>یادداشت و پیام</span><strong>{{ interactionCounts.other.toLocaleString('fa-IR') }} مورد</strong></div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
.reminder-detail {
|
||
background: var(--color-bg);
|
||
}
|
||
|
||
.detail-scroll {
|
||
padding: 12px var(--page-padding) 24px;
|
||
}
|
||
|
||
.form-error {
|
||
margin: 0 0 12px;
|
||
color: var(--color-danger, #b91c1c);
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.layout {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
@media (min-width: 1024px) {
|
||
.layout {
|
||
display: grid;
|
||
grid-template-columns: 1.1fr 1fr 1fr;
|
||
align-items: start;
|
||
}
|
||
}
|
||
|
||
.panel {
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 16px;
|
||
}
|
||
|
||
.panel h2 {
|
||
margin: 0 0 14px;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.field {
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.field > span {
|
||
display: block;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.78rem;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.customer-line {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.avatar {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 999px;
|
||
display: grid;
|
||
place-items: center;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.customer-line p {
|
||
margin: 2px 0 0;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.actions {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.btn-outline,
|
||
.btn-primary {
|
||
min-height: 44px;
|
||
border-radius: 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn-outline {
|
||
border: 1px solid var(--color-border);
|
||
background: var(--color-surface);
|
||
}
|
||
|
||
.btn-primary {
|
||
border: none;
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.7;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.history-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.history-row p {
|
||
margin: 2px 0 0;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.see-all {
|
||
width: 100%;
|
||
margin-top: 12px;
|
||
border: none;
|
||
border-radius: 12px;
|
||
background: #eef1ef;
|
||
min-height: 44px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.chart-panel__head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.chart-panel__head span {
|
||
color: var(--color-text-muted);
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.bars {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, 1fr);
|
||
gap: 8px;
|
||
height: 140px;
|
||
align-items: end;
|
||
margin: 16px 0;
|
||
}
|
||
|
||
.bar {
|
||
display: flex;
|
||
flex-direction: column-reverse;
|
||
gap: 2px;
|
||
height: 100%;
|
||
}
|
||
|
||
.seg {
|
||
display: block;
|
||
width: 100%;
|
||
border-radius: 4px 4px 0 0;
|
||
}
|
||
|
||
.seg--a {
|
||
background: var(--color-primary);
|
||
}
|
||
|
||
.seg--b {
|
||
background: #3b82f6;
|
||
}
|
||
|
||
.seg--c {
|
||
background: #a78bfa;
|
||
}
|
||
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
|
||
.stats-grid div {
|
||
background: var(--color-bg);
|
||
border-radius: 12px;
|
||
padding: 10px;
|
||
}
|
||
|
||
.stats-grid span {
|
||
display: block;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.72rem;
|
||
}
|
||
|
||
.stats-grid strong {
|
||
display: block;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.empty {
|
||
padding: 40px;
|
||
text-align: center;
|
||
}
|
||
</style>
|