Improve order tracking timeline, receipt gating, and numeric/dark UI polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-11 18:11:43 +02:00
co-authored by Cursor
parent 22282d2480
commit 1ecdc29daa
13 changed files with 543 additions and 163 deletions
+1
View File
@@ -155,6 +155,7 @@ input[type='number']::-webkit-outer-spin-button {
input[type='number'] {
-moz-appearance: textfield;
text-align: right;
}
.page {
+17 -3
View File
@@ -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>
+36 -24
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+10 -2
View File
@@ -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>
+4 -3
View File
@@ -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: 'آماده ارسال' },
+10
View File
@@ -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
View File
@@ -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
}
+2
View File
@@ -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')"
+10 -1
View File
@@ -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="نام صاحب چک" />
+324 -110
View File
@@ -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;
+1
View File
@@ -98,6 +98,7 @@ async function save() {
label="کد ملی"
maxlength="10"
inputmode="numeric"
dir="ltr"
hint="برای ثبت چک الزامی است"
:error="fieldErrors.nationalId"
@update:model-value="clearField('nationalId')"