Improve order tracking timeline, receipt gating, and numeric/dark UI polish.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -155,6 +155,7 @@ input[type='number']::-webkit-outer-spin-button {
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.page {
|
||||
|
||||
@@ -19,8 +19,10 @@ const icons = {
|
||||
<div class="status-hero__icon" aria-hidden="true">
|
||||
<BasicIcon :icon="icons[config.variant]" :size="28" :stroke-width="1.5" />
|
||||
</div>
|
||||
<h2 class="status-hero__title">{{ config.title }}</h2>
|
||||
<p v-if="config.description" class="status-hero__desc">{{ config.description }}</p>
|
||||
<div class="status-hero__copy">
|
||||
<h2 class="status-hero__title">{{ config.title }}</h2>
|
||||
<p v-if="config.description" class="status-hero__desc">{{ config.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,6 +33,7 @@ const icons = {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 8px 12px 20px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.status-hero__icon {
|
||||
@@ -42,6 +45,7 @@ const icons = {
|
||||
justify-content: center;
|
||||
margin-bottom: 14px;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-hero--waiting .status-hero__icon {
|
||||
@@ -59,6 +63,10 @@ const icons = {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status-hero__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-hero__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.0625rem;
|
||||
@@ -80,15 +88,21 @@ const icons = {
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.status-hero {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
text-align: start;
|
||||
padding: 0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-hero__icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status-hero__title {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.status-hero__desc {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Order, OrderStatus } from '@/types'
|
||||
import { ORDER_TRACKING_STEPS } from '@/utils/constants'
|
||||
import { getStepTimestamp, getTrackingStepIndex } from '@/utils/orderTracking'
|
||||
import {
|
||||
getOrderTrackingSteps,
|
||||
getStepTimestamp,
|
||||
getTrackingStepIndex,
|
||||
} from '@/utils/orderTracking'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
order: Order
|
||||
}>()
|
||||
|
||||
const steps = computed(() => getOrderTrackingSteps(props.order))
|
||||
const currentIndex = computed(() => getTrackingStepIndex(props.order.status))
|
||||
|
||||
function stepState(index: number): 'done' | 'current' | 'pending' {
|
||||
@@ -26,7 +30,7 @@ function timestampFor(step: OrderStatus) {
|
||||
<template>
|
||||
<div class="tracking-timeline">
|
||||
<div
|
||||
v-for="(step, index) in ORDER_TRACKING_STEPS"
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.status"
|
||||
class="tracking-timeline__item"
|
||||
:class="`tracking-timeline__item--${stepState(index)}`"
|
||||
@@ -141,16 +145,17 @@ function timestampFor(step: OrderStatus) {
|
||||
}
|
||||
|
||||
.tracking-timeline__content--current {
|
||||
padding: 8px 12px 10px;
|
||||
padding: 10px 14px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-primary-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tracking-timeline__label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@@ -162,7 +167,9 @@ function timestampFor(step: OrderStatus) {
|
||||
|
||||
.tracking-timeline__time {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | number
|
||||
label?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
maxlength?: number | string
|
||||
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
|
||||
autocomplete?: string
|
||||
dir?: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>(),
|
||||
{
|
||||
align: 'start',
|
||||
},
|
||||
)
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string | number
|
||||
label?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
maxlength?: number | string
|
||||
inputmode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url'
|
||||
autocomplete?: string
|
||||
dir?: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
blur: [event: FocusEvent]
|
||||
}>()
|
||||
|
||||
const isNumericField = computed(() => {
|
||||
const type = props.type ?? 'text'
|
||||
return (
|
||||
type === 'number' ||
|
||||
type === 'tel' ||
|
||||
props.inputmode === 'numeric' ||
|
||||
props.inputmode === 'decimal' ||
|
||||
props.inputmode === 'tel' ||
|
||||
props.dir === 'ltr'
|
||||
)
|
||||
})
|
||||
|
||||
/** Numbers default to right; OTP/login can still force center via `align`. */
|
||||
const resolvedAlign = computed(() => props.align ?? (isNumericField.value ? 'end' : 'start'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,7 +45,7 @@ defineEmits<{
|
||||
<label v-if="label" class="form-label">{{ label }}</label>
|
||||
<input
|
||||
class="input"
|
||||
:class="[`input--align-${align}`, { 'input-error': error }]"
|
||||
:class="[`input--align-${resolvedAlign}`, { 'input-error': error }]"
|
||||
:type="type ?? 'text'"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@@ -76,7 +88,7 @@ defineEmits<{
|
||||
}
|
||||
|
||||
.input--align-end {
|
||||
text-align: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
|
||||
@@ -64,7 +64,7 @@ function onInput(event: Event) {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s;
|
||||
text-align: left;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
|
||||
@@ -310,7 +310,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.persian-date-input__cell--selected {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--color-on-primary, #fff);
|
||||
}
|
||||
|
||||
.persian-date-input__today {
|
||||
@@ -319,10 +319,18 @@ onBeforeUnmount(() => {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #f8fafc;
|
||||
background: var(--color-elevated);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.persian-date-input__today:hover {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,9 +36,10 @@ export const ORDER_TRACKING_STEPS: { status: OrderStatus; label: string }[] = [
|
||||
{ status: 'submitted', label: 'ثبت سفارش' },
|
||||
{ status: 'awaiting_review', label: 'در انتظار بررسی' },
|
||||
{ status: 'pricing', label: 'قیمتگذاری توسط مدیریت' },
|
||||
{ status: 'check_in_progress', label: 'ثبت چک' },
|
||||
{ status: 'awaiting_check_review', label: 'در حال بررسی چکها' },
|
||||
{ status: 'checks_approved', label: 'چکها تأیید شده' },
|
||||
{ status: 'awaiting_settlement', label: 'انتخاب روش تسویه' },
|
||||
{ status: 'check_in_progress', label: 'ثبت پرداخت / چک' },
|
||||
{ status: 'awaiting_check_review', label: 'بررسی پرداخت / چک' },
|
||||
{ status: 'checks_approved', label: 'تأیید پرداخت' },
|
||||
{ status: 'final_approved', label: 'تأیید نهایی' },
|
||||
{ status: 'preparing', label: 'آمادهسازی' },
|
||||
{ status: 'ready_to_ship', label: 'آماده ارسال' },
|
||||
|
||||
@@ -26,10 +26,20 @@ const PRE_PRICE_STATUSES: OrderStatus[] = [
|
||||
'needs_correction',
|
||||
]
|
||||
|
||||
const CASH_RECEIPT_STATUSES: OrderStatus[] = [
|
||||
'awaiting_cash_payment',
|
||||
'cash_invoice_issued',
|
||||
]
|
||||
|
||||
export function canChooseSettlement(order: Order | null | undefined): boolean {
|
||||
return isInvoicePriced(order) && !!order && SETTLEMENT_STATUSES.includes(order.status)
|
||||
}
|
||||
|
||||
/** ثبت رسید فقط بعد از صدور فاکتور نقدی / انتظار پرداخت. */
|
||||
export function canSubmitCashReceipt(order: Order | null | undefined): boolean {
|
||||
return isInvoicePriced(order) && !!order && CASH_RECEIPT_STATUSES.includes(order.status)
|
||||
}
|
||||
|
||||
export function canEnterChecks(order: Order | null | undefined): boolean {
|
||||
if (!isInvoicePriced(order) || !order) return false
|
||||
if (CHECK_ENTRY_STATUSES.includes(order.status)) return true
|
||||
|
||||
+115
-14
@@ -1,4 +1,4 @@
|
||||
import type { Order, OrderStatus } from '@/types'
|
||||
import type { Order, OrderStatus, SettlementType } from '@/types'
|
||||
import { ORDER_TRACKING_STEPS } from '@/utils/constants'
|
||||
|
||||
export type StatusHeroVariant = 'waiting' | 'success' | 'info' | 'none'
|
||||
@@ -9,21 +9,38 @@ export interface StatusHeroConfig {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface TrackingStep {
|
||||
status: OrderStatus
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Index into getOrderTrackingSteps(); must stay aligned with that list. */
|
||||
const STATUS_STEP_INDEX: Partial<Record<OrderStatus, number>> = {
|
||||
submitted: 0,
|
||||
awaiting_review: 1,
|
||||
pricing: 2,
|
||||
check_in_progress: 3,
|
||||
awaiting_check_review: 4,
|
||||
check_needs_fix: 4,
|
||||
check_rejected: 4,
|
||||
checks_approved: 5,
|
||||
final_approved: 6,
|
||||
needs_correction: 1,
|
||||
awaiting_settlement: 3,
|
||||
awaiting_settlement_fix: 3,
|
||||
cash_in_progress: 4,
|
||||
cash_invoice_issued: 4,
|
||||
awaiting_cash_payment: 4,
|
||||
cash_payment_submitted: 4,
|
||||
check_in_progress: 4,
|
||||
awaiting_cash_confirmation: 5,
|
||||
awaiting_check_review: 5,
|
||||
check_needs_fix: 5,
|
||||
check_rejected: 5,
|
||||
checks_approved: 6,
|
||||
cash_confirmed: 6,
|
||||
preparing: 7,
|
||||
ready_to_ship: 8,
|
||||
shipped: 9,
|
||||
delivered: 10,
|
||||
final_approved: 7,
|
||||
preparing: 8,
|
||||
ready_to_ship: 9,
|
||||
shipped: 10,
|
||||
delivered: 11,
|
||||
expired: 3,
|
||||
cancelled: 0,
|
||||
rejected: 1,
|
||||
}
|
||||
|
||||
const STATUS_HERO: Partial<Record<OrderStatus, StatusHeroConfig>> = {
|
||||
@@ -55,6 +72,26 @@ const STATUS_HERO: Partial<Record<OrderStatus, StatusHeroConfig>> = {
|
||||
title: 'نیاز به اصلاح تسویه',
|
||||
description: 'لطفاً روش تسویه یا اطلاعات پرداخت را اصلاح کنید.',
|
||||
},
|
||||
cash_invoice_issued: {
|
||||
variant: 'info',
|
||||
title: 'فاکتور نقدی صادر شد',
|
||||
description: 'مبلغ را به حسابهای اعلامشده واریز و رسید پرداخت را ثبت کنید.',
|
||||
},
|
||||
awaiting_cash_payment: {
|
||||
variant: 'info',
|
||||
title: 'در انتظار پرداخت نقدی',
|
||||
description: 'لطفاً مبلغ فاکتور را واریز و رسید پرداخت را ثبت کنید.',
|
||||
},
|
||||
cash_payment_submitted: {
|
||||
variant: 'waiting',
|
||||
title: 'رسید ثبت شد',
|
||||
description: 'رسید پرداخت شما دریافت شد و در صف بررسی مدیریت است.',
|
||||
},
|
||||
awaiting_cash_confirmation: {
|
||||
variant: 'waiting',
|
||||
title: 'در انتظار تأیید پرداخت',
|
||||
description: 'رسید پرداخت در حال بررسی است. نتیجه بهزودی اعلام میشود.',
|
||||
},
|
||||
awaiting_check_review: {
|
||||
variant: 'waiting',
|
||||
title: 'در حال بررسی چکها',
|
||||
@@ -63,7 +100,8 @@ const STATUS_HERO: Partial<Record<OrderStatus, StatusHeroConfig>> = {
|
||||
checks_approved: {
|
||||
variant: 'success',
|
||||
title: 'چکها تأیید شده',
|
||||
description: 'چکهای شما توسط مدیریت تأیید شد. سفارش وارد مراحل بعدی میشود.',
|
||||
description:
|
||||
'چکهای شما تأیید شد. سفارش در انتظار تأیید نهایی مدیریت است و پس از آن وارد آمادهسازی میشود.',
|
||||
},
|
||||
cash_confirmed: {
|
||||
variant: 'success',
|
||||
@@ -95,24 +133,87 @@ const STATUS_HERO: Partial<Record<OrderStatus, StatusHeroConfig>> = {
|
||||
title: 'تحویل شده',
|
||||
description: 'سفارش با موفقیت تحویل داده شد.',
|
||||
},
|
||||
expired: {
|
||||
variant: 'waiting',
|
||||
title: 'فاکتور منقضی شده',
|
||||
description: 'مهلت پرداخت این فاکتور به پایان رسیده است. برای ادامه با پشتیبانی یا مدیریت هماهنگ کنید.',
|
||||
},
|
||||
}
|
||||
|
||||
function paymentStepLabels(settlementType: SettlementType | null | undefined): {
|
||||
payment: string
|
||||
review: string
|
||||
approved: string
|
||||
} {
|
||||
if (settlementType === 'cash') {
|
||||
return {
|
||||
payment: 'ثبت رسید پرداخت',
|
||||
review: 'بررسی پرداخت نقدی',
|
||||
approved: 'پرداخت تأیید شده',
|
||||
}
|
||||
}
|
||||
if (settlementType === 'check') {
|
||||
return {
|
||||
payment: 'ثبت چک',
|
||||
review: 'در حال بررسی چکها',
|
||||
approved: 'چکها تأیید شده',
|
||||
}
|
||||
}
|
||||
return {
|
||||
payment: 'ثبت پرداخت / چک',
|
||||
review: 'بررسی پرداخت / چک',
|
||||
approved: 'تأیید پرداخت',
|
||||
}
|
||||
}
|
||||
|
||||
/** Customer-facing timeline steps; payment labels follow settlement type. */
|
||||
export function getOrderTrackingSteps(order: Order): TrackingStep[] {
|
||||
const labels = paymentStepLabels(order.settlementType)
|
||||
return ORDER_TRACKING_STEPS.map((step) => {
|
||||
if (step.status === 'check_in_progress') return { ...step, label: labels.payment }
|
||||
if (step.status === 'awaiting_check_review') return { ...step, label: labels.review }
|
||||
if (step.status === 'checks_approved') return { ...step, label: labels.approved }
|
||||
return step
|
||||
})
|
||||
}
|
||||
|
||||
export function getTrackingStepIndex(status: OrderStatus): number {
|
||||
return STATUS_STEP_INDEX[status] ?? 1
|
||||
return STATUS_STEP_INDEX[status] ?? 0
|
||||
}
|
||||
|
||||
export function getStatusHero(status: OrderStatus): StatusHeroConfig | null {
|
||||
return STATUS_HERO[status] ?? null
|
||||
}
|
||||
|
||||
/** Statuses that may mark a timeline step as reached in history. */
|
||||
const STEP_HISTORY_ALIASES: Partial<Record<OrderStatus, OrderStatus[]>> = {
|
||||
awaiting_settlement: ['awaiting_settlement', 'awaiting_settlement_fix'],
|
||||
check_in_progress: [
|
||||
'check_in_progress',
|
||||
'cash_in_progress',
|
||||
'cash_invoice_issued',
|
||||
'awaiting_cash_payment',
|
||||
'cash_payment_submitted',
|
||||
],
|
||||
awaiting_check_review: [
|
||||
'awaiting_check_review',
|
||||
'awaiting_cash_confirmation',
|
||||
'check_needs_fix',
|
||||
'check_rejected',
|
||||
],
|
||||
checks_approved: ['checks_approved', 'cash_confirmed'],
|
||||
pricing: ['pricing'],
|
||||
}
|
||||
|
||||
export function getStepTimestamp(order: Order, stepStatus: OrderStatus): string | null {
|
||||
if (stepStatus === 'submitted') {
|
||||
return order.createdAt
|
||||
}
|
||||
|
||||
const aliases = STEP_HISTORY_ALIASES[stepStatus] ?? [stepStatus]
|
||||
const entry = [...order.statusHistory]
|
||||
.reverse()
|
||||
.find((item) => item.toStatus === stepStatus)
|
||||
.find((item) => aliases.includes(item.toStatus))
|
||||
|
||||
return entry?.changedAt ?? null
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ async function submit() {
|
||||
v-model="phone"
|
||||
label="شماره موبایل"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
dir="ltr"
|
||||
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
|
||||
:error="fieldErrors.phone"
|
||||
@update:model-value="clearField('phone')"
|
||||
|
||||
@@ -192,13 +192,22 @@ function resetForm() {
|
||||
label="شناسه صیادی"
|
||||
placeholder="۱۶ رقم"
|
||||
maxlength="16"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
:error="fieldErrors.sayadId"
|
||||
/>
|
||||
<BaseInput v-model="checkNumber" label="شماره چک" />
|
||||
<BaseInput
|
||||
v-model="checkNumber"
|
||||
label="شماره چک"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
/>
|
||||
<BaseInput
|
||||
v-model="ownerNationalId"
|
||||
label="کد ملی صاحب چک"
|
||||
maxlength="10"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
:error="fieldErrors.ownerNationalId"
|
||||
/>
|
||||
<BaseInput v-model="ownerFirstName" label="نام صاحب چک" />
|
||||
|
||||
@@ -30,7 +30,7 @@ import { getStatusHero, getTrackingFooterLabel } from '@/utils/orderTracking'
|
||||
import OrderTrackingTimeline from '@/components/orders/OrderTrackingTimeline.vue'
|
||||
import OrderStatusHero from '@/components/orders/OrderStatusHero.vue'
|
||||
import OrderTrackingSummary from '@/components/orders/OrderTrackingSummary.vue'
|
||||
import { isInvoicePriced } from '@/utils/orderInvoice'
|
||||
import { canSubmitCashReceipt, isInvoicePriced } from '@/utils/orderInvoice'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -40,7 +40,7 @@ const isDesktopLayout = useMediaQuery('(min-width: 768px)')
|
||||
const orderId = computed(() => route.params.id as string)
|
||||
const order = computed(() => orderStore.getOrderById(orderId.value))
|
||||
const showTracking = ref(route.query.track === '1' || route.query.receipt === '1')
|
||||
const showReceiptForm = ref(route.query.receipt === '1')
|
||||
const showReceiptForm = ref(false)
|
||||
const showTimeline = ref(true)
|
||||
const showOrderDetails = ref(route.query.receipt === '1')
|
||||
const receiptAmount = ref('')
|
||||
@@ -52,6 +52,24 @@ const receiptImage = ref('')
|
||||
const fieldErrors = ref<FieldErrors>({})
|
||||
const formError = ref('')
|
||||
|
||||
watch(
|
||||
[order, () => route.query.receipt],
|
||||
([current]) => {
|
||||
const wantsReceipt = route.query.receipt === '1'
|
||||
if (wantsReceipt && canSubmitCashReceipt(current)) {
|
||||
showReceiptForm.value = true
|
||||
showOrderDetails.value = true
|
||||
return
|
||||
}
|
||||
if (wantsReceipt && current && !canSubmitCashReceipt(current)) {
|
||||
showReceiptForm.value = false
|
||||
const { receipt: _receipt, ...rest } = route.query
|
||||
void router.replace({ query: { ...rest, track: '1' } })
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
isDesktopLayout,
|
||||
(wide) => {
|
||||
@@ -95,7 +113,11 @@ watch(
|
||||
bankAccounts.value = []
|
||||
return
|
||||
}
|
||||
bankAccounts.value = await orderStore.getBankAccountsForCompany(companyId)
|
||||
try {
|
||||
bankAccounts.value = await orderStore.getBankAccountsForCompany(companyId)
|
||||
} catch {
|
||||
bankAccounts.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -105,7 +127,11 @@ watch(
|
||||
async (id) => {
|
||||
if (!id) return
|
||||
if (!orderStore.getOrderById(id)) {
|
||||
await orderStore.fetchOrder(id)
|
||||
try {
|
||||
await orderStore.fetchOrder(id)
|
||||
} catch {
|
||||
/* keep empty state if fetch fails */
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -150,13 +176,21 @@ const nextAction = computed<NextAction | null>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function openReceiptForm() {
|
||||
if (!canSubmitCashReceipt(order.value)) {
|
||||
formError.value = 'در وضعیت فعلی سفارش امکان ثبت رسید وجود ندارد'
|
||||
return
|
||||
}
|
||||
showTracking.value = true
|
||||
showOrderDetails.value = true
|
||||
showReceiptForm.value = true
|
||||
}
|
||||
|
||||
function runNextAction() {
|
||||
const action = nextAction.value
|
||||
if (!action) return
|
||||
if (action.receipt) {
|
||||
showTracking.value = true
|
||||
showOrderDetails.value = true
|
||||
showReceiptForm.value = true
|
||||
openReceiptForm()
|
||||
return
|
||||
}
|
||||
if (action.to) router.push(action.to)
|
||||
@@ -194,6 +228,11 @@ async function submitReceipt() {
|
||||
fieldErrors.value = {}
|
||||
formError.value = ''
|
||||
|
||||
if (!canSubmitCashReceipt(order.value)) {
|
||||
formError.value = 'در وضعیت فعلی سفارش امکان ثبت رسید وجود ندارد'
|
||||
return
|
||||
}
|
||||
|
||||
const amount = parseFormattedNumber(receiptAmount.value)
|
||||
const errors: FieldErrors = {}
|
||||
|
||||
@@ -240,7 +279,10 @@ async function submitReceipt() {
|
||||
<button type="button" class="nav-btn" aria-label="بازگشت" @click="router.push('/orders')">
|
||||
<IconChevronBack :size="20" />
|
||||
</button>
|
||||
<h1 class="order-detail-page__title">{{ showTracking ? 'پیگیری سفارش' : 'جزئیات سفارش' }}</h1>
|
||||
<div class="order-detail-page__nav-center">
|
||||
<h1 class="order-detail-page__title">{{ showTracking ? 'پیگیری سفارش' : 'جزئیات سفارش' }}</h1>
|
||||
<p v-if="showTracking" class="order-detail-page__order-id">{{ order.orderNumber }}</p>
|
||||
</div>
|
||||
<button type="button" class="nav-btn nav-btn--ghost" aria-hidden="true" tabindex="-1">
|
||||
<IconChevronBack :size="18" class="nav-btn__flip" />
|
||||
</button>
|
||||
@@ -248,15 +290,14 @@ async function submitReceipt() {
|
||||
|
||||
<header class="desktop-page__head desktop-only order-detail-page__desktop-head">
|
||||
<button type="button" class="order-detail-back" @click="router.push('/orders')">
|
||||
← بازگشت به سفارشها
|
||||
<IconChevronBack :size="18" />
|
||||
بازگشت به سفارشها
|
||||
</button>
|
||||
<h1 class="desktop-page__title">پیگیری سفارش</h1>
|
||||
<p class="desktop-page__subtitle">{{ order.orderNumber }} · {{ order.companyName }}</p>
|
||||
</header>
|
||||
|
||||
<div class="order-detail-page__body">
|
||||
<p v-if="showTracking" class="order-detail-page__order-id mobile-only">{{ order.orderNumber }}</p>
|
||||
|
||||
<div v-if="!showTracking" class="order-info-card mobile-only">
|
||||
<div class="order-info-card__head">
|
||||
<span class="order-info-card__label">شماره سفارش</span>
|
||||
@@ -298,7 +339,7 @@ async function submitReceipt() {
|
||||
</div>
|
||||
|
||||
<div v-if="showTracking" class="order-detail-page__tracking">
|
||||
<section class="order-detail-page__tracking-aside">
|
||||
<section class="order-detail-page__tracking-hero">
|
||||
<div class="tracking-card tracking-card--hero">
|
||||
<OrderStatusHero v-if="statusHero" :config="statusHero" />
|
||||
<OrderTrackingSummary :order="order" />
|
||||
@@ -330,7 +371,6 @@ async function submitReceipt() {
|
||||
</button>
|
||||
|
||||
<div v-show="showTimeline" class="tracking-collapse__body">
|
||||
<h2 class="tracking-panel__title desktop-only">پیگیری وضعیت</h2>
|
||||
<div class="tracking-collapse__section tracking-collapse__section--flush">
|
||||
<OrderTrackingTimeline :order="order" />
|
||||
</div>
|
||||
@@ -354,7 +394,6 @@ async function submitReceipt() {
|
||||
</button>
|
||||
|
||||
<div v-show="showOrderDetails" class="tracking-collapse__body">
|
||||
<h2 class="tracking-panel__title desktop-only">{{ trackingFooterLabel }}</h2>
|
||||
<div class="tracking-collapse__section">
|
||||
<h3 class="tracking-card__title">اقلام سفارش</h3>
|
||||
<div v-for="item in order.items" :key="item.id" class="item-row">
|
||||
@@ -387,7 +426,7 @@ async function submitReceipt() {
|
||||
<BaseButton
|
||||
v-if="nextAction?.receipt"
|
||||
block
|
||||
@click="showReceiptForm = true"
|
||||
@click="openReceiptForm"
|
||||
>
|
||||
{{ order.cashReceipt ? 'ثبت رسید جدید' : 'ثبت رسید پرداخت' }}
|
||||
</BaseButton>
|
||||
@@ -441,52 +480,91 @@ async function submitReceipt() {
|
||||
<BaseButton block @click="openTracking">مشاهده جزئیات و پیگیری</BaseButton>
|
||||
</div>
|
||||
|
||||
<div v-if="showReceiptForm" class="dialog-overlay" @click.self="showReceiptForm = false">
|
||||
<div class="dialog card">
|
||||
<h3>ثبت رسید پرداخت</h3>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showReceiptForm"
|
||||
class="receipt-modal-overlay"
|
||||
@click.self="showReceiptForm = false"
|
||||
>
|
||||
<div
|
||||
class="receipt-modal card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="receipt-modal-title"
|
||||
>
|
||||
<header class="receipt-modal__header">
|
||||
<h3 id="receipt-modal-title" class="receipt-modal__title">ثبت رسید پرداخت</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="receipt-modal__close"
|
||||
aria-label="بستن"
|
||||
@click="showReceiptForm = false"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<FormErrorBanner :message="formError" />
|
||||
<div class="receipt-modal__body">
|
||||
<FormErrorBanner :message="formError" />
|
||||
|
||||
<CurrencyInput
|
||||
v-model="receiptAmount"
|
||||
label="مبلغ واریزی"
|
||||
:error="fieldErrors.receiptAmount"
|
||||
/>
|
||||
<PersianDateInput
|
||||
v-model="receiptDate"
|
||||
label="تاریخ واریز"
|
||||
:error="fieldErrors.receiptDate"
|
||||
/>
|
||||
<BaseInput v-model="receiptTime" label="ساعت واریز" type="time" />
|
||||
<BaseInput
|
||||
v-model="trackingNumber"
|
||||
label="شماره پیگیری"
|
||||
:error="fieldErrors.trackingNumber"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label class="form-label">حساب مقصد</label>
|
||||
<select
|
||||
v-model="destinationAccountId"
|
||||
class="select-input"
|
||||
:class="{ 'select-input--error': fieldErrors.destinationAccountId }"
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
<option v-for="acc in bankAccounts" :key="acc.id" :value="acc.id">
|
||||
{{ acc.bankName }} - {{ acc.cardNumber }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="fieldErrors.destinationAccountId" class="input-error-text">
|
||||
{{ fieldErrors.destinationAccountId }}
|
||||
</p>
|
||||
<CurrencyInput
|
||||
v-model="receiptAmount"
|
||||
label="مبلغ واریزی"
|
||||
:error="fieldErrors.receiptAmount"
|
||||
/>
|
||||
<PersianDateInput
|
||||
v-model="receiptDate"
|
||||
label="تاریخ واریز"
|
||||
:error="fieldErrors.receiptDate"
|
||||
/>
|
||||
<BaseInput v-model="receiptTime" label="ساعت واریز" type="time" />
|
||||
<BaseInput
|
||||
v-model="trackingNumber"
|
||||
label="شماره پیگیری"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
:error="fieldErrors.trackingNumber"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label class="form-label">حساب مقصد</label>
|
||||
<select
|
||||
v-model="destinationAccountId"
|
||||
class="select-input"
|
||||
:class="{ 'select-input--error': fieldErrors.destinationAccountId }"
|
||||
>
|
||||
<option value="">انتخاب کنید</option>
|
||||
<option v-for="acc in bankAccounts" :key="acc.id" :value="acc.id">
|
||||
{{ acc.bankName }} - {{ acc.cardNumber }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="fieldErrors.destinationAccountId" class="input-error-text">
|
||||
{{ fieldErrors.destinationAccountId }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">تصویر رسید</label>
|
||||
<label class="upload-box" :class="{ 'upload-box--error': fieldErrors.receiptImage }">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="upload-box__input"
|
||||
@change="handleReceiptImage"
|
||||
/>
|
||||
<img
|
||||
v-if="receiptImage"
|
||||
:src="receiptImage"
|
||||
class="upload-box__preview"
|
||||
alt="تصویر رسید"
|
||||
/>
|
||||
<span v-else class="upload-box__placeholder">انتخاب تصویر رسید</span>
|
||||
</label>
|
||||
<p v-if="fieldErrors.receiptImage" class="input-error-text">{{ fieldErrors.receiptImage }}</p>
|
||||
</div>
|
||||
<BaseButton block class="mt-4" @click="submitReceipt">ثبت رسید</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">تصویر رسید</label>
|
||||
<input type="file" accept="image/*" @change="handleReceiptImage" />
|
||||
<p v-if="fieldErrors.receiptImage" class="input-error-text">{{ fieldErrors.receiptImage }}</p>
|
||||
</div>
|
||||
<BaseButton block class="mt-4" @click="submitReceipt">ثبت رسید</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<div v-else class="page empty-state">
|
||||
@@ -541,8 +619,17 @@ async function submitReceipt() {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 40px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.order-detail-page__nav-center {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.order-detail-page__title {
|
||||
@@ -551,6 +638,17 @@ async function submitReceipt() {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.order-detail-page__order-id {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@@ -584,14 +682,6 @@ async function submitReceipt() {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.order-detail-page__order-id {
|
||||
margin: -8px 0 16px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.order-info-card {
|
||||
background: var(--color-elevated);
|
||||
border-radius: 20px;
|
||||
@@ -713,7 +803,7 @@ async function submitReceipt() {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking-aside,
|
||||
.order-detail-page__tracking-hero,
|
||||
.order-detail-page__tracking-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -722,11 +812,6 @@ async function submitReceipt() {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile: status first (above content) */
|
||||
.order-detail-page__tracking-aside {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.tracking-card {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -741,10 +826,6 @@ async function submitReceipt() {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tracking-panel__title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking :deep(.status-hero) {
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
@@ -761,13 +842,11 @@ async function submitReceipt() {
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.order-detail-page__tracking :deep(.status-hero) {
|
||||
align-items: flex-start;
|
||||
text-align: start;
|
||||
padding: 0 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking :deep(.status-hero__desc) {
|
||||
max-width: none;
|
||||
max-width: 42rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,26 +923,27 @@ async function submitReceipt() {
|
||||
}
|
||||
|
||||
.order-detail-page__tracking {
|
||||
display: grid;
|
||||
/* RTL: first column (status) sits beside sidebar on the right */
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking-aside {
|
||||
order: initial;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking-main {
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tracking-card--hero {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking :deep(.tracking-summary) {
|
||||
border-inline-start: none;
|
||||
padding-inline-start: 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.tracking-collapse {
|
||||
@@ -875,26 +955,16 @@ async function submitReceipt() {
|
||||
}
|
||||
|
||||
.tracking-collapse__trigger {
|
||||
display: none;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.tracking-collapse__body {
|
||||
display: flex !important;
|
||||
padding: 20px;
|
||||
border-top: none;
|
||||
padding: 0 20px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tracking-panel__title {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tracking-collapse__section {
|
||||
padding-top: 0;
|
||||
.tracking-collapse__section--flush {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.tracking-collapse__section + .tracking-collapse__section,
|
||||
@@ -909,10 +979,29 @@ async function submitReceipt() {
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.tracking-card--hero {
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.75fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.order-detail-page__tracking :deep(.tracking-summary) {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
border-inline-start: 1px solid var(--color-border);
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.order-detail-page__tracking {
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
.tracking-card--hero {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.7fr);
|
||||
gap: 28px;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.tracking-collapse__section--flush {
|
||||
max-width: 560px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,6 +1097,96 @@ async function submitReceipt() {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.receipt-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: rgba(15, 23, 42, 0.52);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.receipt-modal {
|
||||
width: 100%;
|
||||
max-height: 92dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.receipt-modal__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.receipt-modal__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.receipt-modal__close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.receipt-modal__close:hover {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.receipt-modal__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.receipt-modal-overlay {
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.receipt-modal {
|
||||
width: min(100%, 520px);
|
||||
max-height: min(90dvh, 760px);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.receipt-modal__header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.receipt-modal__body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
@@ -1020,6 +1199,41 @@ async function submitReceipt() {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
border: 1.5px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-box--error {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.upload-box__input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-box__placeholder {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.upload-box__preview {
|
||||
max-width: 100%;
|
||||
max-height: 140px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.input-error-text {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
@@ -98,6 +98,7 @@ async function save() {
|
||||
label="کد ملی"
|
||||
maxlength="10"
|
||||
inputmode="numeric"
|
||||
dir="ltr"
|
||||
hint="برای ثبت چک الزامی است"
|
||||
:error="fieldErrors.nationalId"
|
||||
@update:model-value="clearField('nationalId')"
|
||||
|
||||
Reference in New Issue
Block a user