This commit is contained in:
2026-09-29 22:00:50 +03:30
parent 148f00a75d
commit 4d27da98fc
85 changed files with 865 additions and 522 deletions
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import AdminImageLightbox from '@/components/ui/AdminImageLightbox.vue'
import { cashPaymentsApi, ordersApi } from '@/services/api'
import { ApiError } from '@/services/http'
import {
@@ -21,6 +22,13 @@ const statusFilter = ref<'all' | CashPayment['status']>('all')
const loading = ref(true)
const toast = useToast()
const busyId = ref<string | null>(null)
const receiptPreviewOpen = ref(false)
const receiptPreviewSrc = ref('')
function openReceiptPreview(src: string) {
receiptPreviewSrc.value = src
receiptPreviewOpen.value = true
}
const statusFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
@@ -144,6 +152,7 @@ async function rejectPayment(payment: CashPayment) {
<th>شماره پیگیری</th>
<th>پایان اعتبار</th>
<th>وضعیت</th>
<th>رسید</th>
<th>عملیات</th>
</tr>
</thead>
@@ -161,6 +170,17 @@ async function rejectPayment(payment: CashPayment) {
{{ CASH_PAYMENT_STATUS_LABELS[payment.status] }}
</span>
</td>
<td>
<button
v-if="payment.receiptImage"
type="button"
class="admin-btn admin-btn--outline admin-btn--xs"
@click="openReceiptPreview(payment.receiptImage!)"
>
مشاهده
</button>
<span v-else>—</span>
</td>
<td>
<div v-if="payment.status === 'pending_review'" class="admin-action-group">
<button
@@ -212,6 +232,14 @@ async function rejectPayment(payment: CashPayment) {
<span v-if="payment.paidAmount != null">پرداختی: {{ formatCurrency(payment.paidAmount) }}</span>
<span dir="ltr">{{ payment.trackingCode }}</span>
</div>
<button
v-if="payment.receiptImage"
type="button"
class="admin-btn admin-btn--outline admin-btn--xs cash-payments-page__receipt-btn"
@click="openReceiptPreview(payment.receiptImage!)"
>
مشاهده تصویر رسید
</button>
<div
v-if="payment.status === 'pending_review' || payment.status === 'approved'"
class="admin-card-item__actions"
@@ -247,6 +275,13 @@ async function rejectPayment(payment: CashPayment) {
</article>
</div>
</template>
<AdminImageLightbox
v-if="receiptPreviewSrc"
v-model="receiptPreviewOpen"
:src="receiptPreviewSrc"
alt="تصویر رسید پرداخت"
/>
</div>
</template>
+155 -29
View File
@@ -3,10 +3,17 @@ import { computed, reactive, ref, watch } from 'vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
import { productsApi } from '@/services/api'
import { productsApi, settingsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatNumber } from '@/utils/format'
import {
meterageFromQuantity,
rollMetersForProduct,
rollsFromMeterage,
validateOrderMeterage,
type QuantityMode,
} from '@/utils/orderItemValidation'
import type { Product } from '@/types'
const {
@@ -24,9 +31,11 @@ const {
const toast = useToast()
const products = ref<Product[]>([])
const minOrderRolls = ref(1)
const draft = reactive({
productId: '',
meterage: 0,
quantityMode: 'roll' as QuantityMode,
quantity: 0,
})
const customerOptions = computed(() =>
@@ -53,6 +62,10 @@ const selectedProduct = computed(() =>
products.value.find((product) => product.id === draft.productId) ?? null,
)
const quantityLabel = computed(() =>
draft.quantityMode === 'roll' ? 'تعداد طاقه' : 'متراژ (متر)',
)
watch(
() => form.customerId,
(id) => {
@@ -65,44 +78,85 @@ watch(
},
)
function defaultQuantity(product: Product, mode: QuantityMode) {
const minRolls = minOrderRolls.value > 0 ? minOrderRolls.value : 1
if (mode === 'roll') return minRolls
const rollMeters = rollMetersForProduct(product)
return Math.max(product.minMeterage || 0, minRolls * rollMeters)
}
watch(showAddModal, async (open) => {
if (!open) return
draft.productId = ''
draft.meterage = 0
draft.quantityMode = 'roll'
draft.quantity = 0
formError.value = ''
try {
products.value = await productsApi.list({ activeOnly: true })
const [list, settings] = await Promise.all([
productsApi.list({ activeOnly: true }),
settingsApi.get().catch(() => null),
])
products.value = list
minOrderRolls.value = settings?.minOrderRollsPerProduct ?? 1
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری محصولات انجام نشد.')
}
})
watch(
() => draft.productId,
(id) => {
() => [draft.productId, draft.quantityMode] as const,
([id, mode]) => {
const product = products.value.find((item) => item.id === id)
if (!product) return
draft.meterage = product.rollMeters || product.minMeterage || product.orderStep || 50
draft.quantity = defaultQuantity(product, mode)
},
)
function addDraftProduct() {
formError.value = ''
const product = selectedProduct.value
if (!product) {
formError.value = 'محصول را انتخاب کنید.'
toast.error(formError.value)
return
}
if (!draft.meterage || draft.meterage <= 0) {
formError.value = 'متراژ باید بزرگ‌تر از صفر باشد.'
if (!draft.quantity || draft.quantity <= 0) {
formError.value = 'مقدار باید بزرگ‌تر از صفر باشد.'
toast.error(formError.value)
return
}
addFormItem({
const meterage = meterageFromQuantity(product, draft.quantityMode, draft.quantity)
const validationError = validateOrderMeterage(product, meterage, minOrderRolls.value)
if (validationError) {
formError.value = validationError
toast.error(validationError)
return
}
const added = addFormItem({
productId: product.id,
productName: product.name,
catalogCode: product.catalogCode,
meterage: draft.meterage,
meterage,
})
if (!added) {
toast.error(formError.value || 'افزودن محصول انجام نشد.')
return
}
draft.productId = ''
draft.meterage = 0
draft.quantityMode = 'roll'
draft.quantity = 0
}
function itemRollsLabel(item: { productId: string; meterage: number }) {
const product = products.value.find((p) => p.id === item.productId)
if (!product) return null
const rolls = rollsFromMeterage(product, item.meterage)
if (!Number.isFinite(rolls)) return null
const rounded = Math.round(rolls * 100) / 100
return rounded % 1 === 0 ? formatNumber(rounded) : formatNumber(rounded)
}
</script>
@@ -133,26 +187,49 @@ function addDraftProduct() {
</p>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label>محصول</label>
<AdminSelect
v-model="draft.productId"
block
searchable
aria-label="انتخاب محصول"
placeholder="جستجو و انتخاب محصول"
search-placeholder="نام، کد یا کالیته…"
:options="productOptions"
/>
</div>
<div class="admin-form__row order-add__qty-row">
<div class="admin-form__field">
<label>محصول</label>
<AdminSelect
v-model="draft.productId"
block
searchable
aria-label="انتخاب محصول"
placeholder="جستجو و انتخاب محصول"
search-placeholder="نام، کد یا کالیته…"
:options="productOptions"
/>
<span class="admin-form__label">واحد سفارش</span>
<div class="order-add__mode" role="group" aria-label="واحد سفارش">
<button
type="button"
class="order-add__mode-btn"
:class="{ 'order-add__mode-btn--active': draft.quantityMode === 'roll' }"
@click="draft.quantityMode = 'roll'"
>
طاقه
</button>
<button
type="button"
class="order-add__mode-btn"
:class="{ 'order-add__mode-btn--active': draft.quantityMode === 'meter' }"
@click="draft.quantityMode = 'meter'"
>
متراژ
</button>
</div>
</div>
<div class="admin-form__field">
<label for="order-item-meterage">متراژ (متر)</label>
<label for="order-item-quantity">{{ quantityLabel }}</label>
<input
id="order-item-meterage"
v-model.number="draft.meterage"
id="order-item-quantity"
v-model.number="draft.quantity"
type="number"
min="1"
min="0"
step="any"
class="admin-input"
/>
</div>
@@ -166,9 +243,19 @@ function addDraftProduct() {
<div v-for="item in form.items" :key="item.productId" class="order-add__item">
<div>
<strong>{{ item.productName }}</strong>
<span>کالیته {{ item.catalogCode }} — {{ formatNumber(item.meterage) }} متر</span>
<span>
کالیته {{ item.catalogCode }} —
{{ formatNumber(item.meterage) }} متر
<template v-if="itemRollsLabel(item)">
({{ itemRollsLabel(item) }} طاقه)
</template>
</span>
</div>
<button type="button" class="admin-btn admin-btn--outline-danger admin-btn--xs" @click="removeFormItem(item.productId)">
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
@click="removeFormItem(item.productId)"
>
حذف
</button>
</div>
@@ -199,6 +286,45 @@ function addDraftProduct() {
color: var(--color-text-muted);
}
.admin-form__label {
display: block;
margin-bottom: 6px;
font-size: 0.8125rem;
font-weight: 600;
}
.order-add__qty-row {
align-items: end;
}
.order-add__mode {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.order-add__mode-btn {
height: var(--control-height, 40px);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-muted);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
color 0.15s;
}
.order-add__mode-btn--active {
border-color: var(--color-primary);
background: var(--color-primary-soft, rgba(var(--color-primary-rgb), 0.08));
color: var(--color-primary);
}
.order-add__items {
display: flex;
flex-direction: column;
@@ -3,9 +3,10 @@ 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 { 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(
@@ -28,25 +29,19 @@ const emit = defineEmits<{
orderId: string
status: RawOrderStatus
note?: string
reason?: string
force?: boolean
},
]
}>()
const manualMode = ref(false)
const nextStatus = ref<RawOrderStatus | ''>('')
const statusNote = ref('')
const receiptPreviewOpen = ref(false)
const allStatuses = Object.keys(RAW_ORDER_STATUS_LABELS) as RawOrderStatus[]
const cashReceipt = computed(() => props.order.cashReceipt ?? null)
const transitions = computed(() => props.order.allowedTransitions ?? [])
const statusOptions = computed<RawOrderStatus[]>(() =>
manualMode.value
? allStatuses.filter((s) => s !== props.order.rawStatus)
: [...transitions.value],
)
const statusOptions = computed<RawOrderStatus[]>(() => [...transitions.value])
const statusSelectOptions = computed(() => [
{ value: '', label: 'انتخاب کنید…' },
@@ -56,10 +51,7 @@ const statusSelectOptions = computed(() => [
})),
])
const noteRequired = computed(() => manualMode.value)
const canSubmitStatus = computed(
() => !!nextStatus.value && (!noteRequired.value || statusNote.value.trim().length > 0),
)
const canSubmitStatus = computed(() => !!nextStatus.value)
const useTable = computed(() => props.layout === 'desktop')
@@ -100,10 +92,13 @@ const primaryAction = computed<'pricing' | 'final' | 'checks' | 'cash' | null>((
return null
})
watch([() => props.order.id, manualMode], () => {
nextStatus.value = ''
statusNote.value = ''
})
watch(
() => props.order.id,
() => {
nextStatus.value = ''
statusNote.value = ''
},
)
function submitStatus() {
if (!nextStatus.value || !canSubmitStatus.value) return
@@ -112,12 +107,18 @@ function submitStatus() {
orderId: props.order.id,
status: nextStatus.value,
note,
reason: manualMode.value ? note : undefined,
force: manualMode.value || undefined,
})
nextStatus.value = ''
statusNote.value = ''
}
function onCashReceiptAction() {
if (cashReceipt.value?.receiptImage) {
receiptPreviewOpen.value = true
return
}
emit('cashReceipt')
}
</script>
<template>
@@ -157,9 +158,9 @@ function submitStatus() {
type="button"
class="admin-btn admin-btn--sm"
:class="primaryAction === 'cash' ? 'admin-btn--primary' : 'admin-btn--outline'"
@click="emit('cashReceipt')"
@click="onCashReceiptAction"
>
رسید پرداخت
{{ cashReceipt?.receiptImage ? 'مشاهده رسید' : 'رسید پرداخت' }}
</button>
<button
v-if="showFinalApprove"
@@ -275,20 +276,75 @@ function submitStatus() {
</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>
<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>
<p v-if="manualMode" class="order-detail__status-hint">
این گذر خارج از روال استاندارد است؛ ثبت یادداشت اجباری است.
</p>
<p v-else-if="!statusOptions.length" class="order-detail__status-hint">
در این وضعیت گذر خودکاری وجود ندارد. برای تغییر، حالت دستی را فعال کنید.
<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">
@@ -304,9 +360,7 @@ function submitStatus() {
</div>
<div class="admin-form__field">
<label for="status-note">
یادداشت{{ noteRequired ? ' (اجباری)' : ' (اختیاری)' }}
</label>
<label for="status-note">یادداشت (اختیاری)</label>
<input
id="status-note"
v-model="statusNote"
@@ -477,31 +531,33 @@ function submitStatus() {
color: var(--color-text);
}
.order-detail__status-head {
.order-detail__receipt {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
margin-top: 12px;
}
.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);
.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__manual input {
width: 15px;
height: 15px;
accent-color: var(--color-primary);
.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 {