Add preparing tab, clarify order status actions and stage banners
This commit is contained in:
@@ -15,7 +15,7 @@ export function useOrdersPage() {
|
||||
const companies = ref<Company[]>([])
|
||||
const customers = ref<Customer[]>([])
|
||||
const orderList = ref<Order[]>([])
|
||||
const activeTab = ref('all')
|
||||
const activeTab = ref(typeof route.query.tab === 'string' && route.query.tab.trim() ? route.query.tab.trim() : 'all')
|
||||
const selectedOrderId = ref<string>('')
|
||||
const searchQuery = ref('')
|
||||
const showAddModal = ref(false)
|
||||
@@ -140,6 +140,15 @@ export function useOrdersPage() {
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(tab) => {
|
||||
if (typeof tab === 'string' && tab.trim()) {
|
||||
activeTab.value = tab.trim()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function selectOrder(order: Order, openDetail = false) {
|
||||
selectedOrderId.value = order.id
|
||||
if (openDetail) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export type OrderStatus =
|
||||
| 'needs_correction'
|
||||
| 'check_correction'
|
||||
| 'final_approval'
|
||||
| 'preparing'
|
||||
| 'shipped'
|
||||
| 'delivered'
|
||||
| 'cancelled'
|
||||
|
||||
@@ -13,6 +13,7 @@ export const ORDER_STATUS_META: Record<OrderStatus, StatusMeta> = {
|
||||
needs_correction: { label: 'نیاز به اصلاح', tone: 'danger' },
|
||||
check_correction: { label: 'نیاز به اصلاح چک', tone: 'warning' },
|
||||
final_approval: { label: 'تأیید نهایی', tone: 'success' },
|
||||
preparing: { label: 'در حال آمادهسازی', tone: 'info' },
|
||||
shipped: { label: 'ارسال شده', tone: 'info' },
|
||||
delivered: { label: 'تحویل شده', tone: 'delivered' },
|
||||
cancelled: { label: 'لغو شده', tone: 'neutral' },
|
||||
@@ -74,6 +75,18 @@ export function canPriceOrder(order: {
|
||||
return PRE_INVOICE_ADMIN_STATUSES.includes(order.status)
|
||||
}
|
||||
|
||||
/** فاکتور صادر شده ولی هنوز تسویه تکمیل نشده و ادمین میتواند در صورت نیاز قیمت را بازبینی کند */
|
||||
export function canEditInvoice(order: {
|
||||
status: OrderStatus
|
||||
rawStatus?: RawOrderStatus
|
||||
}): boolean {
|
||||
const raw = order.rawStatus
|
||||
if (raw) {
|
||||
return raw === 'awaiting_settlement' || raw === 'awaiting_settlement_fix'
|
||||
}
|
||||
return order.status === 'awaiting_customer'
|
||||
}
|
||||
|
||||
export const ORDER_TABS = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'pending_review', label: 'در انتظار بررسی' },
|
||||
@@ -83,6 +96,7 @@ export const ORDER_TABS = [
|
||||
{ key: 'check_correction', label: 'اصلاح چک' },
|
||||
{ key: 'needs_correction', label: 'نیاز به اصلاح' },
|
||||
{ key: 'final_approval', label: 'تأیید نهایی' },
|
||||
{ key: 'preparing', label: 'آمادهسازی' },
|
||||
{ key: 'shipped', label: 'ارسال شده' },
|
||||
{ key: 'delivered', label: 'تحویل شده' },
|
||||
] as const
|
||||
|
||||
@@ -55,7 +55,7 @@ const invoiceSent = computed(() =>
|
||||
: false,
|
||||
)
|
||||
|
||||
const canSendInvoice = computed(() => !!order.value?.finalAmount && !invoiceSent.value)
|
||||
const canSendInvoice = computed(() => !!order.value?.finalAmount)
|
||||
|
||||
async function loadOrder() {
|
||||
loading.value = true
|
||||
@@ -185,10 +185,13 @@ async function sendInvoice() {
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadySent = invoiceSent.value
|
||||
const ok = await confirm({
|
||||
title: 'ارسال فاکتور برای مشتری',
|
||||
message: `فاکتور ${formatCurrency(order.value.finalAmount)} برای مشتری ارسال میشود و او روش تسویه را انتخاب میکند. ادامه میدهید؟`,
|
||||
confirmLabel: 'ارسال فاکتور',
|
||||
title: isAlreadySent ? 'بهروزرسانی فاکتور برای مشتری' : 'ارسال فاکتور برای مشتری',
|
||||
message: isAlreadySent
|
||||
? `فاکتور بهروزشده با مبلغ ${formatCurrency(order.value.finalAmount)} برای مشتری ارسال میشود. ادامه میدهید؟`
|
||||
: `فاکتور ${formatCurrency(order.value.finalAmount)} برای مشتری ارسال میشود و او امکان پرداخت/ثبت چک را خواهد داشت. ادامه میدهید؟`,
|
||||
confirmLabel: isAlreadySent ? 'ارسال مجدد فاکتور' : 'ارسال فاکتور',
|
||||
cancelLabel: 'انصراف',
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -200,7 +203,7 @@ async function sendInvoice() {
|
||||
note: customerNote.value.trim() || undefined,
|
||||
})
|
||||
order.value = updated
|
||||
toast.success('فاکتور برای مشتری ارسال شد.')
|
||||
toast.success(isAlreadySent ? 'فاکتور با موفقیت بهروزرسانی شد.' : 'فاکتور برای مشتری ارسال شد.')
|
||||
} catch (err) {
|
||||
toast.error(err instanceof ApiError ? err.message : 'ارسال فاکتور انجام نشد.')
|
||||
} finally {
|
||||
@@ -231,7 +234,7 @@ async function sendInvoice() {
|
||||
:disabled="!canSendInvoice || sending || saving"
|
||||
@click="sendInvoice"
|
||||
>
|
||||
{{ sending ? 'در حال ارسال…' : 'ارسال فاکتور برای مشتری' }}
|
||||
{{ sending ? 'در حال ارسال…' : invoiceSent ? 'بهروزرسانی و ارسال مجدد فاکتور' : 'ارسال فاکتور برای مشتری' }}
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
|
||||
بازگشت
|
||||
@@ -240,6 +243,21 @@ async function sendInvoice() {
|
||||
</div>
|
||||
|
||||
<template v-if="displayOrder">
|
||||
<div v-if="invoiceSent" class="pricing-page__banner pricing-page__banner--info">
|
||||
<AdminIcon name="info" :size="20" />
|
||||
<div>
|
||||
<strong>فاکتور برای مشتری ارسال شده است (وضعیت: در انتظار تسویه مشتری)</strong>
|
||||
<p>مبالغ این فاکتور در پنل کاربر قابل مشاهده است. در صورت نیاز به تغییر قیمتها، پس از ذخیره مبالغ روی «بهروزرسانی و ارسال مجدد فاکتور» کلیک کنید.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="order?.finalAmount" class="pricing-page__banner pricing-page__banner--warning">
|
||||
<AdminIcon name="alert-circle" :size="20" />
|
||||
<div>
|
||||
<strong>پیشنویس فاکتور ذخیره شده (مخفی از مشتری)</strong>
|
||||
<p>قیمتها در سیستم ذخیره شدهاند، اما هنوز فاکتور صادر نشده است و مشتری قیمتها را مشاهده نمیکند. برای ارسال، دکمه «ارسال فاکتور برای مشتری» را بزنید.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-page__summary admin-card">
|
||||
<div class="pricing-page__order-meta">
|
||||
<div>
|
||||
@@ -491,6 +509,39 @@ async function sendInvoice() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pricing-page__banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pricing-page__banner--info {
|
||||
background: var(--color-info-light, rgba(37, 99, 235, 0.08));
|
||||
border: 1px solid var(--color-info, #2563eb);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.pricing-page__banner--warning {
|
||||
background: var(--color-warning-light, rgba(217, 119, 6, 0.08));
|
||||
border: 1px solid var(--color-warning, #d97706);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.pricing-page__banner strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.pricing-page__banner p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pricing-page__summary {
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -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, canPriceOrder, canEditInvoice, rawStatusLabel } from '@/utils/orderStatus'
|
||||
import type { CustomerType, Order, RawOrderStatus } from '@/types'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -47,11 +47,39 @@ const statusOptions = computed<RawOrderStatus[]>(() =>
|
||||
: [...transitions.value],
|
||||
)
|
||||
|
||||
function contextualStatusLabel(to: RawOrderStatus): string {
|
||||
const from = props.order.rawStatus
|
||||
if (from === 'awaiting_settlement') {
|
||||
if (to === 'pricing') return 'بازگشت به پیشنویس قیمتگذاری (مخفی شدن از مشتری)'
|
||||
if (to === 'awaiting_cash_payment') return 'انتخاب تسویه نقدی'
|
||||
if (to === 'check_in_progress') return 'انتخاب تسویه چکی'
|
||||
}
|
||||
if (from === 'final_approved') {
|
||||
if (to === 'preparing') return 'انتقال به آمادهسازی در انبار'
|
||||
}
|
||||
if (from === 'checks_approved' || from === 'cash_confirmed') {
|
||||
if (to === 'preparing') return 'تأیید و انتقال مستقیم به آمادهسازی'
|
||||
if (to === 'final_approved') return 'تأیید نهایی مالی سفارش'
|
||||
}
|
||||
if (from === 'preparing') {
|
||||
if (to === 'ready_to_ship') return 'تکمیل آمادهسازی (آماده ارسال)'
|
||||
}
|
||||
if (from === 'ready_to_ship') {
|
||||
if (to === 'shipped') return 'تحویل به باربری (ارسال شد)'
|
||||
}
|
||||
if (from === 'shipped') {
|
||||
if (to === 'delivered') return 'تحویل به مشتری انجام شد'
|
||||
}
|
||||
if (to === 'cancelled') return 'لغو سفارش'
|
||||
if (to === 'rejected') return 'رد سفارش'
|
||||
return rawStatusLabel(to)
|
||||
}
|
||||
|
||||
const statusSelectOptions = computed(() => [
|
||||
{ value: '', label: 'انتخاب کنید…' },
|
||||
...statusOptions.value.map((status) => ({
|
||||
value: status,
|
||||
label: rawStatusLabel(status),
|
||||
label: contextualStatusLabel(status),
|
||||
})),
|
||||
])
|
||||
|
||||
@@ -80,16 +108,90 @@ const customerTypeLabel = computed(() => {
|
||||
})
|
||||
|
||||
const showPricingAction = computed(() => canPriceOrder(props.order))
|
||||
const showEditInvoiceAction = computed(() => !showPricingAction.value && canEditInvoice(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>(() => {
|
||||
const primaryAction = computed<'pricing' | 'editInvoice' | 'checks' | 'cash' | null>(() => {
|
||||
if (showPricingAction.value) return 'pricing'
|
||||
if (showEditInvoiceAction.value) return 'editInvoice'
|
||||
if (showChecksAction.value) return 'checks'
|
||||
if (showCashAction.value) return 'cash'
|
||||
return null
|
||||
})
|
||||
|
||||
const currentStageNotice = computed<{
|
||||
title: string
|
||||
desc: string
|
||||
tone: 'info' | 'warning' | 'success' | 'neutral'
|
||||
} | null>(() => {
|
||||
const raw = props.order.rawStatus
|
||||
if (raw === 'submitted' || raw === 'awaiting_review') {
|
||||
return {
|
||||
title: 'پیشسفارش ثبت شد — در انتظار قیمتگذاری',
|
||||
desc: 'برای تعیین قیمت اقلام و صدور فاکتور، روی دکمه «قیمتگذاری سفارش» در بالا کلیک کنید.',
|
||||
tone: 'warning',
|
||||
}
|
||||
}
|
||||
if (raw === 'pricing') {
|
||||
return {
|
||||
title: 'پیشنویس قیمتگذاری (فاکتور هنوز برای مشتری ارسال نشده)',
|
||||
desc: 'مبالغ تا قبل از زدن «ارسال فاکتور» از دید مشتری پنهان هستند. وارد صفحه قیمتگذاری شده و فاکتور را برای مشتری ارسال کنید.',
|
||||
tone: 'warning',
|
||||
}
|
||||
}
|
||||
if (raw === 'awaiting_settlement') {
|
||||
return {
|
||||
title: 'فاکتور صادر شد — در انتظار انتخاب تسویه توسط مشتری',
|
||||
desc: 'فاکتور با موفقیت برای مشتری ارسال شده و مبالغ در پنل کاربر قابل مشاهده است. اکنون مشتری در حال انتخاب روش پرداخت (نقد یا چک) است و نیازی به تغییر دستی وضعیت نیست.',
|
||||
tone: 'info',
|
||||
}
|
||||
}
|
||||
if (raw === 'awaiting_check_review') {
|
||||
return {
|
||||
title: 'چکهای مشتری ثبت شد',
|
||||
desc: 'چکها در صف بررسی هستند. برای مشاهده تصویر و تأیید چکها، دکمه «مدیریت چکها» را بزنید.',
|
||||
tone: 'warning',
|
||||
}
|
||||
}
|
||||
if (raw === 'awaiting_cash_confirmation') {
|
||||
return {
|
||||
title: 'رسید پرداخت نقدی ثبت شد',
|
||||
desc: 'مشتری رسید واریز را ارسال کرده است. برای تأیید فیش، دکمه «رسید پرداخت» را بزنید.',
|
||||
tone: 'warning',
|
||||
}
|
||||
}
|
||||
if (raw === 'checks_approved' || raw === 'cash_confirmed') {
|
||||
return {
|
||||
title: 'مدارک تسویه تأیید شد',
|
||||
desc: 'پرداخت مشتری تأیید شد. سفارش آماده تأیید نهایی و ورود به انبار است.',
|
||||
tone: 'success',
|
||||
}
|
||||
}
|
||||
if (raw === 'final_approved') {
|
||||
return {
|
||||
title: 'سفارش تأیید نهایی شد',
|
||||
desc: 'سفارش تأیید نهایی شده است. با انتخاب وضعیت «آمادهسازی»، سفارش را برای انبار ارسال کنید.',
|
||||
tone: 'success',
|
||||
}
|
||||
}
|
||||
if (raw === 'preparing') {
|
||||
return {
|
||||
title: 'در حال آمادهسازی در انبار',
|
||||
desc: 'سفارش در انبار در حال برش و بستهبندی است. پس از اتمام، وضعیت را به «آماده ارسال» تغییر دهید.',
|
||||
tone: 'info',
|
||||
}
|
||||
}
|
||||
if (raw === 'ready_to_ship') {
|
||||
return {
|
||||
title: 'آماده ارسال به باربری',
|
||||
desc: 'سفارش آماده تحویل به باربری است. میتوانید بارنامه را در بخش ارسال ثبت کنید یا وضعیت را به «ارسال شده» ببرید.',
|
||||
tone: 'info',
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
watch([() => props.order.id, manualMode], () => {
|
||||
nextStatus.value = ''
|
||||
statusNote.value = ''
|
||||
@@ -133,6 +235,14 @@ function submitStatus() {
|
||||
>
|
||||
قیمتگذاری سفارش
|
||||
</button>
|
||||
<button
|
||||
v-if="showEditInvoiceAction"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--sm admin-btn--outline"
|
||||
@click="emit('pricing')"
|
||||
>
|
||||
ویرایش فاکتور
|
||||
</button>
|
||||
<button
|
||||
v-if="showChecksAction"
|
||||
type="button"
|
||||
@@ -154,6 +264,23 @@ function submitStatus() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="currentStageNotice"
|
||||
class="order-detail__stage-banner"
|
||||
:class="`order-detail__stage-banner--${currentStageNotice.tone}`"
|
||||
>
|
||||
<div class="order-detail__stage-icon">
|
||||
<AdminIcon
|
||||
:name="currentStageNotice.tone === 'success' ? 'check-circle' : currentStageNotice.tone === 'warning' ? 'alert-circle' : 'file'"
|
||||
:size="18"
|
||||
/>
|
||||
</div>
|
||||
<div class="order-detail__stage-text">
|
||||
<strong>{{ currentStageNotice.title }}</strong>
|
||||
<p>{{ currentStageNotice.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-detail__panels">
|
||||
<section class="order-detail__section">
|
||||
<h3 class="order-detail__section-title">اطلاعات مشتری</h3>
|
||||
@@ -316,6 +443,49 @@ function submitStatus() {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.order-detail__stage-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.order-detail__stage-banner--info {
|
||||
background: var(--color-info-light, rgba(37, 99, 235, 0.08));
|
||||
border: 1px solid var(--color-info, #2563eb);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.order-detail__stage-banner--warning {
|
||||
background: var(--color-warning-light, rgba(217, 119, 6, 0.08));
|
||||
border: 1px solid var(--color-warning, #d97706);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.order-detail__stage-banner--success {
|
||||
background: var(--color-success-light, rgba(16, 185, 129, 0.08));
|
||||
border: 1px solid var(--color-success, #10b981);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.order-detail__stage-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.order-detail__stage-text strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.order-detail__stage-text p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.order-detail__hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -501,7 +671,50 @@ function submitStatus() {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.order-detail__hero {
|
||||
.order-detail__stage-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.order-detail__stage-banner--info {
|
||||
background: var(--color-info-light, rgba(37, 99, 235, 0.08));
|
||||
border: 1px solid var(--color-info, #2563eb);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.order-detail__stage-banner--warning {
|
||||
background: var(--color-warning-light, rgba(217, 119, 6, 0.08));
|
||||
border: 1px solid var(--color-warning, #d97706);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.order-detail__stage-banner--success {
|
||||
background: var(--color-success-light, rgba(16, 185, 129, 0.08));
|
||||
border: 1px solid var(--color-success, #10b981);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.order-detail__stage-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.order-detail__stage-text strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.order-detail__stage-text p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.order-detail__hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user