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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user