593 lines
18 KiB
Vue
593 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch } from 'vue'
|
||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||
import AdminSelect from '@/components/ui/AdminSelect.vue'
|
||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||
import { formatCurrency, formatDateTime, formatMeterage, formatNumber } from '@/utils/format'
|
||
import { CASH_PAYMENT_STATUS_LABELS, CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
|
||
import AdminImageLightbox from '@/components/ui/AdminImageLightbox.vue'
|
||
import { ORDER_STATUS_META, canFinalApproveOrder, canPriceOrder, rawStatusLabel } from '@/utils/orderStatus'
|
||
import type { CustomerType, Order, RawOrderStatus } from '@/types'
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
order: Order
|
||
settlementLabel: (method?: string | null) => string
|
||
layout?: 'desktop' | 'compact'
|
||
statusSaving?: boolean
|
||
}>(),
|
||
{ layout: 'compact' },
|
||
)
|
||
|
||
const emit = defineEmits<{
|
||
pricing: []
|
||
checks: []
|
||
cashReceipt: []
|
||
finalApprove: []
|
||
status: [
|
||
payload: {
|
||
orderId: string
|
||
status: RawOrderStatus
|
||
note?: string
|
||
},
|
||
]
|
||
}>()
|
||
|
||
const nextStatus = ref<RawOrderStatus | ''>('')
|
||
const statusNote = ref('')
|
||
const receiptPreviewOpen = ref(false)
|
||
|
||
const cashReceipt = computed(() => props.order.cashReceipt ?? null)
|
||
|
||
const transitions = computed(() => props.order.allowedTransitions ?? [])
|
||
|
||
const statusOptions = computed<RawOrderStatus[]>(() => [...transitions.value])
|
||
|
||
const statusSelectOptions = computed(() => [
|
||
{ value: '', label: 'انتخاب کنید…' },
|
||
...statusOptions.value.map((status) => ({
|
||
value: status,
|
||
label: rawStatusLabel(status),
|
||
})),
|
||
])
|
||
|
||
const canSubmitStatus = computed(() => !!nextStatus.value)
|
||
|
||
const useTable = computed(() => props.layout === 'desktop')
|
||
|
||
const badgeStatusLabel = computed(
|
||
() => ORDER_STATUS_META[props.order.status]?.label ?? props.order.status,
|
||
)
|
||
|
||
/** Avoid duplicating the same Persian label under the badge. */
|
||
const distinctRawStatusLabel = computed(() => {
|
||
const raw = rawStatusLabel(props.order.rawStatus)
|
||
if (!raw || raw === badgeStatusLabel.value) return ''
|
||
return raw
|
||
})
|
||
|
||
const customerTypeLabel = computed(() => {
|
||
const type = props.order.customerType as CustomerType | undefined
|
||
if (!type) return '—'
|
||
return CUSTOMER_TYPE_LABELS[type] ?? props.order.customerType ?? '—'
|
||
})
|
||
|
||
const showPricingAction = computed(() => canPriceOrder(props.order))
|
||
const showFinalApprove = computed(() => canFinalApproveOrder(props.order))
|
||
const showChecksAction = computed(
|
||
() => props.order.settlementMethod === 'check' && !showPricingAction.value,
|
||
)
|
||
const showCashAction = computed(
|
||
() =>
|
||
props.order.settlementMethod === 'cash' &&
|
||
!showPricingAction.value &&
|
||
props.order.rawStatus !== 'cash_confirmed',
|
||
)
|
||
/** Primary CTA follows the current workflow stage. */
|
||
const primaryAction = computed<'pricing' | 'final' | 'checks' | 'cash' | null>(() => {
|
||
if (showPricingAction.value) return 'pricing'
|
||
if (showFinalApprove.value) return 'final'
|
||
if (showChecksAction.value) return 'checks'
|
||
if (showCashAction.value) return 'cash'
|
||
return null
|
||
})
|
||
|
||
watch(
|
||
() => props.order.id,
|
||
() => {
|
||
nextStatus.value = ''
|
||
statusNote.value = ''
|
||
},
|
||
)
|
||
|
||
function submitStatus() {
|
||
if (!nextStatus.value || !canSubmitStatus.value) return
|
||
const note = statusNote.value.trim() || undefined
|
||
emit('status', {
|
||
orderId: props.order.id,
|
||
status: nextStatus.value,
|
||
note,
|
||
})
|
||
nextStatus.value = ''
|
||
statusNote.value = ''
|
||
}
|
||
|
||
function onCashReceiptAction() {
|
||
if (cashReceipt.value?.receiptImage) {
|
||
receiptPreviewOpen.value = true
|
||
return
|
||
}
|
||
emit('cashReceipt')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="order-detail" :class="`order-detail--${layout}`">
|
||
<header class="order-detail__hero">
|
||
<div class="order-detail__hero-text">
|
||
<div class="order-detail__id">
|
||
<strong>{{ order.orderNumber }}</strong>
|
||
<StatusBadge :status="order.status" />
|
||
</div>
|
||
<p v-if="distinctRawStatusLabel" class="order-detail__raw-status">
|
||
{{ distinctRawStatusLabel }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="order-detail__actions no-print">
|
||
<button
|
||
v-if="showPricingAction"
|
||
type="button"
|
||
class="admin-btn admin-btn--sm"
|
||
:class="primaryAction === 'pricing' ? 'admin-btn--primary' : 'admin-btn--outline'"
|
||
@click="emit('pricing')"
|
||
>
|
||
قیمتگذاری سفارش
|
||
</button>
|
||
<button
|
||
v-if="showChecksAction"
|
||
type="button"
|
||
class="admin-btn admin-btn--sm"
|
||
:class="primaryAction === 'checks' ? 'admin-btn--primary' : 'admin-btn--outline'"
|
||
@click="emit('checks')"
|
||
>
|
||
مدیریت چکها
|
||
</button>
|
||
<button
|
||
v-if="showCashAction"
|
||
type="button"
|
||
class="admin-btn admin-btn--sm"
|
||
:class="primaryAction === 'cash' ? 'admin-btn--primary' : 'admin-btn--outline'"
|
||
@click="onCashReceiptAction"
|
||
>
|
||
{{ cashReceipt?.receiptImage ? 'مشاهده رسید' : 'رسید پرداخت' }}
|
||
</button>
|
||
<button
|
||
v-if="showFinalApprove"
|
||
type="button"
|
||
class="admin-btn admin-btn--sm"
|
||
:class="primaryAction === 'final' ? 'admin-btn--primary' : 'admin-btn--outline'"
|
||
:disabled="statusSaving"
|
||
@click="emit('finalApprove')"
|
||
>
|
||
تأیید نهایی
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="order-detail__panels">
|
||
<section class="order-detail__section">
|
||
<h3 class="order-detail__section-title">اطلاعات مشتری</h3>
|
||
<div class="order-detail__info">
|
||
<div class="order-detail__info-row">
|
||
<AdminIcon name="building" :size="16" />
|
||
<span class="order-detail__info-label">مشتری</span>
|
||
<span class="order-detail__info-value">{{ order.customerName }}</span>
|
||
</div>
|
||
<div class="order-detail__info-row">
|
||
<AdminIcon name="phone" :size="16" />
|
||
<span class="order-detail__info-label">تلفن</span>
|
||
<span class="order-detail__info-value order-detail__info-value--phone" dir="ltr">{{ order.customerPhone ?? '—' }}</span>
|
||
</div>
|
||
<div class="order-detail__info-row">
|
||
<AdminIcon name="id-card" :size="16" />
|
||
<span class="order-detail__info-label">شناسه ملی</span>
|
||
<span class="order-detail__info-value">{{ order.customerNationalId ?? '—' }}</span>
|
||
</div>
|
||
<div class="order-detail__info-row">
|
||
<AdminIcon name="user" :size="16" />
|
||
<span class="order-detail__info-label">نوع مشتری</span>
|
||
<span class="order-detail__info-value">{{ customerTypeLabel }}</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="order-detail__section">
|
||
<h3 class="order-detail__section-title">اطلاعات سفارش</h3>
|
||
<div class="order-detail__info">
|
||
<div class="order-detail__info-row">
|
||
<span class="order-detail__info-label">شرکت</span>
|
||
<span class="order-detail__info-value">{{ order.companyName }}</span>
|
||
</div>
|
||
<div class="order-detail__info-row">
|
||
<span class="order-detail__info-label">روش تسویه</span>
|
||
<span class="order-detail__info-value">{{ settlementLabel(order.settlementMethod) }}</span>
|
||
</div>
|
||
<div class="order-detail__info-row">
|
||
<span class="order-detail__info-label">متراژ کل</span>
|
||
<span class="order-detail__info-value">{{ formatMeterage(order.meterage) }}</span>
|
||
</div>
|
||
<div class="order-detail__info-row">
|
||
<span class="order-detail__info-label">مبلغ کل</span>
|
||
<span class="order-detail__info-value">
|
||
{{ order.totalAmount != null ? formatCurrency(order.totalAmount) : 'پس از قیمتگذاری' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section v-if="order.items?.length" class="order-detail__section">
|
||
<h3 class="order-detail__section-title">محصولات سفارش</h3>
|
||
|
||
<div v-if="useTable" class="admin-table-wrap order-detail__table">
|
||
<table class="admin-table admin-table--compact">
|
||
<thead>
|
||
<tr>
|
||
<th>شرکت</th>
|
||
<th>کالیته</th>
|
||
<th>محصول</th>
|
||
<th>رنگ</th>
|
||
<th>متراژ</th>
|
||
<th>قیمت</th>
|
||
<th>مبلغ</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="item in order.items" :key="item.id">
|
||
<td>{{ item.companyName || order.companyName }}</td>
|
||
<td>{{ item.catalogCode }}</td>
|
||
<td>{{ item.productName }}</td>
|
||
<td>{{ item.color }}</td>
|
||
<td>{{ formatNumber(item.confirmedMeterage ?? item.requestedMeterage) }}</td>
|
||
<td>{{ item.supplyPrice != null ? formatCurrency(item.supplyPrice) : '—' }}</td>
|
||
<td>{{ item.totalAmount != null ? formatCurrency(item.totalAmount) : '—' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<ul v-else class="order-detail__items">
|
||
<li v-for="item in order.items" :key="item.id" class="order-detail__item">
|
||
<div class="order-detail__item-head">
|
||
<strong>{{ item.productName }}</strong>
|
||
<span>{{ item.catalogCode }}</span>
|
||
</div>
|
||
<div class="order-detail__item-meta">
|
||
<span v-if="item.companyName">{{ item.companyName }}</span>
|
||
<span>{{ item.color }}</span>
|
||
<span>{{ formatMeterage(item.confirmedMeterage ?? item.requestedMeterage) }}</span>
|
||
<span v-if="item.supplyPrice != null">{{ formatCurrency(item.supplyPrice) }}</span>
|
||
<span v-if="item.totalAmount != null" class="order-detail__item-total">
|
||
{{ formatCurrency(item.totalAmount) }}
|
||
</span>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section v-if="cashReceipt" class="order-detail__section">
|
||
<h3 class="order-detail__section-title">رسید پرداخت نقدی</h3>
|
||
<div class="order-detail__info">
|
||
<div class="order-detail__info-row">
|
||
<span class="order-detail__info-label">وضعیت</span>
|
||
<span class="order-detail__info-value">
|
||
{{ CASH_PAYMENT_STATUS_LABELS[cashReceipt.status] }}
|
||
</span>
|
||
</div>
|
||
<div class="order-detail__info-row">
|
||
<span class="order-detail__info-label">مبلغ واریزی</span>
|
||
<span class="order-detail__info-value">
|
||
{{
|
||
cashReceipt.paidAmount != null
|
||
? formatCurrency(cashReceipt.paidAmount)
|
||
: formatCurrency(cashReceipt.amount)
|
||
}}
|
||
</span>
|
||
</div>
|
||
<div v-if="cashReceipt.transferDate" class="order-detail__info-row">
|
||
<span class="order-detail__info-label">تاریخ واریز</span>
|
||
<span class="order-detail__info-value">
|
||
{{ formatDateTime(cashReceipt.transferDate) }}
|
||
<template v-if="cashReceipt.transferTime"> · {{ cashReceipt.transferTime }}</template>
|
||
</span>
|
||
</div>
|
||
<div v-if="cashReceipt.trackingNumber || cashReceipt.trackingCode" class="order-detail__info-row">
|
||
<span class="order-detail__info-label">پیگیری</span>
|
||
<span class="order-detail__info-value" dir="ltr">
|
||
{{ cashReceipt.trackingNumber || cashReceipt.trackingCode }}
|
||
</span>
|
||
</div>
|
||
<div v-if="cashReceipt.notes" class="order-detail__info-row">
|
||
<span class="order-detail__info-label">یادداشت</span>
|
||
<span class="order-detail__info-value">{{ cashReceipt.notes }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="cashReceipt.receiptImage" class="order-detail__receipt">
|
||
<button
|
||
type="button"
|
||
class="order-detail__receipt-thumb"
|
||
@click="receiptPreviewOpen = true"
|
||
>
|
||
<img :src="cashReceipt.receiptImage" alt="تصویر رسید پرداخت" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="admin-btn admin-btn--outline admin-btn--sm"
|
||
@click="receiptPreviewOpen = true"
|
||
>
|
||
مشاهده تصویر رسید
|
||
</button>
|
||
</div>
|
||
<p v-else class="order-detail__status-hint">تصویر رسید هنوز آپلود نشده است.</p>
|
||
</section>
|
||
|
||
<AdminImageLightbox
|
||
v-if="cashReceipt?.receiptImage"
|
||
v-model="receiptPreviewOpen"
|
||
:src="cashReceipt.receiptImage"
|
||
alt="تصویر رسید پرداخت"
|
||
/>
|
||
|
||
<section class="order-detail__section order-detail__status no-print">
|
||
<h3 class="order-detail__section-title">تغییر وضعیت</h3>
|
||
|
||
<p v-if="!statusOptions.length" class="order-detail__status-hint">
|
||
در این مرحله گذر مجازی از این بخش نیست؛ از اقدامهای مرحلهای بالا (قیمتگذاری، چک، رسید نقدی و …) استفاده کنید.
|
||
</p>
|
||
|
||
<div v-if="statusOptions.length" class="order-detail__status-form">
|
||
<div class="admin-form__field">
|
||
<label>وضعیت بعدی</label>
|
||
<AdminSelect
|
||
v-model="nextStatus"
|
||
block
|
||
aria-label="وضعیت بعدی"
|
||
placeholder="انتخاب کنید…"
|
||
:options="statusSelectOptions"
|
||
/>
|
||
</div>
|
||
|
||
<div class="admin-form__field">
|
||
<label for="status-note">یادداشت (اختیاری)</label>
|
||
<input
|
||
id="status-note"
|
||
v-model="statusNote"
|
||
type="text"
|
||
class="admin-input"
|
||
placeholder="دلیل یا توضیح این تغییر"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="admin-btn admin-btn--primary admin-btn--sm"
|
||
:disabled="!canSubmitStatus || statusSaving"
|
||
@click="submitStatus"
|
||
>
|
||
{{ statusSaving ? 'در حال ثبت…' : 'ثبت وضعیت' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.order-detail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.order-detail__hero {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 14px 16px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-elevated, var(--color-bg));
|
||
}
|
||
|
||
.order-detail__id {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
margin-bottom: 0;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.order-detail__id strong {
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.order-detail__raw-status {
|
||
margin: 0;
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.order-detail__actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.order-detail__actions .admin-btn {
|
||
min-height: 40px;
|
||
}
|
||
|
||
.order-detail__panels {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.order-detail__section {
|
||
margin: 0;
|
||
padding: 14px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-surface);
|
||
}
|
||
|
||
.order-detail__section-title {
|
||
margin: 0 0 12px;
|
||
font-size: 0.8125rem;
|
||
font-weight: 700;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.order-detail__info {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.order-detail__info-row {
|
||
display: grid;
|
||
grid-template-columns: auto minmax(72px, auto) 1fr;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.order-detail__info-row:not(:has(svg)) {
|
||
grid-template-columns: minmax(72px, auto) 1fr;
|
||
}
|
||
|
||
.order-detail__info-label {
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.order-detail__info-value {
|
||
font-weight: 600;
|
||
color: var(--color-text);
|
||
word-break: break-word;
|
||
}
|
||
|
||
.order-detail__info-value--phone {
|
||
text-align: right;
|
||
justify-self: stretch;
|
||
}
|
||
|
||
.order-detail__table {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.order-detail__items {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.order-detail__item {
|
||
padding: 12px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-elevated, var(--color-bg));
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
|
||
.order-detail__item-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.order-detail__item-head span {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.order-detail__item-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px 12px;
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.order-detail__item-total {
|
||
margin-inline-start: auto;
|
||
font-weight: 700;
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.order-detail__receipt {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.order-detail__receipt-thumb {
|
||
padding: 0;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
background: var(--color-bg);
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
line-height: 0;
|
||
}
|
||
|
||
.order-detail__receipt-thumb img {
|
||
display: block;
|
||
width: 120px;
|
||
height: 120px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.order-detail__status .order-detail__section-title {
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.order-detail__status-hint {
|
||
margin: 0 0 12px;
|
||
font-size: 0.75rem;
|
||
line-height: 1.7;
|
||
color: var(--color-text-muted);
|
||
}
|
||
|
||
.order-detail__status-form {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
@media (max-width: 639px) {
|
||
.order-detail__panels {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.order-detail__hero {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.order-detail__actions {
|
||
width: 100%;
|
||
}
|
||
|
||
.order-detail__actions .admin-btn {
|
||
flex: 1;
|
||
}
|
||
}
|
||
</style>
|