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:
2026-09-10 21:41:09 +03:30
co-authored by Cursor
parent 37cb1ad859
commit f68fd820e7
12 changed files with 411 additions and 153 deletions
+59 -14
View File
@@ -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
View File
@@ -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 & {
+1
View File
@@ -69,6 +69,7 @@ export interface BankAccount {
export interface OrderItem {
id: string
productId?: string
catalogCode: string
productName: string
color: string
+14
View File
@@ -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: 'در انتظار بررسی' },
+41 -2
View File
@@ -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>
+1 -1
View File
@@ -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)
+99 -82
View File
@@ -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>
+123 -44
View File
@@ -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>
+2 -1
View File
@@ -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(() =>