Add preparing tab, clarify order status actions and stage banners

This commit is contained in:
2026-11-08 11:58:05 +03:30
parent 12a31903ed
commit 93d677fa9d
5 changed files with 299 additions and 11 deletions
+10 -1
View File
@@ -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) {
+1
View File
@@ -7,6 +7,7 @@ export type OrderStatus =
| 'needs_correction'
| 'check_correction'
| 'final_approval'
| 'preparing'
| 'shipped'
| 'delivered'
| 'cancelled'
+14
View File
@@ -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
+57 -6
View File
@@ -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;
}