Gate order actions by status, add final-approve after checks, and select customers in new orders.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -320,6 +320,15 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-input[type='number'],
|
||||
.admin-input[type='tel'],
|
||||
.admin-input[inputmode='numeric'],
|
||||
.admin-input[inputmode='decimal'],
|
||||
.admin-input[inputmode='tel'],
|
||||
.admin-input[dir='ltr'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
select.admin-input {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.75' d='m7 10 5 5 5-5'/%3E%3C/svg%3E");
|
||||
|
||||
@@ -181,7 +181,8 @@
|
||||
|
||||
.admin-detail-panel__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px var(--page-padding);
|
||||
@@ -200,12 +201,23 @@
|
||||
justify-content: center;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
grid-column: 1;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-detail-panel__back:hover {
|
||||
background: var(--color-border-light);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.admin-detail-panel__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.admin-detail-panel__body {
|
||||
|
||||
@@ -39,6 +39,9 @@ const ICON_MAP: Record<string, string> = {
|
||||
filter: 'vuesax-linear:filter',
|
||||
more: 'vuesax-linear:more',
|
||||
'chevron-left': 'vuesax-linear:arrow-left-1',
|
||||
'chevron-right': 'vuesax-linear:arrow-right-1',
|
||||
/** RTL back — same direction as user app IconChevronBack */
|
||||
'chevron-back': 'vuesax-linear:arrow-right-1',
|
||||
'chevron-down': 'vuesax-linear:arrow-down',
|
||||
print: 'vuesax-linear:printer',
|
||||
sms: 'vuesax-linear:sms',
|
||||
|
||||
@@ -49,6 +49,7 @@ function onInput(event: Event) {
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
class="admin-currency-input"
|
||||
:class="inputClass"
|
||||
:value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
@@ -56,3 +57,9 @@ function onInput(event: Event) {
|
||||
@input="onInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-currency-input {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -61,7 +61,9 @@ function onBackdropClick(event: MouseEvent) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
background: rgba(15, 23, 42, 0.52);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { OrderStatus } from '@/types'
|
||||
import { ORDER_STATUS_META } from '@/utils/orderStatus'
|
||||
|
||||
defineProps<{
|
||||
status: OrderStatus | 'active' | 'inactive' | 'pending_check_review'
|
||||
status: OrderStatus | 'active' | 'inactive' | 'pending_check_review' | 'check_approved' | 'check_rejected'
|
||||
}>()
|
||||
|
||||
/** فقط وضعیتهایی که سفارش نیستند */
|
||||
@@ -11,6 +11,8 @@ const extraMeta: Record<string, { label: string; tone: string }> = {
|
||||
active: { label: 'فعال', tone: 'success' },
|
||||
inactive: { label: 'غیرفعال', tone: 'neutral' },
|
||||
pending_check_review: { label: 'در انتظار بررسی', tone: 'warning' },
|
||||
check_approved: { label: 'تأیید شده', tone: 'success' },
|
||||
check_rejected: { label: 'رد شده', tone: 'danger' },
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { computed, inject, onMounted, provide, reactive, ref, watch, type InjectionKey } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { companiesApi, mapAdminOrder, ordersApi } from '@/services/api'
|
||||
import { companiesApi, customersApi, mapAdminOrder, ordersApi } from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import type { Company, Order, RawOrderStatus, SettlementMethod } from '@/types'
|
||||
import type { Company, Customer, Order, RawOrderStatus, SettlementMethod } from '@/types'
|
||||
import { rawStatusLabel } from '@/utils/orderStatus'
|
||||
|
||||
import { useToast } from '@/composables/useToast'
|
||||
@@ -13,6 +13,7 @@ export function useOrdersPage() {
|
||||
const toast = useToast()
|
||||
|
||||
const companies = ref<Company[]>([])
|
||||
const customers = ref<Customer[]>([])
|
||||
const orderList = ref<Order[]>([])
|
||||
const activeTab = ref('all')
|
||||
const selectedOrderId = ref<string>('')
|
||||
@@ -24,6 +25,7 @@ export function useOrdersPage() {
|
||||
const saving = ref(false)
|
||||
|
||||
const emptyForm = () => ({
|
||||
customerId: '',
|
||||
customerName: '',
|
||||
companyId: companies.value[0]?.id ?? '',
|
||||
settlementMethod: 'check' as SettlementMethod,
|
||||
@@ -34,9 +36,18 @@ export function useOrdersPage() {
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isFormValid = computed(
|
||||
() => form.customerName.trim().length > 0 && form.companyId.length > 0,
|
||||
() => form.customerId.length > 0 && form.companyId.length > 0,
|
||||
)
|
||||
|
||||
const activeCustomers = computed(() => customers.value.filter((c) => c.isActive))
|
||||
|
||||
function applyCustomer(customerId: string) {
|
||||
const customer = customers.value.find((c) => c.id === customerId)
|
||||
form.customerId = customerId
|
||||
form.customerName = customer?.name ?? ''
|
||||
form.customerPhone = customer?.phone ?? ''
|
||||
}
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
let list = orderList.value
|
||||
if (activeTab.value !== 'all') {
|
||||
@@ -89,12 +100,14 @@ export function useOrdersPage() {
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [ordersData, companiesData] = await Promise.all([
|
||||
const [ordersData, companiesData, customersData] = await Promise.all([
|
||||
ordersApi.list(),
|
||||
companiesApi.list(),
|
||||
customersApi.list(),
|
||||
])
|
||||
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
|
||||
}
|
||||
@@ -196,7 +209,7 @@ export function useOrdersPage() {
|
||||
|
||||
async function submitAddOrder() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'نام مشتری و شرکت الزامی هستند.'
|
||||
formError.value = 'انتخاب مشتری و شرکت الزامی است.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -204,6 +217,8 @@ export function useOrdersPage() {
|
||||
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,
|
||||
@@ -223,6 +238,9 @@ export function useOrdersPage() {
|
||||
|
||||
return {
|
||||
companies,
|
||||
customers,
|
||||
activeCustomers,
|
||||
applyCustomer,
|
||||
orderList,
|
||||
activeTab,
|
||||
selectedOrderId,
|
||||
|
||||
@@ -211,6 +211,7 @@ export const customersApi = {
|
||||
|
||||
export type AdminCreateOrderPayload = {
|
||||
companyId: string
|
||||
customerId?: string
|
||||
customerName?: string
|
||||
customerPhone?: string
|
||||
userId?: string
|
||||
|
||||
@@ -55,6 +55,25 @@ export function rawStatusLabel(status?: string): string {
|
||||
return RAW_ORDER_STATUS_LABELS[status as RawOrderStatus] ?? status
|
||||
}
|
||||
|
||||
/** وضعیتهایی که هنوز فاکتور صادر نشده و قیمتگذاری اقدام اصلی است. */
|
||||
export const PRE_INVOICE_RAW_STATUSES: RawOrderStatus[] = [
|
||||
'cart',
|
||||
'submitted',
|
||||
'awaiting_review',
|
||||
'pricing',
|
||||
'needs_correction',
|
||||
]
|
||||
|
||||
const PRE_INVOICE_ADMIN_STATUSES: OrderStatus[] = ['pending_review', 'needs_correction']
|
||||
|
||||
export function canPriceOrder(order: {
|
||||
status: OrderStatus
|
||||
rawStatus?: RawOrderStatus
|
||||
}): boolean {
|
||||
if (order.rawStatus) return PRE_INVOICE_RAW_STATUSES.includes(order.rawStatus)
|
||||
return PRE_INVOICE_ADMIN_STATUSES.includes(order.status)
|
||||
}
|
||||
|
||||
export const ORDER_TABS = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'pending_review', label: 'در انتظار بررسی' },
|
||||
|
||||
@@ -61,6 +61,8 @@ async function submit() {
|
||||
class="admin-input login-page__input"
|
||||
placeholder="۰۹۱۲۱۱۱۱۲۲۲۲"
|
||||
autocomplete="username"
|
||||
inputmode="tel"
|
||||
dir="ltr"
|
||||
@input="auth.clearError()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,24 @@ const totalCheckAmount = computed(() =>
|
||||
orderChecks.value.reduce((sum, c) => sum + c.amount, 0),
|
||||
)
|
||||
|
||||
const pendingChecks = computed(() =>
|
||||
orderChecks.value.filter((c) => c.status === 'pending_review'),
|
||||
)
|
||||
|
||||
const allChecksApproved = computed(
|
||||
() =>
|
||||
orderChecks.value.length > 0 &&
|
||||
orderChecks.value.every((c) => c.status === 'approved'),
|
||||
)
|
||||
|
||||
const canFinalApprove = computed(() => {
|
||||
if (!allChecksApproved.value || !order.value) return false
|
||||
const status = order.value.status
|
||||
return status === 'checks_approved' || status === 'awaiting_check_review'
|
||||
})
|
||||
|
||||
const finalizing = ref(false)
|
||||
|
||||
const reviewHistory = computed(() => {
|
||||
const items = order.value?.statusHistory ?? []
|
||||
const checkRelated = new Set([
|
||||
@@ -87,8 +105,8 @@ function goBack() {
|
||||
}
|
||||
|
||||
function checkBadgeStatus(status: Check['status']) {
|
||||
if (status === 'approved') return 'final_approval'
|
||||
if (status === 'rejected') return 'needs_correction'
|
||||
if (status === 'approved') return 'check_approved'
|
||||
if (status === 'rejected') return 'check_rejected'
|
||||
return 'pending_check_review'
|
||||
}
|
||||
|
||||
@@ -177,7 +195,7 @@ async function updateCheckStatus(check: Check, status: Check['status']) {
|
||||
}
|
||||
|
||||
async function approveAll() {
|
||||
const pending = orderChecks.value.filter((c) => c.status === 'pending_review')
|
||||
const pending = pendingChecks.value
|
||||
if (!pending.length) {
|
||||
toast.error('چکی برای تأیید وجود ندارد.')
|
||||
return
|
||||
@@ -198,6 +216,34 @@ async function approveAll() {
|
||||
}
|
||||
toast.success('همه چکهای در انتظار تأیید شدند.')
|
||||
}
|
||||
|
||||
async function finalApproveOrder() {
|
||||
if (!order.value || finalizing.value) return
|
||||
|
||||
const ok = await confirm({
|
||||
title: 'تأیید نهایی سفارش',
|
||||
message:
|
||||
'با تأیید نهایی، سفارش از مرحله بررسی چک خارج میشود و وارد آمادهسازی میگردد. ادامه میدهید؟',
|
||||
tone: 'primary',
|
||||
confirmLabel: 'تأیید نهایی',
|
||||
cancelLabel: 'انصراف',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
finalizing.value = true
|
||||
try {
|
||||
const updated = await ordersApi.updateStatus(order.value.id, {
|
||||
status: 'final_approved',
|
||||
note: 'تأیید نهایی پس از تأیید همه چکها',
|
||||
})
|
||||
order.value = updated
|
||||
toast.success('سفارش تأیید نهایی شد.')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'تأیید نهایی انجام نشد.')
|
||||
} finally {
|
||||
finalizing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -352,14 +398,31 @@ async function approveAll() {
|
||||
|
||||
<div class="checks-page__footer">
|
||||
<p class="checks-page__note">
|
||||
<template v-if="order?.checkRassDate">
|
||||
<template v-if="canFinalApprove">
|
||||
همه چکها تأیید شدهاند. برای ادامه، تأیید نهایی سفارش را ثبت کنید.
|
||||
</template>
|
||||
<template v-else-if="order?.checkRassDate">
|
||||
راس: {{ formatDate(order.checkRassDate) }}
|
||||
</template>
|
||||
<template v-else>راس بر اساس تاریخ ثبت سفارش محاسبه میشود.</template>
|
||||
</p>
|
||||
<div class="admin-action-group">
|
||||
<button type="button" class="admin-btn admin-btn--success admin-btn--sm" @click="approveAll">
|
||||
تأیید همه
|
||||
<button
|
||||
v-if="canFinalApprove"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--success admin-btn--sm"
|
||||
:disabled="finalizing"
|
||||
@click="finalApproveOrder"
|
||||
>
|
||||
{{ finalizing ? 'در حال ثبت…' : 'تأیید نهایی سفارش' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="pendingChecks.length"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--success admin-btn--sm"
|
||||
@click="approveAll"
|
||||
>
|
||||
تأیید همه چکها
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
|
||||
بازگشت
|
||||
|
||||
@@ -218,6 +218,8 @@ function goBack() {
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="1234567890"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,6 +245,8 @@ function goBack() {
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="6104 3378 0077 1234"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
|
||||
@@ -275,11 +275,25 @@ function clearSearch() {
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="customer-phone">تلفن <span class="admin-form__required">*</span></label>
|
||||
<input id="customer-phone" v-model="form.phone" type="text" class="admin-input" />
|
||||
<input
|
||||
id="customer-phone"
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
class="admin-input"
|
||||
inputmode="tel"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="customer-national-id">شناسه ملی <span class="admin-form__required">*</span></label>
|
||||
<input id="customer-national-id" v-model="form.nationalId" type="text" class="admin-input" />
|
||||
<input
|
||||
id="customer-national-id"
|
||||
v-model="form.nationalId"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import { formatCurrency, formatNumber } from '@/utils/format'
|
||||
import { confirm } from '@/composables/useConfirm'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
|
||||
import type { OrderItem } from '@/types'
|
||||
import { PRE_INVOICE_RAW_STATUSES } from '@/utils/orderStatus'
|
||||
import type { OrderItem, RawOrderStatus } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -48,10 +49,10 @@ const displayOrder = computed(() =>
|
||||
)
|
||||
|
||||
/** تا وقتی سفارش در این وضعیتهاست، فاکتور برای مشتری ارسال نشده است. */
|
||||
const PRE_INVOICE_STATUSES = ['cart', 'submitted', 'awaiting_review', 'pricing', 'needs_correction']
|
||||
|
||||
const invoiceSent = computed(() =>
|
||||
order.value ? !PRE_INVOICE_STATUSES.includes(order.value.status) : false,
|
||||
order.value
|
||||
? !PRE_INVOICE_RAW_STATUSES.includes(order.value.status as RawOrderStatus)
|
||||
: false,
|
||||
)
|
||||
|
||||
const canSendInvoice = computed(() => !!order.value?.finalAmount && !invoiceSent.value)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { provideOrdersPage } from '@/composables/useOrdersPage'
|
||||
import DesktopOrdersPage from '@/views/orders/components/DesktopOrdersPage.vue'
|
||||
import CompactOrdersPage from '@/views/orders/components/CompactOrdersPage.vue'
|
||||
import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
|
||||
|
||||
provideOrdersPage()
|
||||
</script>
|
||||
@@ -9,4 +10,5 @@ provideOrdersPage()
|
||||
<template>
|
||||
<DesktopOrdersPage class="desktop-only" />
|
||||
<CompactOrdersPage class="compact-only" />
|
||||
<OrderAddModal />
|
||||
</template>
|
||||
|
||||
@@ -4,12 +4,13 @@ import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
|
||||
import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
|
||||
import { useOrdersPageContext } from '@/composables/useOrdersPage'
|
||||
import { ORDER_TABS } from '@/utils/orderStatus'
|
||||
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
|
||||
|
||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||
/** Matches `.compact-only` visible range (< 1280px) */
|
||||
const isCompactLayout = useMediaQuery('(max-width: 1279px)')
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
@@ -141,7 +142,7 @@ function handleCashReceipt() {
|
||||
aria-label="بازگشت به لیست"
|
||||
@click="closeDetailPanel"
|
||||
>
|
||||
<AdminIcon name="chevron-left" :size="20" />
|
||||
<AdminIcon name="chevron-back" :size="20" />
|
||||
</button>
|
||||
<h2 class="admin-detail-panel__title">جزئیات سفارش</h2>
|
||||
</div>
|
||||
@@ -161,7 +162,7 @@ function handleCashReceipt() {
|
||||
|
||||
<!-- Tablet / small desktop: modal -->
|
||||
<BaseModal
|
||||
:open="!isMobile && showDetailModal && !!selectedOrder"
|
||||
:open="isCompactLayout && !isMobile && showDetailModal && !!selectedOrder"
|
||||
title="جزئیات سفارش"
|
||||
size="xl"
|
||||
@close="closeDetailModal"
|
||||
@@ -179,8 +180,6 @@ function handleCashReceipt() {
|
||||
/>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<OrderAddModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
|
||||
import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
|
||||
import { useOrdersPageContext } from '@/composables/useOrdersPage'
|
||||
import { ORDER_TABS } from '@/utils/orderStatus'
|
||||
import { ORDER_TABS, canPriceOrder } from '@/utils/orderStatus'
|
||||
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
|
||||
import type { Order } from '@/types'
|
||||
|
||||
/** Matches `.desktop-only` in responsive.css (>= 1280px) */
|
||||
const isDesktopLayout = useMediaQuery('(min-width: 1280px)')
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
selectedOrderId,
|
||||
@@ -241,6 +244,7 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
جزئیات سفارش
|
||||
</button>
|
||||
<button
|
||||
v-if="menuOrder && canPriceOrder(menuOrder)"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="orders-page__menu-item"
|
||||
@@ -270,7 +274,7 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
</Teleport>
|
||||
|
||||
<BaseModal
|
||||
:open="showDetailModal && !!selectedOrder"
|
||||
:open="isDesktopLayout && showDetailModal && !!selectedOrder"
|
||||
title="جزئیات سفارش"
|
||||
size="xl"
|
||||
@close="closeDetailModal"
|
||||
@@ -306,8 +310,6 @@ function runMenuAction(action: 'detail' | 'pricing' | 'checks' | 'cash') {
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<OrderAddModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import AdminSelect from '@/components/ui/AdminSelect.vue'
|
||||
import { useOrdersPageContext } from '@/composables/useOrdersPage'
|
||||
@@ -10,6 +10,8 @@ const {
|
||||
form,
|
||||
isFormValid,
|
||||
companies,
|
||||
activeCustomers,
|
||||
applyCustomer,
|
||||
closeAddModal,
|
||||
submitAddOrder,
|
||||
} = useOrdersPageContext()
|
||||
@@ -18,10 +20,31 @@ const companyOptions = computed(() =>
|
||||
companies.value.map((company) => ({ value: company.id, label: company.name })),
|
||||
)
|
||||
|
||||
const customerOptions = computed(() =>
|
||||
activeCustomers.value.map((customer) => ({
|
||||
value: customer.id,
|
||||
label: customer.phone
|
||||
? `${customer.name} — ${customer.phone}`
|
||||
: customer.name,
|
||||
})),
|
||||
)
|
||||
|
||||
const settlementOptions = [
|
||||
{ value: 'check', label: 'چکی' },
|
||||
{ value: 'cash', label: 'نقدی' },
|
||||
]
|
||||
|
||||
watch(
|
||||
() => form.customerId,
|
||||
(id) => {
|
||||
if (!id) {
|
||||
form.customerName = ''
|
||||
form.customerPhone = ''
|
||||
return
|
||||
}
|
||||
applyCustomer(id)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,14 +53,17 @@ const settlementOptions = [
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="order-customer">نام مشتری <span class="admin-form__required">*</span></label>
|
||||
<input
|
||||
id="order-customer"
|
||||
v-model="form.customerName"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="مثال: پارچهسرای نیکو"
|
||||
<label>نام مشتری <span class="admin-form__required">*</span></label>
|
||||
<AdminSelect
|
||||
v-model="form.customerId"
|
||||
block
|
||||
aria-label="انتخاب مشتری"
|
||||
placeholder="انتخاب مشتری از لیست"
|
||||
:options="customerOptions"
|
||||
/>
|
||||
<p v-if="!customerOptions.length" class="admin-form__hint">
|
||||
مشتری فعالی یافت نشد. ابتدا از بخش مشتریان ثبت کنید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
@@ -80,7 +106,10 @@ const settlementOptions = [
|
||||
v-model="form.customerPhone"
|
||||
type="tel"
|
||||
class="admin-input"
|
||||
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
|
||||
placeholder="پس از انتخاب مشتری پر میشود"
|
||||
inputmode="tel"
|
||||
dir="ltr"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,3 +130,11 @@ const settlementOptions = [
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-form__hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,9 @@ 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 { RAW_ORDER_STATUS_LABELS, rawStatusLabel } from '@/utils/orderStatus'
|
||||
import type { Order, RawOrderStatus } from '@/types'
|
||||
import { CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
|
||||
import { ORDER_STATUS_META, RAW_ORDER_STATUS_LABELS, canPriceOrder, rawStatusLabel } from '@/utils/orderStatus'
|
||||
import type { CustomerType, Order, RawOrderStatus } from '@/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -61,6 +62,34 @@ const canSubmitStatus = computed(
|
||||
|
||||
const useTable = computed(() => props.layout === 'desktop')
|
||||
|
||||
const badgeStatusLabel = computed(
|
||||
() => ORDER_STATUS_META[props.order.status]?.label ?? props.order.status,
|
||||
)
|
||||
|
||||
/** Avoid duplicating the same Persian label under the badge. */
|
||||
const distinctRawStatusLabel = computed(() => {
|
||||
const raw = rawStatusLabel(props.order.rawStatus)
|
||||
if (!raw || raw === badgeStatusLabel.value) return ''
|
||||
return raw
|
||||
})
|
||||
|
||||
const customerTypeLabel = computed(() => {
|
||||
const type = props.order.customerType as CustomerType | undefined
|
||||
if (!type) return '—'
|
||||
return CUSTOMER_TYPE_LABELS[type] ?? props.order.customerType ?? '—'
|
||||
})
|
||||
|
||||
const showPricingAction = computed(() => canPriceOrder(props.order))
|
||||
const showChecksAction = computed(() => props.order.settlementMethod === 'check')
|
||||
const showCashAction = computed(() => props.order.settlementMethod === 'cash')
|
||||
/** Primary CTA follows the current workflow stage. */
|
||||
const primaryAction = computed<'pricing' | 'checks' | 'cash' | null>(() => {
|
||||
if (showPricingAction.value) return 'pricing'
|
||||
if (showChecksAction.value) return 'checks'
|
||||
if (showCashAction.value) return 'cash'
|
||||
return null
|
||||
})
|
||||
|
||||
watch([() => props.order.id, manualMode], () => {
|
||||
nextStatus.value = ''
|
||||
statusNote.value = ''
|
||||
@@ -89,25 +118,35 @@ function submitStatus() {
|
||||
<strong>{{ order.orderNumber }}</strong>
|
||||
<StatusBadge :status="order.status" />
|
||||
</div>
|
||||
<p class="order-detail__raw-status">{{ rawStatusLabel(order.rawStatus) }}</p>
|
||||
<p v-if="distinctRawStatusLabel" class="order-detail__raw-status">
|
||||
{{ distinctRawStatusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="order-detail__actions no-print">
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="emit('pricing')">
|
||||
<button
|
||||
v-if="showPricingAction"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--sm"
|
||||
:class="primaryAction === 'pricing' ? 'admin-btn--primary' : 'admin-btn--outline'"
|
||||
@click="emit('pricing')"
|
||||
>
|
||||
قیمتگذاری سفارش
|
||||
</button>
|
||||
<button
|
||||
v-if="order.settlementMethod === 'check'"
|
||||
v-if="showChecksAction"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--sm"
|
||||
class="admin-btn admin-btn--sm"
|
||||
:class="primaryAction === 'checks' ? 'admin-btn--primary' : 'admin-btn--outline'"
|
||||
@click="emit('checks')"
|
||||
>
|
||||
مدیریت چکها
|
||||
</button>
|
||||
<button
|
||||
v-if="order.settlementMethod === 'cash'"
|
||||
v-if="showCashAction"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--sm"
|
||||
class="admin-btn admin-btn--sm"
|
||||
:class="primaryAction === 'cash' ? 'admin-btn--primary' : 'admin-btn--outline'"
|
||||
@click="emit('cashReceipt')"
|
||||
>
|
||||
رسید پرداخت
|
||||
@@ -127,7 +166,7 @@ function submitStatus() {
|
||||
<div class="order-detail__info-row">
|
||||
<AdminIcon name="phone" :size="16" />
|
||||
<span class="order-detail__info-label">تلفن</span>
|
||||
<span class="order-detail__info-value" dir="ltr">{{ order.customerPhone ?? '—' }}</span>
|
||||
<span class="order-detail__info-value order-detail__info-value--phone" dir="ltr">{{ order.customerPhone ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="order-detail__info-row">
|
||||
<AdminIcon name="id-card" :size="16" />
|
||||
@@ -137,7 +176,7 @@ function submitStatus() {
|
||||
<div class="order-detail__info-row">
|
||||
<AdminIcon name="user" :size="16" />
|
||||
<span class="order-detail__info-label">نوع مشتری</span>
|
||||
<span class="order-detail__info-value">{{ order.customerType ?? '—' }}</span>
|
||||
<span class="order-detail__info-value">{{ customerTypeLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -288,13 +327,17 @@ function submitStatus() {
|
||||
|
||||
.order-detail__id {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.order-detail__id strong {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.order-detail__raw-status {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
@@ -359,6 +402,11 @@ function submitStatus() {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.order-detail__info-value--phone {
|
||||
text-align: right;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.order-detail__table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -253,7 +253,14 @@ function clearSearch() {
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="user-phone">تلفن <span class="admin-form__required">*</span></label>
|
||||
<input id="user-phone" v-model="form.phone" type="text" class="admin-input" />
|
||||
<input
|
||||
id="user-phone"
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
class="admin-input"
|
||||
inputmode="tel"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="user-email">ایمیل <span class="admin-form__required">*</span></label>
|
||||
|
||||
Reference in New Issue
Block a user