Files
nademitex_admin/src/views/orders/components/OrderDetailContent.vue
T

537 lines
15 KiB
Vue
Raw Normal View History

<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, formatMeterage, formatNumber } from '@/utils/format'
import { CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
import { ORDER_STATUS_META, RAW_ORDER_STATUS_LABELS, 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
reason?: string
force?: boolean
},
]
}>()
const manualMode = ref(false)
const nextStatus = ref<RawOrderStatus | ''>('')
const statusNote = ref('')
const allStatuses = Object.keys(RAW_ORDER_STATUS_LABELS) as RawOrderStatus[]
const transitions = computed(() => props.order.allowedTransitions ?? [])
const statusOptions = computed<RawOrderStatus[]>(() =>
manualMode.value
? allStatuses.filter((s) => s !== props.order.rawStatus)
: [...transitions.value],
)
const statusSelectOptions = computed(() => [
{ value: '', label: 'انتخاب کنید…' },
...statusOptions.value.map((status) => ({
value: status,
label: rawStatusLabel(status),
})),
])
const noteRequired = computed(() => manualMode.value)
const canSubmitStatus = computed(
() => !!nextStatus.value && (!noteRequired.value || statusNote.value.trim().length > 0),
)
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, manualMode], () => {
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,
reason: manualMode.value ? note : undefined,
force: manualMode.value || undefined,
})
nextStatus.value = ''
statusNote.value = ''
}
</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="emit('cashReceipt')"
>
رسید پرداخت
</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 class="order-detail__section order-detail__status no-print">
<div class="order-detail__status-head">
<h3 class="order-detail__section-title">تغییر وضعیت</h3>
<label class="order-detail__manual">
<input v-model="manualMode" type="checkbox" />
<span>تغییر دستی</span>
</label>
</div>
<p v-if="manualMode" class="order-detail__status-hint">
این گذر خارج از روال استاندارد است؛ ثبت یادداشت اجباری است.
</p>
<p v-else-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">
یادداشت{{ noteRequired ? ' (اجباری)' : ' (اختیاری)' }}
</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__status-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.order-detail__status-head .order-detail__section-title {
margin: 0;
}
.order-detail__manual {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
color: var(--color-text-muted);
cursor: pointer;
}
.order-detail__manual input {
width: 15px;
height: 15px;
accent-color: var(--color-primary);
}
.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>