Improve order detail UX, confirm check actions, and fix notification deep links.

Normalize inbox /orders/:id links, open related admin pages on click, and keep Jalali dates consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-09 14:32:09 +02:00
co-authored by Cursor
parent 120c62bdca
commit 1c3c494a43
14 changed files with 737 additions and 212 deletions
+40 -44
View File
@@ -297,49 +297,22 @@
font-weight: 700;
}
/* Responsive overrides for shared admin components */
/* Tablet / compact desktop: keep multi-column layouts usable in modals */
@media (max-width: 1279px) {
.admin-shell__content {
padding: var(--page-padding);
}
.admin-page-header {
flex-direction: column;
align-items: stretch;
}
.admin-page-header .admin-btn,
.admin-page-header .admin-action-group {
width: 100%;
}
.admin-page-header .admin-action-group {
flex-wrap: wrap;
}
.admin-page-header .admin-action-group .admin-btn {
flex: 1;
min-width: fit-content;
}
.admin-summary-grid {
grid-template-columns: repeat(2, 1fr);
}
.admin-form-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(2, 1fr);
}
.admin-info-grid {
grid-template-columns: 1fr;
}
.admin-footer-actions {
flex-direction: column;
}
.admin-footer-actions .admin-btn {
width: 100%;
grid-template-columns: repeat(2, 1fr);
}
.admin-pagination {
@@ -352,17 +325,53 @@
justify-content: center;
flex-wrap: wrap;
}
.admin-tab {
padding: 10px 12px;
font-size: 0.8125rem;
}
.admin-page-header .admin-action-group {
flex-wrap: wrap;
gap: 8px;
}
}
/* Phone only: stack to single column / action sheet */
@media (max-width: 767px) {
:root {
--header-height: 60px;
}
.admin-summary-grid {
.admin-page-header {
flex-direction: column;
align-items: stretch;
}
.admin-page-header .admin-btn,
.admin-page-header .admin-action-group {
width: 100%;
}
.admin-page-header .admin-action-group .admin-btn {
flex: 1;
min-width: fit-content;
}
.admin-summary-grid,
.admin-form-grid,
.admin-info-grid {
grid-template-columns: 1fr;
}
.admin-footer-actions {
flex-direction: column;
}
.admin-footer-actions .admin-btn {
width: 100%;
}
.admin-form__row {
grid-template-columns: 1fr;
}
@@ -397,16 +406,3 @@
width: 100%;
}
}
@media (max-width: 1279px) {
.admin-tab {
padding: 10px 12px;
font-size: 0.8125rem;
}
}
@media (min-width: 768px) and (max-width: 1279px) {
.admin-form-grid {
grid-template-columns: repeat(2, 1fr);
}
}
+32 -8
View File
@@ -5,7 +5,9 @@ import { onClickOutside, useEventListener } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import { dashboardApi, notificationsApi } from '@/services/api'
import { adminNotifications, internalMessages } from '@/data/mockData'
import { formatDateTime } from '@/utils/format'
import { resolveAdminInboxLink } from '@/utils/inboxLinks'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore, type ThemePreference } from '@/stores/theme'
import type { AdminNotification, InternalMessage } from '@/types'
@@ -26,8 +28,8 @@ const theme = useThemeStore()
const openPanel = ref<OpenPanel>(null)
const actionsRef = ref<HTMLElement | null>(null)
const messageList = ref<InternalMessage[]>([])
const notificationList = ref<AdminNotification[]>([])
const messageList = ref<InternalMessage[]>(internalMessages.map((m) => ({ ...m })))
const notificationList = ref<AdminNotification[]>(adminNotifications.map((n) => ({ ...n })))
const unreadMessages = computed(() => messageList.value.filter((m) => !m.isRead).length)
const unreadNotifications = computed(() => notificationList.value.filter((n) => !n.isRead).length)
@@ -53,7 +55,7 @@ void dashboardApi
if (data.notifications?.length) notificationList.value = data.notifications
})
.catch(() => {
/* keep mock fallback */
/* keep mock fallback seeded above */
})
onClickOutside(actionsRef, () => {
@@ -103,12 +105,34 @@ function markAllNotificationsRead() {
})
}
function navigateInboxTarget(target: string | null) {
closePanel()
if (!target) return
void router.push(target)
}
function openMessage(message: InternalMessage) {
markMessageRead(message)
navigateInboxTarget(
resolveAdminInboxLink({
link: message.link,
orderId: message.orderId,
title: message.subject,
body: message.preview,
}),
)
}
function openNotification(notification: AdminNotification) {
markNotificationRead(notification)
closePanel()
if (notification.link) {
router.push(notification.link)
}
navigateInboxTarget(
resolveAdminInboxLink({
link: notification.link,
orderId: notification.orderId,
title: notification.title,
body: notification.body,
}),
)
}
function selectTheme(value: ThemePreference) {
@@ -176,7 +200,7 @@ function selectTheme(value: ThemePreference) {
class="admin-header__panel-item"
:class="{ unread: !message.isRead }"
>
<button type="button" class="admin-header__panel-item-btn" @click="markMessageRead(message)">
<button type="button" class="admin-header__panel-item-btn" @click="openMessage(message)">
<span class="admin-header__panel-item-top">
<strong>{{ message.subject }}</strong>
<time :datetime="message.sentAt">{{ formatDateTime(message.sentAt) }}</time>
+18 -13
View File
@@ -60,14 +60,13 @@ function onBackdropClick(event: MouseEvent) {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
padding: 16px;
background: rgba(15, 23, 42, 0.45);
}
.admin-modal {
width: 100%;
max-width: 480px;
max-height: calc(100dvh - 48px);
width: min(100%, 480px);
max-height: calc(100dvh - 32px);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -77,19 +76,20 @@ function onBackdropClick(event: MouseEvent) {
}
.admin-modal--lg {
max-width: 560px;
width: min(100%, 640px);
}
.admin-modal--xl {
max-width: 920px;
width: min(100%, 960px);
}
.admin-modal__header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 20px 24px;
padding: 16px 20px;
border-bottom: 1px solid var(--color-border);
}
@@ -120,17 +120,20 @@ function onBackdropClick(event: MouseEvent) {
}
.admin-modal__body {
padding: 24px;
flex: 1 1 auto;
min-height: 0;
padding: 16px 20px 20px;
overflow-y: auto;
}
.admin-modal__footer {
flex-shrink: 0;
display: flex;
flex-direction: row-reverse;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 16px 24px;
padding: 14px 20px;
border-top: 1px solid var(--color-border);
background: var(--color-bg);
}
@@ -145,18 +148,20 @@ function onBackdropClick(event: MouseEvent) {
align-items: flex-end;
}
.admin-modal {
max-width: 100%;
.admin-modal,
.admin-modal--lg,
.admin-modal--xl {
width: 100%;
max-height: 92dvh;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.admin-modal__header {
padding: 16px 20px;
padding: 14px 16px;
}
.admin-modal__body {
padding: 20px;
padding: 14px 16px 18px;
}
.admin-modal__footer {
+43 -2
View File
@@ -1,5 +1,5 @@
import { computed, inject, onMounted, provide, reactive, ref, type InjectionKey } from 'vue'
import { useRouter } from 'vue-router'
import { computed, inject, onMounted, provide, reactive, ref, watch, type InjectionKey } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { companiesApi, mapAdminOrder, ordersApi } from '@/services/api'
import { ApiError } from '@/services/http'
import type { Company, Order, RawOrderStatus, SettlementMethod } from '@/types'
@@ -8,6 +8,7 @@ import { rawStatusLabel } from '@/utils/orderStatus'
import { useToast } from '@/composables/useToast'
export function useOrdersPage() {
const route = useRoute()
const router = useRouter()
const toast = useToast()
@@ -54,6 +55,38 @@ export function useOrdersPage() {
orderList.value.find((o) => o.id === selectedOrderId.value) ?? null,
)
function findOrderByRef(ref: string): Order | undefined {
const key = ref.trim()
if (!key) return undefined
return orderList.value.find(
(o) => o.id === key || o.orderNumber.toUpperCase() === key.toUpperCase(),
)
}
async function applyOrderDeepLink() {
const orderRef = typeof route.query.order === 'string' ? route.query.order : ''
if (!orderRef || !orderList.value.length) return
const order = findOrderByRef(orderRef)
if (!order) {
searchQuery.value = orderRef
return
}
const focus = typeof route.query.focus === 'string' ? route.query.focus : ''
if (focus === 'pricing') {
await router.replace(`/orders/${order.id}/pricing`)
return
}
if (focus === 'checks') {
await router.replace(`/orders/${order.id}/checks`)
return
}
openOrderDetail(order)
searchQuery.value = order.orderNumber
}
async function loadData() {
try {
const [ordersData, companiesData] = await Promise.all([
@@ -65,6 +98,7 @@ export function useOrdersPage() {
if (!form.companyId && companiesData[0]) {
form.companyId = companiesData[0].id
}
await applyOrderDeepLink()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری سفارش‌ها انجام نشد.')
}
@@ -74,6 +108,13 @@ export function useOrdersPage() {
void loadData()
})
watch(
() => [route.query.order, route.query.focus] as const,
() => {
void applyOrderDeepLink()
},
)
function selectOrder(order: Order, openDetail = false) {
selectedOrderId.value = order.id
if (openDetail) {
+22 -4
View File
@@ -242,14 +242,18 @@ export const internalMessages: InternalMessage[] = [
preview: 'لطفاً وضعیت باربری سفارش مشتری پارچه‌سرای نیکو را بررسی کنید.',
sentAt: '2024-10-25T09:15:00',
isRead: false,
orderId: 'ord-1',
link: '/shipping?order=ORD-1403-1256',
},
{
id: 'msg-2',
senderName: 'رضا کریمی',
subject: 'درخواست اصلاح قیمت',
preview: 'مشتری درخواست اصلاح متراژ سفارش را داده است.',
preview: 'مشتری درخواست تخفیف ۵٪ روی متراژ بالای ۳۰۰۰ متر داده است.',
sentAt: '2024-10-25T08:40:00',
isRead: false,
orderId: 'ord-1',
link: '/orders/ord-1/pricing',
},
{
id: 'msg-3',
@@ -258,6 +262,8 @@ export const internalMessages: InternalMessage[] = [
preview: 'دو فقره چک بررسی شد؛ یک مورد نیاز به اصلاح دارد.',
sentAt: '2024-10-24T16:20:00',
isRead: false,
orderId: 'ord-3',
link: '/orders/ord-3/checks',
},
{
id: 'msg-4',
@@ -266,6 +272,7 @@ export const internalMessages: InternalMessage[] = [
preview: 'گزارش فروش واحد تهران برای هفته جاری آماده است.',
sentAt: '2024-10-24T11:00:00',
isRead: false,
link: '/reports',
},
{
id: 'msg-5',
@@ -274,6 +281,7 @@ export const internalMessages: InternalMessage[] = [
preview: 'مشتری «نساجی آریا» با مدارک کامل ثبت شد.',
sentAt: '2024-10-23T14:30:00',
isRead: false,
link: '/customers',
},
{
id: 'msg-6',
@@ -293,7 +301,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-25T10:05:00',
isRead: false,
tone: 'info',
link: '/orders',
link: '/orders?order=ORD-1403-1260',
},
{
id: 'ntf-2',
@@ -302,7 +310,8 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-25T09:50:00',
isRead: false,
tone: 'warning',
link: '/orders/ord-1/checks',
orderId: 'ord-3',
link: '/orders/ord-3/checks',
},
{
id: 'ntf-3',
@@ -311,6 +320,8 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-25T09:20:00',
isRead: false,
tone: 'success',
orderId: 'ord-5',
link: '/orders/ord-5/pricing',
},
{
id: 'ntf-4',
@@ -319,6 +330,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-25T08:00:00',
isRead: false,
tone: 'danger',
link: '/checks',
},
{
id: 'ntf-5',
@@ -327,6 +339,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-24T17:30:00',
isRead: false,
tone: 'warning',
link: '/orders?order=ORD-1403-1248&focus=pricing',
},
{
id: 'ntf-6',
@@ -335,7 +348,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-24T15:00:00',
isRead: false,
tone: 'info',
link: '/companies',
link: '/companies/company-fam-baft/accounts',
},
{
id: 'ntf-7',
@@ -344,6 +357,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-24T12:45:00',
isRead: false,
tone: 'success',
link: '/shipping?order=ORD-1403-1245',
},
{
id: 'ntf-8',
@@ -352,6 +366,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-24T11:20:00',
isRead: false,
tone: 'danger',
link: '/settings',
},
{
id: 'ntf-9',
@@ -360,6 +375,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-23T16:00:00',
isRead: false,
tone: 'warning',
link: '/orders?order=ORD-1403-1240',
},
{
id: 'ntf-10',
@@ -377,6 +393,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-22T14:00:00',
isRead: false,
tone: 'info',
link: '/catalogs',
},
{
id: 'ntf-12',
@@ -394,6 +411,7 @@ export const adminNotifications: AdminNotification[] = [
sentAt: '2024-10-21T06:00:00',
isRead: true,
tone: 'success',
link: '/settings',
},
]
+9
View File
@@ -40,6 +40,15 @@ const router = createRouter({
component: () => import('@/views/checks/CheckManagementView.vue'),
meta: { title: 'مدیریت چک‌ها' },
},
{
/* API links often use /orders/:id — open list + detail via query */
path: 'orders/:id',
name: 'order-detail',
redirect: (to) => ({
name: 'orders',
query: { order: String(to.params.id) },
}),
},
{
path: 'companies',
name: 'companies',
+3
View File
@@ -130,6 +130,8 @@ export interface InternalMessage {
preview: string
sentAt: string
isRead: boolean
link?: string
orderId?: string
}
export interface AdminNotification {
@@ -140,6 +142,7 @@ export interface AdminNotification {
isRead: boolean
tone?: 'info' | 'warning' | 'success' | 'danger'
link?: string
orderId?: string
}
export type CustomerType = 'wholesale' | 'retail'
+52 -4
View File
@@ -27,6 +27,16 @@ export function formatMeterage(value: number): string {
return `${formatNumber(value)} متر`
}
export function toPersianDigits(input: string | number): string {
return String(input).replace(/\d/g, (d) => persianDigits[Number(d)]!)
}
const persianDateFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
const persianDateTimeFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
year: 'numeric',
month: '2-digit',
@@ -35,12 +45,50 @@ const persianDateTimeFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
minute: '2-digit',
})
export function formatDateTime(date: string): string {
return persianDateTimeFormatter.format(new Date(date))
/** Detect already-Jalali date strings like 1403/07/30 or 1403-07-30 */
function isJalaliDateString(value: string): boolean {
const normalized = normalizeDigits(value.trim()).replace(/-/g, '/')
const match = normalized.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})/)
if (!match) return false
const year = Number(match[1])
return year >= 1300 && year <= 1500
}
export function toPersianDigits(input: string | number): string {
return String(input).replace(/\d/g, (d) => persianDigits[Number(d)]!)
function toDateValue(date: string | Date): Date {
if (typeof date !== 'string') return date
const trimmed = date.trim()
// YYYY-MM-DD (and datetime starting with it) as local calendar date — avoids UTC day-shift
const isoDay = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/)
if (isoDay && Number(isoDay[1]) >= 1700) {
return new Date(Number(isoDay[1]), Number(isoDay[2]) - 1, Number(isoDay[3]))
}
const parsed = new Date(trimmed)
if (Number.isNaN(parsed.getTime())) return new Date(NaN)
return parsed
}
export function formatDate(date: string | Date | null | undefined): string {
if (!date) return '—'
if (typeof date === 'string' && isJalaliDateString(date)) {
const normalized = normalizeDigits(date.trim()).replace(/-/g, '/')
const match = normalized.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})/)
if (!match) return toPersianDigits(normalized)
const [, y, m, d] = match
return toPersianDigits(`${y}/${m!.padStart(2, '0')}/${d!.padStart(2, '0')}`)
}
const value = toDateValue(date)
if (Number.isNaN(value.getTime())) return '—'
return persianDateFormatter.format(value)
}
export function formatDateTime(date: string | Date | null | undefined): string {
if (!date) return '—'
if (typeof date === 'string' && isJalaliDateString(date) && !/[T\s]\d/.test(date)) {
return formatDate(date)
}
const value = toDateValue(date)
if (Number.isNaN(value.getTime())) return '—'
return persianDateTimeFormatter.format(value)
}
export function generateOrderNumber(): string {
+112
View File
@@ -0,0 +1,112 @@
/** Resolve admin header message/notification click targets. */
const ORDER_NUMBER_RE = /ORD-?\d{2,4}-?\d{2,6}/i
const BARE_ORDER_PATH_RE = /^\/?(?:admin\/)?orders\/([^/?#]+)\/?$/i
const ORDER_SUBPATH_RE = /^\/?(?:admin\/)?orders\/([^/?#]+)\/(pricing|checks)\/?$/i
export function extractOrderNumber(text: string): string | null {
const match = text.match(ORDER_NUMBER_RE)
if (!match?.[0]) return null
const digits = match[0].toUpperCase().replace(/^ORD-?/, '').replace(/-/g, '')
if (digits.length < 6) return match[0].toUpperCase()
const year = digits.slice(0, 4)
const seq = digits.slice(4)
return `ORD-${year}-${seq}`
}
function hasKeyword(text: string, patterns: RegExp[]): boolean {
return patterns.some((pattern) => pattern.test(text))
}
/** Rewrite API links to routes that exist in the admin app. */
export function normalizeAdminInboxLink(link: string): string {
let path = link.trim()
if (!path) return path
try {
if (/^https?:\/\//i.test(path)) {
const url = new URL(path)
path = `${url.pathname}${url.search}`
}
} catch {
/* keep raw path */
}
path = path.replace(/^\/admin(?=\/)/, '')
if (!path.startsWith('/')) path = `/${path}`
const sub = path.match(ORDER_SUBPATH_RE)
if (sub?.[1] && sub[2]) {
return `/orders/${sub[1]}/${sub[2]}`
}
const bare = path.match(BARE_ORDER_PATH_RE)
if (bare?.[1]) {
return `/orders?order=${encodeURIComponent(bare[1])}`
}
return path
}
export type InboxLinkInput = {
link?: string | null
orderId?: string | null
title?: string | null
body?: string | null
}
/**
* Prefer explicit `link`, then `orderId` + keywords, then order number / keyword heuristics.
*/
export function resolveAdminInboxLink(input: InboxLinkInput): string | null {
const explicit = input.link?.trim()
if (explicit) return normalizeAdminInboxLink(explicit)
const title = input.title?.trim() ?? ''
const body = input.body?.trim() ?? ''
const text = `${title} ${body}`
const orderId = input.orderId?.trim() || null
const orderNumber = extractOrderNumber(text)
const isSms = hasKeyword(text, [/پیامک/])
const isCheck = hasKeyword(text, [/چک/])
const isPricing = hasKeyword(text, [/قیمت/, /تخفیف/, /پیش[‌\s]?فاکتور/, /قیمت‌گذاری/, /اصلاح قیمت/])
const isShipping = !isSms && hasKeyword(text, [/ارسال/, /باربری/, /حمل/, /بارنامه/])
const isCash = hasKeyword(text, [/نقدی/, /پرداخت نقد/, /رسید پرداخت/])
const isReport = hasKeyword(text, [/گزارش/])
const isCustomer = hasKeyword(text, [/مشتری جدید/, /ثبت مشتری/])
const isCatalog = hasKeyword(text, [/کالیته/])
const isProduct = hasKeyword(text, [/محصول/])
const isCompany = hasKeyword(text, [/شرکت/, /حساب بانکی/])
const isOrderish = hasKeyword(text, [/سفارش/])
const isSettings = isSms || hasKeyword(text, [/پشتیبان/, /تنظیمات/])
if (orderId) {
if (isCheck) return `/orders/${orderId}/checks`
if (isPricing) return `/orders/${orderId}/pricing`
if (isShipping) return `/shipping?order=${encodeURIComponent(orderId)}`
if (isCash) return `/cash-payments?order=${encodeURIComponent(orderId)}`
return `/orders?order=${encodeURIComponent(orderId)}`
}
if (orderNumber) {
if (isCheck) return `/orders?order=${encodeURIComponent(orderNumber)}&focus=checks`
if (isPricing) return `/orders?order=${encodeURIComponent(orderNumber)}&focus=pricing`
if (isShipping) return `/shipping?order=${encodeURIComponent(orderNumber)}`
if (isCash) return `/cash-payments?order=${encodeURIComponent(orderNumber)}`
return `/orders?order=${encodeURIComponent(orderNumber)}`
}
if (isCheck) return '/checks'
if (isShipping) return '/shipping'
if (isCash) return '/cash-payments'
if (isReport) return '/reports'
if (isCustomer) return '/customers'
if (isCatalog) return '/catalogs'
if (isProduct) return '/products'
if (isCompany) return '/companies'
if (isSettings) return '/settings'
if (isPricing || isOrderish) return '/orders'
return null
}
+54 -6
View File
@@ -5,8 +5,9 @@ import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { checksApi, mapAdminOrder, ordersApi, type ApiOrder } from '@/services/api'
import { ApiError } from '@/services/http'
import { formatCurrency } from '@/utils/format'
import { formatCurrency, formatDate } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import { confirm } from '@/composables/useConfirm'
import type { Check } from '@/types'
const route = useRoute()
@@ -55,6 +56,28 @@ function checkBadgeStatus(status: Check['status']) {
}
async function updateCheckStatus(check: Check, status: Check['status']) {
if (status !== 'approved' && status !== 'rejected') return
const amountLabel = formatCurrency(check.amount)
const ok = await confirm(
status === 'approved'
? {
title: 'تأیید چک',
message: `آیا از تأیید چک ردیف ${check.row} به مبلغ ${amountLabel} مطمئن هستید؟`,
tone: 'primary',
confirmLabel: 'تأیید چک',
cancelLabel: 'انصراف',
}
: {
title: 'رد چک',
message: `آیا از رد چک ردیف ${check.row} به مبلغ ${amountLabel} مطمئن هستید؟`,
tone: 'danger',
confirmLabel: 'رد چک',
cancelLabel: 'انصراف',
},
)
if (!ok) return
busyId.value = check.id
try {
const updated = await checksApi.updateStatus(check.id, { status })
@@ -70,9 +93,34 @@ async function updateCheckStatus(check: Check, status: Check['status']) {
async function approveAll() {
const pending = orderChecks.value.filter((c) => c.status === 'pending_review')
for (const check of pending) {
await updateCheckStatus(check, 'approved')
if (!pending.length) {
toast.error('چکی برای تأیید وجود ندارد.')
return
}
const ok = await confirm({
title: 'تأیید همه چک‌ها',
message: `آیا از تأیید ${pending.length} چک در انتظار بررسی مطمئن هستید؟`,
tone: 'primary',
confirmLabel: 'تأیید همه',
cancelLabel: 'انصراف',
})
if (!ok) return
for (const check of pending) {
busyId.value = check.id
try {
const updated = await checksApi.updateStatus(check.id, { status: 'approved' })
const index = orderChecks.value.findIndex((c) => c.id === check.id)
if (index !== -1) orderChecks.value[index] = updated
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'به‌روزرسانی وضعیت چک انجام نشد.')
busyId.value = null
return
}
}
busyId.value = null
toast.success('همه چک‌های در انتظار تأیید شدند.')
}
</script>
@@ -129,7 +177,7 @@ async function approveAll() {
{{ order?.checkRassDays ?? '—' }} روز
</p>
<p v-if="order?.checkRassDate" class="checks-page__rass-date">
تاریخ راس: {{ order.checkRassDate.slice(0, 10) }}
تاریخ راس: {{ formatDate(order.checkRassDate) }}
</p>
</div>
<div class="admin-summary-item">
@@ -164,7 +212,7 @@ async function approveAll() {
<td>{{ formatCurrency(check.amount) }}</td>
<td>{{ check.bankName }}</td>
<td>{{ check.sayadId }}</td>
<td>{{ check.dueDate.slice(0, 10) }}</td>
<td>{{ formatDate(check.dueDate) }}</td>
<td><StatusBadge :status="checkBadgeStatus(check.status)" /></td>
<td>
<div v-if="check.status === 'pending_review'" class="admin-action-group">
@@ -201,7 +249,7 @@ async function approveAll() {
</div>
<div class="admin-card-item__meta">
<span>بانک {{ check.bankName }}</span>
<span>سررسید {{ check.dueDate.slice(0, 10) }}</span>
<span>سررسید {{ formatDate(check.dueDate) }}</span>
<span dir="ltr">{{ check.sayadId }}</span>
</div>
<div v-if="check.status === 'pending_review'" class="admin-card-item__actions">
+14 -1
View File
@@ -526,7 +526,7 @@ async function sendInvoice() {
.pricing-page__compact-fields {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
@@ -537,6 +537,13 @@ async function sendInvoice() {
gap: 6px;
font-size: 0.75rem;
color: var(--color-text-muted);
min-width: 0;
}
.pricing-page__compact-fields :deep(.admin-input),
.pricing-page__compact-fields :deep(input) {
width: 100%;
min-width: 0;
}
.pricing-page__invoice-hint {
@@ -553,4 +560,10 @@ async function sendInvoice() {
font-weight: 700;
color: var(--color-text);
}
@media (max-width: 479px) {
.pricing-page__compact-fields {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { useMediaQuery } from '@vueuse/core'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
import { useOrdersPageContext } from '@/composables/useOrdersPage'
import { ORDER_TABS } from '@/utils/orderStatus'
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
const isMobile = useMediaQuery('(max-width: 767px)')
const {
activeTab,
selectedOrderId,
@@ -14,8 +18,11 @@ const {
filteredOrders,
selectedOrder,
showDetailPanel,
showDetailModal,
selectOrder,
openOrderDetail,
closeDetailPanel,
closeDetailModal,
settlementLabel,
goPricing,
goChecks,
@@ -24,6 +31,35 @@ const {
statusSaving,
openAddModal,
} = useOrdersPageContext()
function openDetail(order: (typeof filteredOrders.value)[number]) {
if (isMobile.value) {
selectOrder(order, true)
return
}
openOrderDetail(order)
}
function handlePricing() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goPricing(selectedOrder.value)
}
function handleChecks() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goChecks(selectedOrder.value)
}
function handleCashReceipt() {
if (!selectedOrder.value) return
closeDetailModal()
closeDetailPanel()
goCashReceipt(selectedOrder.value)
}
</script>
<template>
@@ -77,7 +113,7 @@ const {
type="button"
class="admin-card-item"
:class="{ selected: selectedOrderId === order.id }"
@click="selectOrder(order, true)"
@click="openDetail(order)"
>
<div class="admin-card-item__header">
<p class="admin-card-item__title">{{ order.orderNumber }}</p>
@@ -92,7 +128,11 @@ const {
</button>
</div>
<div class="admin-detail-panel" :class="{ open: showDetailPanel && selectedOrder }">
<!-- Mobile: full-screen slide panel -->
<div
class="admin-detail-panel compact-orders__mobile-panel"
:class="{ open: isMobile && showDetailPanel && selectedOrder }"
>
<div v-if="selectedOrder" class="admin-detail-panel__inner">
<div class="admin-detail-panel__header">
<button
@@ -110,15 +150,36 @@ const {
:order="selectedOrder"
:settlement-label="settlementLabel"
:status-saving="statusSaving"
@pricing="goPricing(selectedOrder)"
@checks="goChecks(selectedOrder)"
@cash-receipt="goCashReceipt(selectedOrder)"
@pricing="handlePricing"
@checks="handleChecks"
@cash-receipt="handleCashReceipt"
@status="changeStatus"
/>
</div>
</div>
</div>
<!-- Tablet / small desktop: modal -->
<BaseModal
:open="!isMobile && showDetailModal && !!selectedOrder"
title="جزئیات سفارش"
size="xl"
@close="closeDetailModal"
>
<template v-if="selectedOrder">
<p class="compact-orders__modal-breadcrumb">سفارش‌ها / جزئیات سفارش</p>
<OrderDetailContent
:order="selectedOrder"
:settlement-label="settlementLabel"
:status-saving="statusSaving"
@pricing="handlePricing"
@checks="handleChecks"
@cash-receipt="handleCashReceipt"
@status="changeStatus"
/>
</template>
</BaseModal>
<OrderAddModal />
</div>
</template>
@@ -149,6 +210,7 @@ const {
background: transparent;
font-size: 0.875rem;
outline: none;
color: var(--color-text);
}
.admin-card-item {
@@ -162,4 +224,16 @@ const {
height: 100%;
background: var(--color-bg);
}
.compact-orders__modal-breadcrumb {
margin: 0 0 16px;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
@media (min-width: 768px) {
.compact-orders__mobile-panel {
display: none !important;
}
}
</style>
+246 -123
View File
@@ -6,12 +6,15 @@ import { formatCurrency, formatMeterage, formatNumber } from '@/utils/format'
import { RAW_ORDER_STATUS_LABELS, rawStatusLabel } from '@/utils/orderStatus'
import type { Order, RawOrderStatus } from '@/types'
const props = defineProps<{
order: Order
settlementLabel: (method?: string | null) => string
layout?: 'desktop' | 'compact'
statusSaving?: boolean
}>()
const props = withDefaults(
defineProps<{
order: Order
settlementLabel: (method?: string | null) => string
layout?: 'desktop' | 'compact'
statusSaving?: boolean
}>(),
{ layout: 'compact' },
)
const emit = defineEmits<{
pricing: []
@@ -47,6 +50,8 @@ const canSubmitStatus = computed(
() => !!nextStatus.value && (!noteRequired.value || statusNote.value.trim().length > 0),
)
const useTable = computed(() => props.layout === 'desktop')
watch([() => props.order.id, manualMode], () => {
nextStatus.value = ''
statusNote.value = ''
@@ -68,78 +73,104 @@ function submitStatus() {
</script>
<template>
<div class="order-detail">
<div class="order-detail__id">
<strong>{{ order.orderNumber }}</strong>
<StatusBadge :status="order.status" />
<span class="order-detail__raw-status">{{ rawStatusLabel(order.rawStatus) }}</span>
</div>
<div class="order-detail__section">
<h3 class="order-detail__section-title">اطلاعات مشتری</h3>
<div class="admin-info-grid">
<div class="admin-info-row">
<AdminIcon name="building" :size="16" />
<span class="admin-info-row__label">شرکت:</span>
<span class="admin-info-row__value">{{ order.customerName }}</span>
</div>
<div class="admin-info-row">
<AdminIcon name="phone" :size="16" />
<span class="admin-info-row__label">تلفن:</span>
<span class="admin-info-row__value">{{ order.customerPhone ?? '—' }}</span>
</div>
<div class="admin-info-row">
<AdminIcon name="id-card" :size="16" />
<span class="admin-info-row__label">شناسه ملی:</span>
<span class="admin-info-row__value">{{ order.customerNationalId ?? '—' }}</span>
</div>
<div class="admin-info-row">
<AdminIcon name="user" :size="16" />
<span class="admin-info-row__label">نوع مشتری:</span>
<span class="admin-info-row__value">{{ order.customerType ?? '—' }}</span>
<div class="order-detail" :class="`order-detail--${layout}`">
<header class="order-detail__hero">
<div class="order-detail__hero-text">
<div class="order-detail__id">
<strong>{{ order.orderNumber }}</strong>
<StatusBadge :status="order.status" />
</div>
<p class="order-detail__raw-status">{{ rawStatusLabel(order.rawStatus) }}</p>
</div>
</div>
<div class="order-detail__section">
<h3 class="order-detail__section-title">اطلاعات سفارش</h3>
<div class="admin-info-grid">
<div class="admin-info-row">
<span class="admin-info-row__label">شرکت:</span>
<span class="admin-info-row__value">{{ order.companyName }}</span>
</div>
<div class="admin-info-row">
<span class="admin-info-row__label">روش تسویه:</span>
<span class="admin-info-row__value">
{{ settlementLabel(order.settlementMethod) }}
</span>
</div>
<div class="admin-info-row">
<span class="admin-info-row__label">متراژ کل:</span>
<span class="admin-info-row__value">{{ formatMeterage(order.meterage) }}</span>
</div>
<div class="admin-info-row">
<span class="admin-info-row__label">مبلغ کل:</span>
<span class="admin-info-row__value">
{{ order.totalAmount != null ? formatCurrency(order.totalAmount) : 'پس از قیمت‌گذاری' }}
</span>
</div>
<div class="order-detail__actions">
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="emit('pricing')">
قیمت‌گذاری سفارش
</button>
<button
v-if="order.settlementMethod === 'check'"
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
@click="emit('checks')"
>
مدیریت چک‌ها
</button>
<button
v-if="order.settlementMethod === 'cash'"
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
@click="emit('cashReceipt')"
>
رسید پرداخت
</button>
</div>
</header>
<div class="order-detail__panels">
<section class="order-detail__section">
<h3 class="order-detail__section-title">اطلاعات مشتری</h3>
<div class="order-detail__info">
<div class="order-detail__info-row">
<AdminIcon name="building" :size="16" />
<span class="order-detail__info-label">مشتری</span>
<span class="order-detail__info-value">{{ order.customerName }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="phone" :size="16" />
<span class="order-detail__info-label">تلفن</span>
<span class="order-detail__info-value" dir="ltr">{{ order.customerPhone ?? '—' }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="id-card" :size="16" />
<span class="order-detail__info-label">شناسه ملی</span>
<span class="order-detail__info-value">{{ order.customerNationalId ?? '—' }}</span>
</div>
<div class="order-detail__info-row">
<AdminIcon name="user" :size="16" />
<span class="order-detail__info-label">نوع مشتری</span>
<span class="order-detail__info-value">{{ order.customerType ?? '—' }}</span>
</div>
</div>
</section>
<section class="order-detail__section">
<h3 class="order-detail__section-title">اطلاعات سفارش</h3>
<div class="order-detail__info">
<div class="order-detail__info-row">
<span class="order-detail__info-label">شرکت</span>
<span class="order-detail__info-value">{{ order.companyName }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">روش تسویه</span>
<span class="order-detail__info-value">{{ settlementLabel(order.settlementMethod) }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">متراژ کل</span>
<span class="order-detail__info-value">{{ formatMeterage(order.meterage) }}</span>
</div>
<div class="order-detail__info-row">
<span class="order-detail__info-label">مبلغ کل</span>
<span class="order-detail__info-value">
{{ order.totalAmount != null ? formatCurrency(order.totalAmount) : 'پس از قیمت‌گذاری' }}
</span>
</div>
</div>
</section>
</div>
<div v-if="order.items?.length" class="order-detail__section">
<section v-if="order.items?.length" class="order-detail__section">
<h3 class="order-detail__section-title">محصولات سفارش</h3>
<div v-if="layout === 'desktop'" class="admin-table-wrap">
<div v-if="useTable" class="admin-table-wrap order-detail__table">
<table class="admin-table admin-table--compact">
<thead>
<tr>
<th>کالیته</th>
<th>محصول</th>
<th>رنگ / نوع</th>
<th>رنگ</th>
<th>متراژ</th>
<th>قیمت تأیید شده</th>
<th>مبلغ کل</th>
<th>وضعیت</th>
<th>قیمت</th>
<th>مبلغ</th>
</tr>
</thead>
<tbody>
@@ -150,23 +181,30 @@ function submitStatus() {
<td>{{ formatNumber(item.confirmedMeterage ?? item.requestedMeterage) }}</td>
<td>{{ item.supplyPrice != null ? formatCurrency(item.supplyPrice) : '—' }}</td>
<td>{{ item.totalAmount != null ? formatCurrency(item.totalAmount) : '—' }}</td>
<td><StatusBadge status="pending_review" /></td>
</tr>
</tbody>
</table>
</div>
<ul v-else class="order-detail__items">
<li v-for="item in order.items" :key="item.id" class="order-detail__item">
<strong>{{ item.productName }}</strong>
<span>{{ item.catalogCode }} · {{ item.color }}</span>
<span>{{ formatMeterage(item.confirmedMeterage ?? item.requestedMeterage) }}</span>
<span v-if="item.supplyPrice != null">قیمت: {{ formatCurrency(item.supplyPrice) }}</span>
<span v-if="item.totalAmount != null">مبلغ: {{ formatCurrency(item.totalAmount) }}</span>
<div class="order-detail__item-head">
<strong>{{ item.productName }}</strong>
<span>{{ item.catalogCode }}</span>
</div>
<div class="order-detail__item-meta">
<span>{{ item.color }}</span>
<span>{{ formatMeterage(item.confirmedMeterage ?? item.requestedMeterage) }}</span>
<span v-if="item.supplyPrice != null">{{ formatCurrency(item.supplyPrice) }}</span>
<span v-if="item.totalAmount != null" class="order-detail__item-total">
{{ formatCurrency(item.totalAmount) }}
</span>
</div>
</li>
</ul>
</div>
</section>
<div class="order-detail__section order-detail__status">
<section class="order-detail__section order-detail__status">
<div class="order-detail__status-head">
<h3 class="order-detail__section-title">تغییر وضعیت</h3>
<label class="order-detail__manual">
@@ -176,13 +214,13 @@ function submitStatus() {
</div>
<p v-if="manualMode" class="order-detail__status-hint">
این گذر خارج از روال استاندارد است؛ ثبت یادداشت اجباری است و در تاریخچهٔ سفارش ذخیره می‌شود.
این گذر خارج از روال استاندارد است؛ ثبت یادداشت اجباری است.
</p>
<p v-else-if="!statusOptions.length" class="order-detail__status-hint">
در این وضعیت گذر خودکاری وجود ندارد. برای تغییر، حالت دستی را فعال کنید.
</p>
<template v-if="statusOptions.length">
<div v-if="statusOptions.length" class="order-detail__status-form">
<div class="admin-form__field">
<label for="next-status">وضعیت بعدی</label>
<select id="next-status" v-model="nextStatus" class="admin-input">
@@ -214,91 +252,160 @@ function submitStatus() {
>
{{ statusSaving ? 'در حال ثبت…' : 'ثبت وضعیت' }}
</button>
</template>
</div>
<div class="order-detail__footer">
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="$emit('pricing')">
قیمت‌گذاری سفارش
</button>
<button
v-if="order.settlementMethod === 'check'"
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
@click="$emit('checks')"
>
مدیریت چک‌ها
</button>
<button
v-if="order.settlementMethod === 'cash'"
type="button"
class="admin-btn admin-btn--outline admin-btn--sm"
@click="$emit('cashReceipt')"
>
رسید پرداخت این سفارش
</button>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.order-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.order-detail__hero {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-elevated, var(--color-bg));
}
.order-detail__id {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 20px;
font-size: 0.9375rem;
gap: 8px;
margin-bottom: 4px;
font-size: 1rem;
}
.order-detail__raw-status {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.order-detail__actions .admin-btn {
min-height: 40px;
}
.order-detail__panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.order-detail__section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--color-border-light);
margin: 0;
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.order-detail__section-title {
margin: 0 0 12px;
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--color-text);
}
.order-detail__info {
display: grid;
gap: 10px;
}
.order-detail__info-row {
display: grid;
grid-template-columns: auto minmax(72px, auto) 1fr;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
}
.order-detail__info-row:not(:has(svg)) {
grid-template-columns: minmax(72px, auto) 1fr;
}
.order-detail__info-label {
color: var(--color-text-muted);
}
.order-detail__info-value {
font-weight: 600;
color: var(--color-text);
word-break: break-word;
}
.order-detail__table {
overflow-x: auto;
}
.order-detail__items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
display: grid;
gap: 8px;
}
.order-detail__item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
background: var(--color-border-light);
padding: 12px;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
background: var(--color-elevated, var(--color-bg));
border: 1px solid var(--color-border);
}
.order-detail__item strong {
.order-detail__item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
font-size: 0.875rem;
}
.order-detail__item span {
.order-detail__item-head span {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__raw-status {
.order-detail__item-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.order-detail__item-total {
margin-inline-start: auto;
font-weight: 700;
color: var(--color-text);
}
.order-detail__status-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.order-detail__status-head .order-detail__section-title {
margin: 0;
}
.order-detail__manual {
@@ -323,10 +430,26 @@ function submitStatus() {
color: var(--color-text-muted);
}
.order-detail__footer {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
.order-detail__status-form {
display: grid;
gap: 12px;
}
@media (max-width: 639px) {
.order-detail__panels {
grid-template-columns: 1fr;
}
.order-detail__hero {
flex-direction: column;
}
.order-detail__actions {
width: 100%;
}
.order-detail__actions .admin-btn {
flex: 1;
}
}
</style>
+13 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import { mapAdminOrder, ordersApi, shipmentsApi } from '@/services/api'
@@ -9,9 +10,10 @@ import { formatDateTime, formatMeterage } from '@/utils/format'
import { useToast } from '@/composables/useToast'
import type { Order, Shipment, ShipmentStatus } from '@/types'
const route = useRoute()
const shipmentList = ref<Shipment[]>([])
const orders = ref<Order[]>([])
const searchQuery = ref('')
const searchQuery = ref(typeof route.query.order === 'string' ? route.query.order : '')
const statusFilter = ref<'all' | ShipmentStatus>('all')
const showModal = ref(false)
const formError = ref('')
@@ -70,6 +72,15 @@ onMounted(() => {
void loadData()
})
watch(
() => route.query.order,
(orderRef) => {
if (typeof orderRef === 'string' && orderRef.trim()) {
searchQuery.value = orderRef.trim()
}
},
)
function onOrderChange() {
const order = orders.value.find((o) => o.id === form.orderId)
if (order) form.meterage = order.meterage