Files
fabric_trade_admin/src/views/orders/OrderPricingView.vue
T

571 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency, formatNumber } from '@/utils/format'
import { confirm } from '@/composables/useConfirm'
import { useToast } from '@/composables/useToast'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import { PRE_INVOICE_RAW_STATUSES } from '@/utils/orderStatus'
import type { OrderItem, RawOrderStatus } from '@/types'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const order = ref<ApiOrder | null>(null)
const items = ref<OrderItem[]>([])
const shippingCost = ref(0)
const customerNote = ref('')
const showAddModal = ref(false)
const formError = ref('')
const saving = ref(false)
const sending = ref(false)
const loading = ref(true)
const emptyForm = () => ({
catalogCode: '',
productName: '',
color: '',
requestedMeterage: 100,
confirmedMeterage: 100,
})
const form = reactive(emptyForm())
const isFormValid = computed(
() =>
form.catalogCode.trim().length > 0 &&
form.productName.trim().length > 0 &&
form.requestedMeterage > 0,
)
const displayOrder = computed(() =>
order.value ? mapAdminOrder(order.value) : null,
)
/** تا وقتی سفارش در این وضعیت‌هاست، فاکتور برای مشتری ارسال نشده است. */
const invoiceSent = computed(() =>
order.value
? !PRE_INVOICE_RAW_STATUSES.includes(order.value.status as RawOrderStatus)
: false,
)
const canSendInvoice = computed(() => !!order.value?.finalAmount && !invoiceSent.value)
async function loadOrder() {
loading.value = true
try {
const data = await ordersApi.get(String(route.params.id))
order.value = data
items.value = (data.items ?? []).map((item) => ({
id: item.id,
catalogCode: item.catalogCode,
productName: item.productName,
color: item.color,
requestedMeterage: item.requestedMeterage,
confirmedMeterage: item.confirmedMeterage,
supplyPrice: item.supplyPrice,
totalAmount: item.totalAmount,
}))
shippingCost.value = data.shippingCost ?? 0
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سفارش انجام نشد.')
} finally {
loading.value = false
}
}
onMounted(() => {
void loadOrder()
})
function goBack() {
router.push('/orders')
}
async function removeItem(id: string) {
const item = items.value.find((entry) => entry.id === id)
const ok = await confirm({
title: 'حذف محصول',
message: item
? `آیا از حذف «${item.productName}» از سفارش مطمئن هستید؟`
: 'آیا از حذف این محصول از سفارش مطمئن هستید؟',
confirmLabel: 'حذف',
cancelLabel: 'انصراف',
tone: 'danger',
})
if (!ok) return
items.value = items.value.filter((i) => i.id !== id)
}
function openAddModal() {
Object.assign(form, emptyForm())
formError.value = ''
showAddModal.value = true
}
function closeAddModal() {
showAddModal.value = false
formError.value = ''
}
function submitAddProduct() {
if (!isFormValid.value) {
formError.value = 'کالیته، نام محصول و متراژ الزامی هستند.'
return
}
items.value.push({
id: `oi-${crypto.randomUUID().slice(0, 8)}`,
catalogCode: form.catalogCode.trim(),
productName: form.productName.trim(),
color: form.color.trim() || '—',
requestedMeterage: form.requestedMeterage,
confirmedMeterage: form.confirmedMeterage || form.requestedMeterage,
supplyPrice: null,
totalAmount: null,
})
closeAddModal()
}
async function savePricing() {
if (!order.value) return
const pricedItems = items.value.filter((i) => !i.id.startsWith('oi-'))
if (!pricedItems.length) {
toast.error('اقلام قابل قیمت‌گذاری یافت نشد.')
return
}
saving.value = true
try {
const updated = await ordersApi.updatePricing(order.value.id, {
items: pricedItems.map((item) => ({
id: item.id,
confirmedMeterage: item.confirmedMeterage ?? undefined,
supplyPrice: item.supplyPrice ?? undefined,
})),
shippingCost: shippingCost.value,
note: customerNote.value.trim() || undefined,
})
order.value = updated
items.value = (updated.items ?? []).map((item) => ({
id: item.id,
catalogCode: item.catalogCode,
productName: item.productName,
color: item.color,
requestedMeterage: item.requestedMeterage,
confirmedMeterage: item.confirmedMeterage,
supplyPrice: item.supplyPrice,
totalAmount: item.totalAmount,
}))
toast.success('قیمت‌گذاری ذخیره شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره قیمت‌گذاری انجام نشد.')
} finally {
saving.value = false
}
}
async function sendInvoice() {
if (!order.value) return
if (!order.value.finalAmount) {
toast.error('ابتدا قیمت‌گذاری را ذخیره کنید تا مبلغ فاکتور مشخص شود.')
return
}
const ok = await confirm({
title: 'ارسال فاکتور برای مشتری',
message: `فاکتور ${formatCurrency(order.value.finalAmount)} برای مشتری ارسال می‌شود و او روش تسویه را انتخاب می‌کند. ادامه می‌دهید؟`,
confirmLabel: 'ارسال فاکتور',
cancelLabel: 'انصراف',
})
if (!ok) return
sending.value = true
try {
const updated = await ordersApi.updateStatus(order.value.id, {
status: 'awaiting_settlement',
note: customerNote.value.trim() || undefined,
})
order.value = updated
toast.success('فاکتور برای مشتری ارسال شد.')
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ارسال فاکتور انجام نشد.')
} finally {
sending.value = false
}
}
</script>
<template>
<div class="pricing-page">
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">قیمت‌گذاری سفارش</h1>
<p class="admin-page-header__breadcrumb">سفارش‌ها / قیمت‌گذاری سفارش</p>
</div>
<div class="admin-action-group">
<button
type="button"
class="admin-btn admin-btn--success admin-btn--sm"
:disabled="saving || loading"
@click="savePricing"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره قیمت‌گذاری' }}
</button>
<button
type="button"
class="admin-btn admin-btn--primary admin-btn--sm"
:disabled="!canSendInvoice || sending || saving"
@click="sendInvoice"
>
{{ sending ? 'در حال ارسال…' : 'ارسال فاکتور برای مشتری' }}
</button>
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
بازگشت
</button>
</div>
</div>
<template v-if="displayOrder">
<div class="pricing-page__summary admin-card">
<div class="pricing-page__order-meta">
<div>
<strong class="pricing-page__order-id">{{ displayOrder.orderNumber }}</strong>
<p class="pricing-page__customer">{{ displayOrder.customerName }}</p>
<StatusBadge :status="displayOrder.status" />
</div>
</div>
<div class="admin-summary-grid">
<div class="admin-summary-item">
<p class="admin-summary-item__label">مجموع مبلغ</p>
<p class="admin-summary-item__value">
{{ displayOrder.totalAmount ? formatCurrency(displayOrder.totalAmount) : '—' }}
</p>
</div>
<div class="admin-summary-item">
<p class="admin-summary-item__label">هزینه حمل</p>
<p class="admin-summary-item__value">
{{ formatCurrency(shippingCost) }}
</p>
</div>
<div class="admin-summary-item">
<p class="admin-summary-item__label">مبلغ نهایی</p>
<p class="admin-summary-item__value">
{{ order?.finalAmount ? formatCurrency(order.finalAmount) : '—' }}
</p>
</div>
</div>
</div>
<div class="pricing-page__table admin-card desktop-only">
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ردیف</th>
<th>کالیته</th>
<th>محصول</th>
<th>رنگ / نوع</th>
<th>متراژ درخواستی</th>
<th>متراژ تأیید شده</th>
<th>قیمت تأمینی (واحد)</th>
<th>مبلغ کل (تومان)</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.catalogCode }}</td>
<td>{{ item.productName }}</td>
<td>{{ item.color }}</td>
<td>{{ formatNumber(item.requestedMeterage) }}</td>
<td>
<input
v-model.number="item.confirmedMeterage"
type="number"
class="admin-input admin-input--sm"
/>
</td>
<td>
<AdminCurrencyInput
v-model="item.supplyPrice"
input-class="admin-input admin-input--sm"
placeholder="—"
/>
</td>
<td>{{ item.totalAmount ? formatCurrency(item.totalAmount) : '—' }}</td>
<td>
<button
type="button"
class="admin-btn admin-btn--outline-danger admin-btn--xs"
@click="removeItem(item.id)"
>
حذف
</button>
</td>
</tr>
</tbody>
</table>
</div>
<button type="button" class="admin-btn admin-btn--ghost pricing-page__add-btn" @click="openAddModal">
<AdminIcon name="products" :size="16" />
افزودن محصول
</button>
</div>
<div class="compact-only admin-card-list pricing-page__compact-items">
<article v-for="(item, index) in items" :key="item.id" class="admin-card-item">
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ item.productName }}</p>
<span class="pricing-page__item-index">#{{ index + 1 }}</span>
</div>
<div class="admin-card-item__meta">
<span>کالیته {{ item.catalogCode }}</span>
<span>{{ item.color }}</span>
<span>درخواست: {{ formatNumber(item.requestedMeterage) }} متر</span>
</div>
<div class="pricing-page__compact-fields">
<label>
متراژ تأیید شده
<input v-model.number="item.confirmedMeterage" type="number" class="admin-input" />
</label>
<label>
قیمت تأمینی
<AdminCurrencyInput v-model="item.supplyPrice" placeholder="—" />
</label>
<p v-if="item.totalAmount" class="pricing-page__line-total">
مبلغ کل: {{ formatCurrency(item.totalAmount) }}
</p>
</div>
<div class="admin-card-item__actions">
<button type="button" class="admin-btn admin-btn--outline-danger admin-btn--xs" @click="removeItem(item.id)">
حذف
</button>
</div>
</article>
<button type="button" class="admin-btn admin-btn--ghost" @click="openAddModal">
<AdminIcon name="products" :size="16" />
افزودن محصول
</button>
</div>
<div class="admin-form-grid">
<div class="admin-form-field">
<label>هزینه حمل (تومان)</label>
<AdminCurrencyInput v-model="shippingCost" :allow-empty="false" />
</div>
<div class="admin-form-field">
<label>توضیحات برای مشتری</label>
<textarea v-model="customerNote" placeholder="توضیحات خود را وارد کنید..." />
</div>
</div>
<div class="admin-footer-actions">
<button
type="button"
class="admin-btn admin-btn--success"
:disabled="saving"
@click="savePricing"
>
{{ saving ? 'در حال ذخیره…' : 'ذخیره قیمت‌گذاری' }}
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!canSendInvoice || sending || saving"
@click="sendInvoice"
>
{{ sending ? 'در حال ارسال…' : 'ارسال فاکتور برای مشتری' }}
</button>
</div>
<p class="pricing-page__invoice-hint">
<template v-if="invoiceSent">
فاکتور این سفارش برای مشتری ارسال شده است؛ انتخاب روش تسویه در اختیار اوست.
</template>
<template v-else-if="canSendInvoice">
مبلغ نهایی آماده است. با ارسال فاکتور، مشتری برای انتخاب نقدی یا چک مطلع می‌شود.
</template>
<template v-else>
ابتدا قیمت واحد اقلام و هزینه حمل را ذخیره کنید تا مبلغ نهایی فاکتور ساخته شود.
</template>
</p>
</template>
<BaseModal :open="showAddModal" title="افزودن محصول" size="lg" @close="closeAddModal">
<form class="admin-form" @submit.prevent="submitAddProduct">
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="catalog-code">کالیته <span class="admin-form__required">*</span></label>
<input
id="catalog-code"
v-model="form.catalogCode"
type="text"
class="admin-input"
placeholder="377"
/>
</div>
<div class="admin-form__field">
<label for="product-name">محصول <span class="admin-form__required">*</span></label>
<input
id="product-name"
v-model="form.productName"
type="text"
class="admin-input"
placeholder="پارچه آبی"
/>
</div>
</div>
<div class="admin-form__field">
<label for="product-color">رنگ / نوع</label>
<input
id="product-color"
v-model="form.color"
type="text"
class="admin-input"
placeholder="آبی"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="requested-meterage">
متراژ درخواستی <span class="admin-form__required">*</span>
</label>
<input
id="requested-meterage"
v-model.number="form.requestedMeterage"
type="number"
min="1"
class="admin-input"
/>
</div>
<div class="admin-form__field">
<label for="confirmed-meterage">متراژ تأیید شده</label>
<input
id="confirmed-meterage"
v-model.number="form.confirmedMeterage"
type="number"
min="0"
class="admin-input"
/>
</div>
</div>
</form>
<template #footer>
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
انصراف
</button>
<button
type="button"
class="admin-btn admin-btn--primary"
:disabled="!isFormValid"
@click="submitAddProduct"
>
افزودن به سفارش
</button>
</template>
</BaseModal>
</div>
</template>
<style scoped>
.pricing-page__summary {
padding: 20px;
margin-bottom: 16px;
}
.pricing-page__order-meta {
margin-bottom: 16px;
}
.pricing-page__order-id {
font-size: 1rem;
display: block;
margin-bottom: 4px;
}
.pricing-page__customer {
margin: 0 0 8px;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.pricing-page__table {
padding: 0;
overflow: hidden;
margin-bottom: 16px;
}
.pricing-page__add-btn {
margin: 12px 16px 16px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.pricing-page__compact-items {
margin-bottom: 16px;
}
.pricing-page__item-index {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.pricing-page__compact-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.pricing-page__compact-fields label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.75rem;
color: var(--color-text-muted);
min-width: 0;
}
.pricing-page__compact-fields :deep(.admin-input),
.pricing-page__compact-fields :deep(input) {
width: 100%;
min-width: 0;
}
.pricing-page__invoice-hint {
margin: 12px 0 0;
font-size: 0.75rem;
line-height: 1.8;
color: var(--color-text-muted);
}
.pricing-page__line-total {
grid-column: 1 / -1;
margin: 0;
font-size: 0.875rem;
font-weight: 700;
color: var(--color-text);
}
@media (max-width: 479px) {
.pricing-page__compact-fields {
grid-template-columns: 1fr;
}
}
</style>