Align admin orders with the shop pre-order flow.
Admins create product-based pre-orders, price real line items, reach final approval after cash or checks, and only ship eligible orders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,8 +2,8 @@ import { computed, inject, onMounted, provide, reactive, ref, watch, type Inject
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { companiesApi, customersApi, mapAdminOrder, ordersApi } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import type { Company, Customer, Order, RawOrderStatus, SettlementMethod } from '@/types'
|
||||
import { rawStatusLabel } from '@/utils/orderStatus'
|
||||
import type { Company, Customer, Order, RawOrderStatus } from '@/types'
|
||||
import { ORDER_TABS, rawStatusLabel } from '@/utils/orderStatus'
|
||||
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
@@ -14,8 +14,15 @@ export function useOrdersPage() {
|
||||
|
||||
const companies = ref<Company[]>([])
|
||||
const customers = ref<Customer[]>([])
|
||||
const TAB_KEYS = new Set<string>(ORDER_TABS.map((tab) => tab.key))
|
||||
|
||||
function tabFromQuery() {
|
||||
const tab = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||
return TAB_KEYS.has(tab) ? tab : 'all'
|
||||
}
|
||||
|
||||
const orderList = ref<Order[]>([])
|
||||
const activeTab = ref('all')
|
||||
const activeTab = ref(tabFromQuery())
|
||||
const selectedOrderId = ref<string>('')
|
||||
const searchQuery = ref('')
|
||||
const showAddModal = ref(false)
|
||||
@@ -27,16 +34,19 @@ export function useOrdersPage() {
|
||||
const emptyForm = () => ({
|
||||
customerId: '',
|
||||
customerName: '',
|
||||
companyId: companies.value[0]?.id ?? '',
|
||||
settlementMethod: 'check' as SettlementMethod,
|
||||
meterage: 1000,
|
||||
customerPhone: '',
|
||||
items: [] as Array<{
|
||||
productId: string
|
||||
productName: string
|
||||
catalogCode: string
|
||||
meterage: number
|
||||
}>,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isFormValid = computed(
|
||||
() => form.customerId.length > 0 && form.companyId.length > 0,
|
||||
() => form.customerId.length > 0 && form.items.length > 0,
|
||||
)
|
||||
|
||||
const activeCustomers = computed(() => {
|
||||
@@ -120,9 +130,6 @@ export function useOrdersPage() {
|
||||
orderList.value = ordersData.map((o) => mapAdminOrder(o) as Order)
|
||||
companies.value = companiesData
|
||||
customers.value = customersData
|
||||
if (!form.companyId && companiesData[0]) {
|
||||
form.companyId = companiesData[0].id
|
||||
}
|
||||
await applyOrderDeepLink()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سفارشها انجام نشد.')
|
||||
@@ -140,6 +147,22 @@ export function useOrdersPage() {
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
() => {
|
||||
const next = tabFromQuery()
|
||||
if (activeTab.value !== next) activeTab.value = next
|
||||
},
|
||||
)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
const current = typeof route.query.tab === 'string' ? route.query.tab : 'all'
|
||||
const next = tab === 'all' ? undefined : tab
|
||||
const currentNorm = current === 'all' ? undefined : current
|
||||
if (currentNorm === next) return
|
||||
void router.replace({ query: { ...route.query, tab: next } })
|
||||
})
|
||||
|
||||
function selectOrder(order: Order, openDetail = false) {
|
||||
selectedOrderId.value = order.id
|
||||
if (openDetail) {
|
||||
@@ -220,22 +243,42 @@ export function useOrdersPage() {
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function addFormItem(item: {
|
||||
productId: string
|
||||
productName: string
|
||||
catalogCode: string
|
||||
meterage: number
|
||||
}) {
|
||||
if (form.items.some((row) => row.productId === item.productId)) {
|
||||
formError.value = 'این محصول قبلاً به سفارش اضافه شده است.'
|
||||
return false
|
||||
}
|
||||
form.items.push(item)
|
||||
formError.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
function removeFormItem(productId: string) {
|
||||
form.items = form.items.filter((row) => row.productId !== productId)
|
||||
}
|
||||
|
||||
async function submitAddOrder() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'انتخاب مشتری و شرکت الزامی است.'
|
||||
formError.value = 'انتخاب مشتری و حداقل یک محصول الزامی است.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const created = await ordersApi.create({
|
||||
companyId: form.companyId,
|
||||
customerId: form.customerId || undefined,
|
||||
userId: form.customerId || undefined,
|
||||
customerName: form.customerName.trim(),
|
||||
customerPhone: form.customerPhone.trim() || undefined,
|
||||
settlementMethod: form.settlementMethod,
|
||||
meterage: form.meterage,
|
||||
items: form.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
meterage: item.meterage,
|
||||
})),
|
||||
})
|
||||
const mapped = mapAdminOrder(created) as Order
|
||||
orderList.value.unshift(mapped)
|
||||
@@ -264,6 +307,8 @@ export function useOrdersPage() {
|
||||
showDetailModal,
|
||||
formError,
|
||||
form,
|
||||
addFormItem,
|
||||
removeFormItem,
|
||||
isFormValid,
|
||||
filteredOrders,
|
||||
selectedOrder,
|
||||
|
||||
+6
-2
@@ -228,7 +228,7 @@ export const customersApi = {
|
||||
/* ── Orders ────────────────────────────────────────────────── */
|
||||
|
||||
export type AdminCreateOrderPayload = {
|
||||
companyId: string
|
||||
companyId?: string
|
||||
customerId?: string
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
@@ -237,10 +237,13 @@ export type AdminCreateOrderPayload = {
|
||||
settlementType?: SettlementMethod
|
||||
meterage?: number
|
||||
notes?: string
|
||||
items?: Array<{ productId: string; meterage: number; notes?: string }>
|
||||
}
|
||||
|
||||
export type OrderItemPricingPayload = {
|
||||
id: string
|
||||
id?: string
|
||||
productId?: string
|
||||
requestedMeterage?: number
|
||||
confirmedMeterage?: number
|
||||
unitPrice?: number
|
||||
supplyPrice?: number
|
||||
@@ -250,6 +253,7 @@ export type UpdateOrderPricingPayload = {
|
||||
items: OrderItemPricingPayload[]
|
||||
shippingCost?: number
|
||||
note?: string
|
||||
deletedItemIds?: string[]
|
||||
}
|
||||
|
||||
export type ApiOrder = Order & {
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface BankAccount {
|
||||
|
||||
export interface OrderItem {
|
||||
id: string
|
||||
productId?: string
|
||||
catalogCode: string
|
||||
productName: string
|
||||
color: string
|
||||
|
||||
@@ -74,6 +74,20 @@ export function canPriceOrder(order: {
|
||||
return PRE_INVOICE_ADMIN_STATUSES.includes(order.status)
|
||||
}
|
||||
|
||||
export function canFinalApproveOrder(order: { rawStatus?: RawOrderStatus }): boolean {
|
||||
return order.rawStatus === 'checks_approved' || order.rawStatus === 'cash_confirmed'
|
||||
}
|
||||
|
||||
export const SHIPPABLE_RAW_STATUSES: RawOrderStatus[] = [
|
||||
'final_approved',
|
||||
'preparing',
|
||||
'ready_to_ship',
|
||||
]
|
||||
|
||||
export function canCreateShipment(order: { rawStatus?: RawOrderStatus }): boolean {
|
||||
return !!order.rawStatus && SHIPPABLE_RAW_STATUSES.includes(order.rawStatus)
|
||||
}
|
||||
|
||||
export const ORDER_TABS = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'pending_review', label: 'در انتظار بررسی' },
|
||||
|
||||
@@ -4,7 +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 { cashPaymentsApi } from '@/services/api'
|
||||
import { cashPaymentsApi, ordersApi } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import {
|
||||
CASH_PAYMENT_STATUS_BADGE,
|
||||
@@ -73,6 +73,21 @@ async function approvePayment(payment: CashPayment) {
|
||||
}
|
||||
}
|
||||
|
||||
async function finalApproveOrder(payment: CashPayment) {
|
||||
busyId.value = payment.id
|
||||
try {
|
||||
await ordersApi.updateStatus(payment.orderId, {
|
||||
status: 'final_approved',
|
||||
note: 'تأیید نهایی پس از تأیید رسید نقدی',
|
||||
})
|
||||
toast.success(`سفارش ${payment.orderNumber} تأیید نهایی شد.`)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'تأیید نهایی انجام نشد.')
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectPayment(payment: CashPayment) {
|
||||
busyId.value = payment.id
|
||||
try {
|
||||
@@ -165,6 +180,16 @@ async function rejectPayment(payment: CashPayment) {
|
||||
رد
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="payment.status === 'approved'" class="admin-action-group">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary admin-btn--xs"
|
||||
:disabled="busyId === payment.id"
|
||||
@click="finalApproveOrder(payment)"
|
||||
>
|
||||
تأیید نهایی سفارش
|
||||
</button>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -187,8 +212,12 @@ async function rejectPayment(payment: CashPayment) {
|
||||
<span v-if="payment.paidAmount != null">پرداختی: {{ formatCurrency(payment.paidAmount) }}</span>
|
||||
<span dir="ltr">{{ payment.trackingCode }}</span>
|
||||
</div>
|
||||
<div v-if="payment.status === 'pending_review'" class="admin-card-item__actions">
|
||||
<div
|
||||
v-if="payment.status === 'pending_review' || payment.status === 'approved'"
|
||||
class="admin-card-item__actions"
|
||||
>
|
||||
<button
|
||||
v-if="payment.status === 'pending_review'"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-success admin-btn--xs"
|
||||
:disabled="busyId === payment.id"
|
||||
@@ -197,6 +226,7 @@ async function rejectPayment(payment: CashPayment) {
|
||||
تأیید
|
||||
</button>
|
||||
<button
|
||||
v-if="payment.status === 'pending_review'"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||||
:disabled="busyId === payment.id"
|
||||
@@ -204,6 +234,15 @@ async function rejectPayment(payment: CashPayment) {
|
||||
>
|
||||
رد
|
||||
</button>
|
||||
<button
|
||||
v-if="payment.status === 'approved'"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary admin-btn--xs"
|
||||
:disabled="busyId === payment.id"
|
||||
@click="finalApproveOrder(payment)"
|
||||
>
|
||||
تأیید نهایی سفارش
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ const allChecksApproved = computed(
|
||||
const canFinalApprove = computed(() => {
|
||||
if (!allChecksApproved.value || !order.value) return false
|
||||
const status = (order.value.rawStatus ?? order.value.status) as RawOrderStatus
|
||||
return status === 'checks_approved' || status === 'awaiting_check_review'
|
||||
return status === 'checks_approved'
|
||||
})
|
||||
|
||||
const finalizing = ref(false)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import AdminSelect from '@/components/ui/AdminSelect.vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
|
||||
import { mapAdminOrder, ordersApi, productsApi, 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'
|
||||
import type { OrderItem, Product, RawOrderStatus } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -27,24 +28,28 @@ const formError = ref('')
|
||||
const saving = ref(false)
|
||||
const sending = ref(false)
|
||||
const loading = ref(true)
|
||||
const products = ref<Product[]>([])
|
||||
const removedItemIds = ref<string[]>([])
|
||||
|
||||
const emptyForm = () => ({
|
||||
catalogCode: '',
|
||||
productName: '',
|
||||
color: '',
|
||||
requestedMeterage: 100,
|
||||
confirmedMeterage: 100,
|
||||
productId: '',
|
||||
requestedMeterage: 50,
|
||||
confirmedMeterage: 50,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isFormValid = computed(
|
||||
() =>
|
||||
form.catalogCode.trim().length > 0 &&
|
||||
form.productName.trim().length > 0 &&
|
||||
form.requestedMeterage > 0,
|
||||
const productOptions = computed(() =>
|
||||
products.value
|
||||
.filter((product) => product.isActive !== false)
|
||||
.map((product) => ({
|
||||
value: product.id,
|
||||
label: `${product.name} — ${product.catalogCode} (${product.companyName})`,
|
||||
})),
|
||||
)
|
||||
|
||||
const isFormValid = computed(() => form.productId.length > 0 && form.requestedMeterage > 0)
|
||||
|
||||
const displayOrder = computed(() =>
|
||||
order.value ? mapAdminOrder(order.value) : null,
|
||||
)
|
||||
@@ -63,19 +68,10 @@ async function loadOrder() {
|
||||
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,
|
||||
companyId: item.companyId,
|
||||
companyName: item.companyName,
|
||||
}))
|
||||
items.value = (data.items ?? []).map(mapPricedItem)
|
||||
shippingCost.value = data.shippingCost ?? 0
|
||||
removedItemIds.value = []
|
||||
products.value = await productsApi.list({ activeOnly: true })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سفارش انجام نشد.')
|
||||
} finally {
|
||||
@@ -87,6 +83,22 @@ onMounted(() => {
|
||||
void loadOrder()
|
||||
})
|
||||
|
||||
function mapPricedItem(item: OrderItem): OrderItem {
|
||||
return {
|
||||
id: item.id,
|
||||
productId: item.productId,
|
||||
catalogCode: item.catalogCode,
|
||||
productName: item.productName,
|
||||
color: item.color,
|
||||
requestedMeterage: item.requestedMeterage,
|
||||
confirmedMeterage: item.confirmedMeterage,
|
||||
supplyPrice: item.supplyPrice,
|
||||
totalAmount: item.totalAmount,
|
||||
companyId: item.companyId,
|
||||
companyName: item.companyName,
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/orders')
|
||||
}
|
||||
@@ -103,9 +115,21 @@ async function removeItem(id: string) {
|
||||
tone: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
if (!id.startsWith('new-')) removedItemIds.value.push(id)
|
||||
items.value = items.value.filter((i) => i.id !== id)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => form.productId,
|
||||
(id) => {
|
||||
const product = products.value.find((entry) => entry.id === id)
|
||||
if (!product) return
|
||||
const meters = product.rollMeters || product.minMeterage || product.orderStep || 50
|
||||
form.requestedMeterage = meters
|
||||
form.confirmedMeterage = meters
|
||||
},
|
||||
)
|
||||
|
||||
function openAddModal() {
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
@@ -119,21 +143,36 @@ function closeAddModal() {
|
||||
|
||||
function submitAddProduct() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'کالیته، نام محصول و متراژ الزامی هستند.'
|
||||
formError.value = 'محصول و متراژ الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
const product = products.value.find((entry) => entry.id === form.productId)
|
||||
if (!product) {
|
||||
formError.value = 'محصول انتخابشده یافت نشد.'
|
||||
return
|
||||
}
|
||||
if (
|
||||
items.value.some(
|
||||
(entry) => entry.productId === product.id || (entry.catalogCode === product.catalogCode && entry.productName === product.name),
|
||||
)
|
||||
) {
|
||||
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() || '—',
|
||||
id: `new-${crypto.randomUUID().slice(0, 8)}`,
|
||||
productId: product.id,
|
||||
catalogCode: product.catalogCode,
|
||||
productName: product.name,
|
||||
color: product.color || '—',
|
||||
requestedMeterage: form.requestedMeterage,
|
||||
confirmedMeterage: form.confirmedMeterage || form.requestedMeterage,
|
||||
supplyPrice: null,
|
||||
supplyPrice: product.price,
|
||||
totalAmount: null,
|
||||
companyId: order.value?.companyId,
|
||||
companyName: order.value?.companyName,
|
||||
companyName: product.companyName || order.value?.companyName,
|
||||
})
|
||||
|
||||
closeAddModal()
|
||||
@@ -141,36 +180,35 @@ function submitAddProduct() {
|
||||
|
||||
async function savePricing() {
|
||||
if (!order.value) return
|
||||
const pricedItems = items.value.filter((i) => !i.id.startsWith('oi-'))
|
||||
if (!pricedItems.length) {
|
||||
toast.error('اقلام قابل قیمتگذاری یافت نشد.')
|
||||
if (!items.value.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,
|
||||
})),
|
||||
items: items.value.map((item) =>
|
||||
item.id.startsWith('new-')
|
||||
? {
|
||||
productId: item.productId,
|
||||
requestedMeterage: item.requestedMeterage,
|
||||
confirmedMeterage: item.confirmedMeterage ?? undefined,
|
||||
supplyPrice: item.supplyPrice ?? undefined,
|
||||
}
|
||||
: {
|
||||
id: item.id,
|
||||
confirmedMeterage: item.confirmedMeterage ?? undefined,
|
||||
supplyPrice: item.supplyPrice ?? undefined,
|
||||
},
|
||||
),
|
||||
deletedItemIds: removedItemIds.value,
|
||||
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,
|
||||
companyId: item.companyId,
|
||||
companyName: item.companyName,
|
||||
}))
|
||||
items.value = (updated.items ?? []).map(mapPricedItem)
|
||||
removedItemIds.value = []
|
||||
toast.success('قیمتگذاری ذخیره شد.')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'ذخیره قیمتگذاری انجام نشد.')
|
||||
@@ -414,37 +452,16 @@ async function sendInvoice() {
|
||||
<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="آبی"
|
||||
<label>محصول <span class="admin-form__required">*</span></label>
|
||||
<AdminSelect
|
||||
v-model="form.productId"
|
||||
block
|
||||
searchable
|
||||
aria-label="انتخاب محصول"
|
||||
placeholder="جستجو و انتخاب محصول"
|
||||
search-placeholder="نام، کد یا کالیته…"
|
||||
:options="productOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -88,6 +88,15 @@ function handleCashReceipt() {
|
||||
closeDetailPanel()
|
||||
goCashReceipt(selectedOrder.value)
|
||||
}
|
||||
|
||||
function handleFinalApprove() {
|
||||
if (!selectedOrder.value) return
|
||||
void changeStatus({
|
||||
orderId: selectedOrder.value.id,
|
||||
status: 'final_approved',
|
||||
note: 'تأیید نهایی سفارش',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -219,6 +228,7 @@ function handleCashReceipt() {
|
||||
@pricing="handlePricing"
|
||||
@checks="handleChecks"
|
||||
@cash-receipt="handleCashReceipt"
|
||||
@final-approve="handleFinalApprove"
|
||||
@status="changeStatus"
|
||||
/>
|
||||
</div>
|
||||
@@ -240,6 +250,7 @@ function handleCashReceipt() {
|
||||
@pricing="handlePricing"
|
||||
@checks="handleChecks"
|
||||
@cash-receipt="handleCashReceipt"
|
||||
@final-approve="handleFinalApprove"
|
||||
@status="changeStatus"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
|
||||
import { useOrdersPageContext } from '@/composables/useOrdersPage'
|
||||
import { ORDER_TABS, canPriceOrder } from '@/utils/orderStatus'
|
||||
import { ORDER_TABS, canFinalApproveOrder, canPriceOrder } from '@/utils/orderStatus'
|
||||
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
|
||||
import type { Order } from '@/types'
|
||||
|
||||
@@ -95,6 +95,15 @@ function handleCashReceipt() {
|
||||
goCashReceipt(selectedOrder.value)
|
||||
}
|
||||
|
||||
function handleFinalApprove() {
|
||||
if (!selectedOrder.value) return
|
||||
void changeStatus({
|
||||
orderId: selectedOrder.value.id,
|
||||
status: 'final_approved',
|
||||
note: 'تأیید نهایی سفارش',
|
||||
})
|
||||
}
|
||||
|
||||
function printOrderDetail() {
|
||||
if (!selectedOrder.value) return
|
||||
document.documentElement.classList.add('printing-order-detail')
|
||||
@@ -107,7 +116,7 @@ function printOrderDetail() {
|
||||
window.print()
|
||||
}
|
||||
|
||||
function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash' | 'final') {
|
||||
const order = menuOrder.value
|
||||
if (!order) return
|
||||
closeMenu()
|
||||
@@ -123,6 +132,14 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
goChecks(order)
|
||||
return
|
||||
}
|
||||
if (action === 'final') {
|
||||
void changeStatus({
|
||||
orderId: order.id,
|
||||
status: 'final_approved',
|
||||
note: 'تأیید نهایی سفارش',
|
||||
})
|
||||
return
|
||||
}
|
||||
goCashReceipt(order)
|
||||
}
|
||||
</script>
|
||||
@@ -261,7 +278,7 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
مدیریت چکها
|
||||
</button>
|
||||
<button
|
||||
v-if="menuOrder.settlementMethod === 'cash'"
|
||||
v-if="menuOrder.settlementMethod === 'cash' && menuOrder.rawStatus !== 'cash_confirmed'"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="orders-page__menu-item"
|
||||
@@ -269,6 +286,15 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
>
|
||||
رسید پرداخت
|
||||
</button>
|
||||
<button
|
||||
v-if="canFinalApproveOrder(menuOrder)"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="orders-page__menu-item"
|
||||
@click="runMenuAction('final')"
|
||||
>
|
||||
تأیید نهایی
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -287,6 +313,7 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
@pricing="handlePricing"
|
||||
@checks="handleChecks"
|
||||
@cash-receipt="handleCashReceipt"
|
||||
@final-approve="handleFinalApprove"
|
||||
@status="changeStatus"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
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 { ApiError } from '@/services/http'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import type { Product } from '@/types'
|
||||
|
||||
const {
|
||||
showAddModal,
|
||||
formError,
|
||||
form,
|
||||
isFormValid,
|
||||
companies,
|
||||
activeCustomers,
|
||||
applyCustomer,
|
||||
addFormItem,
|
||||
removeFormItem,
|
||||
closeAddModal,
|
||||
submitAddOrder,
|
||||
} = useOrdersPageContext()
|
||||
|
||||
const companyOptions = computed(() =>
|
||||
companies.value.map((company) => ({ value: company.id, label: company.name })),
|
||||
)
|
||||
const toast = useToast()
|
||||
const products = ref<Product[]>([])
|
||||
const draft = reactive({
|
||||
productId: '',
|
||||
meterage: 0,
|
||||
})
|
||||
|
||||
const customerOptions = computed(() =>
|
||||
activeCustomers.value.map((customer) => {
|
||||
@@ -31,10 +40,18 @@ const customerOptions = computed(() =>
|
||||
}),
|
||||
)
|
||||
|
||||
const settlementOptions = [
|
||||
{ value: 'check', label: 'چکی' },
|
||||
{ value: 'cash', label: 'نقدی' },
|
||||
]
|
||||
const productOptions = computed(() =>
|
||||
products.value
|
||||
.filter((product) => product.isActive !== false)
|
||||
.map((product) => ({
|
||||
value: product.id,
|
||||
label: `${product.name} — ${product.catalogCode} (${product.companyName})`,
|
||||
})),
|
||||
)
|
||||
|
||||
const selectedProduct = computed(() =>
|
||||
products.value.find((product) => product.id === draft.productId) ?? null,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.customerId,
|
||||
@@ -47,16 +64,59 @@ watch(
|
||||
applyCustomer(id)
|
||||
},
|
||||
)
|
||||
|
||||
watch(showAddModal, async (open) => {
|
||||
if (!open) return
|
||||
draft.productId = ''
|
||||
draft.meterage = 0
|
||||
try {
|
||||
products.value = await productsApi.list({ activeOnly: true })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'بارگذاری محصولات انجام نشد.')
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => draft.productId,
|
||||
(id) => {
|
||||
const product = products.value.find((item) => item.id === id)
|
||||
if (!product) return
|
||||
draft.meterage = product.rollMeters || product.minMeterage || product.orderStep || 50
|
||||
},
|
||||
)
|
||||
|
||||
function addDraftProduct() {
|
||||
const product = selectedProduct.value
|
||||
if (!product) {
|
||||
formError.value = 'محصول را انتخاب کنید.'
|
||||
return
|
||||
}
|
||||
if (!draft.meterage || draft.meterage <= 0) {
|
||||
formError.value = 'متراژ باید بزرگتر از صفر باشد.'
|
||||
return
|
||||
}
|
||||
addFormItem({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
catalogCode: product.catalogCode,
|
||||
meterage: draft.meterage,
|
||||
})
|
||||
draft.productId = ''
|
||||
draft.meterage = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="showAddModal" title="سفارش جدید" size="lg" @close="closeAddModal">
|
||||
<BaseModal :open="showAddModal" title="پیشسفارش جدید" size="lg" @close="closeAddModal">
|
||||
<form class="admin-form" @submit.prevent="submitAddOrder">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
<p class="admin-form__hint">
|
||||
مثل سفارش مشتری: ابتدا اقلام ثبت میشود، قیمتگذاری و تسویه بعداً انجام میشود.
|
||||
</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label id="order-customer-label">
|
||||
نام مشتری <span class="admin-form__required">*</span>
|
||||
مشتری <span class="admin-form__required">*</span>
|
||||
</label>
|
||||
<AdminSelect
|
||||
id="order-customer"
|
||||
@@ -75,51 +135,45 @@ watch(
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label>شرکت <span class="admin-form__required">*</span></label>
|
||||
<label>محصول</label>
|
||||
<AdminSelect
|
||||
v-model="form.companyId"
|
||||
v-model="draft.productId"
|
||||
block
|
||||
aria-label="انتخاب شرکت"
|
||||
placeholder="انتخاب شرکت"
|
||||
:options="companyOptions"
|
||||
searchable
|
||||
aria-label="انتخاب محصول"
|
||||
placeholder="جستجو و انتخاب محصول"
|
||||
search-placeholder="نام، کد یا کالیته…"
|
||||
:options="productOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label>روش تسویه</label>
|
||||
<AdminSelect
|
||||
v-model="form.settlementMethod"
|
||||
block
|
||||
aria-label="روش تسویه"
|
||||
:options="settlementOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="order-meterage">متراژ (متر)</label>
|
||||
<label for="order-item-meterage">متراژ (متر)</label>
|
||||
<input
|
||||
id="order-meterage"
|
||||
v-model.number="form.meterage"
|
||||
id="order-item-meterage"
|
||||
v-model.number="draft.meterage"
|
||||
type="number"
|
||||
min="1"
|
||||
class="admin-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="order-phone">تلفن مشتری</label>
|
||||
<input
|
||||
id="order-phone"
|
||||
v-model="form.customerPhone"
|
||||
type="tel"
|
||||
class="admin-input"
|
||||
placeholder="پس از انتخاب مشتری پر میشود"
|
||||
inputmode="tel"
|
||||
dir="ltr"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="addDraftProduct">
|
||||
افزودن به اقلام
|
||||
</button>
|
||||
|
||||
<div v-if="form.items.length" class="order-add__items">
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--outline-danger admin-btn--xs" @click="removeFormItem(item.productId)">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="admin-form__hint">هنوز محصولی به پیشسفارش اضافه نشده است.</p>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -132,7 +186,7 @@ watch(
|
||||
:disabled="!isFormValid"
|
||||
@click="submitAddOrder"
|
||||
>
|
||||
ثبت سفارش
|
||||
ثبت پیشسفارش
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
@@ -144,4 +198,29 @@ watch(
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.order-add__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.order-add__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.order-add__item span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ 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, canPriceOrder, rawStatusLabel } from '@/utils/orderStatus'
|
||||
import { ORDER_STATUS_META, RAW_ORDER_STATUS_LABELS, canFinalApproveOrder, canPriceOrder, rawStatusLabel } from '@/utils/orderStatus'
|
||||
import type { CustomerType, Order, RawOrderStatus } from '@/types'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -22,6 +22,7 @@ const emit = defineEmits<{
|
||||
pricing: []
|
||||
checks: []
|
||||
cashReceipt: []
|
||||
finalApprove: []
|
||||
status: [
|
||||
payload: {
|
||||
orderId: string
|
||||
@@ -80,11 +81,20 @@ const customerTypeLabel = computed(() => {
|
||||
})
|
||||
|
||||
const showPricingAction = computed(() => canPriceOrder(props.order))
|
||||
const showChecksAction = computed(() => props.order.settlementMethod === 'check')
|
||||
const showCashAction = computed(() => props.order.settlementMethod === 'cash')
|
||||
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' | 'checks' | 'cash' | null>(() => {
|
||||
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
|
||||
@@ -151,6 +161,16 @@ function submitStatus() {
|
||||
>
|
||||
رسید پرداخت
|
||||
</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>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiError } from '@/services/http'
|
||||
import { SHIPMENT_STATUS_BADGE, SHIPMENT_STATUS_LABELS } from '@/utils/adminLabels'
|
||||
import { formatDateTime, formatMeterage } from '@/utils/format'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { canCreateShipment } from '@/utils/orderStatus'
|
||||
import type { Order, Shipment, ShipmentStatus } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -70,7 +71,7 @@ const isFormValid = computed(() => form.orderId.trim() && form.carrier.trim())
|
||||
|
||||
const availableOrders = computed(() => {
|
||||
const shippedIds = new Set(shipmentList.value.map((s) => s.orderId))
|
||||
return orders.value.filter((o) => !shippedIds.has(o.id))
|
||||
return orders.value.filter((o) => !shippedIds.has(o.id) && canCreateShipment(o))
|
||||
})
|
||||
|
||||
const orderSelectOptions = computed(() =>
|
||||
|
||||
Reference in New Issue
Block a user