From 265e5e0e30e9056821d8705a2fef102675114f41 Mon Sep 17 00:00:00 2001 From: adonis Date: Sun, 9 Aug 2026 12:00:18 +0330 Subject: [PATCH] Add invoice send, status transitions, and live settings/notifications. Let admins send priced invoices, change order status via allowedTransitions or forced manual jumps, persist notification reads, and save SystemSettings to the API. Co-authored-by: Cursor --- src/components/layout/AdminHeader.vue | 15 +- src/composables/useOrdersPage.ts | 44 ++++- src/services/api.ts | 77 ++++++++- src/types/index.ts | 36 +++- src/utils/orderStatus.ts | 43 ++++- src/views/cash-payments/CashPaymentsView.vue | 4 +- src/views/orders/OrderPricingView.vue | 83 +++++++++- .../orders/components/CompactOrdersPage.vue | 6 + .../orders/components/DesktopOrdersPage.vue | 12 ++ .../orders/components/OrderDetailContent.vue | 155 +++++++++++++++++- src/views/settings/SettingsView.vue | 63 ++++++- 11 files changed, 507 insertions(+), 31 deletions(-) diff --git a/src/components/layout/AdminHeader.vue b/src/components/layout/AdminHeader.vue index 1ba5d4a..39f335c 100644 --- a/src/components/layout/AdminHeader.vue +++ b/src/components/layout/AdminHeader.vue @@ -4,7 +4,7 @@ import { useRouter } from 'vue-router' import { onClickOutside, useEventListener } from '@vueuse/core' import AdminIcon from '@/components/icons/AdminIcon.vue' import BasicIcon from '@/components/BasicPacks/BasicIcon.vue' -import { dashboardApi } from '@/services/api' +import { dashboardApi, notificationsApi } from '@/services/api' import { formatDateTime } from '@/utils/format' import { useAuthStore } from '@/stores/auth' import { useThemeStore, type ThemePreference } from '@/stores/theme' @@ -77,7 +77,11 @@ function markMessageRead(message: InternalMessage) { } function markNotificationRead(notification: AdminNotification) { + if (notification.isRead) return notification.isRead = true + void notificationsApi.markRead(notification.id).catch(() => { + notification.isRead = false + }) } function markAllMessagesRead() { @@ -87,9 +91,16 @@ function markAllMessagesRead() { } function markAllNotificationsRead() { - notificationList.value.forEach((n) => { + const previouslyUnread = notificationList.value.filter((n) => !n.isRead) + if (!previouslyUnread.length) return + previouslyUnread.forEach((n) => { n.isRead = true }) + void notificationsApi.markAllRead().catch(() => { + previouslyUnread.forEach((n) => { + n.isRead = false + }) + }) } function openNotification(notification: AdminNotification) { diff --git a/src/composables/useOrdersPage.ts b/src/composables/useOrdersPage.ts index 4b5f570..fd4b7c5 100644 --- a/src/composables/useOrdersPage.ts +++ b/src/composables/useOrdersPage.ts @@ -2,7 +2,8 @@ import { computed, inject, onMounted, provide, reactive, ref, type InjectionKey import { useRouter } from 'vue-router' import { companiesApi, mapAdminOrder, ordersApi } from '@/services/api' import { ApiError } from '@/services/http' -import type { Company, Order, SettlementMethod } from '@/types' +import type { Company, Order, RawOrderStatus, SettlementMethod } from '@/types' +import { rawStatusLabel } from '@/utils/orderStatus' import { useToast } from '@/composables/useToast' @@ -93,10 +94,42 @@ export function useOrdersPage() { showDetailModal.value = false } - function settlementLabel(method: string) { + function settlementLabel(method?: string | null) { + if (!method) return 'انتخاب‌نشده' return method === 'check' ? 'چکی' : 'نقدی' } + const statusSaving = ref(false) + + async function changeStatus(payload: { + orderId: string + status: RawOrderStatus + note?: string + reason?: string + force?: boolean + }) { + if (statusSaving.value) return null + statusSaving.value = true + try { + const updated = await ordersApi.updateStatus(payload.orderId, { + status: payload.status, + note: payload.note, + reason: payload.reason, + force: payload.force, + }) + const mapped = mapAdminOrder(updated) as Order + const index = orderList.value.findIndex((o) => o.id === payload.orderId) + if (index >= 0) orderList.value[index] = { ...orderList.value[index]!, ...mapped } + toast.success(`وضعیت سفارش به «${rawStatusLabel(payload.status)}» تغییر کرد.`) + return mapped + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'تغییر وضعیت انجام نشد.') + return null + } finally { + statusSaving.value = false + } + } + function goPricing(order: Order) { router.push(`/orders/${order.id}/pricing`) } @@ -105,6 +138,10 @@ export function useOrdersPage() { router.push(`/orders/${order.id}/checks`) } + function goCashReceipt(order: Order) { + router.push({ path: '/cash-payments', query: { order: order.orderNumber } }) + } + function openAddModal() { Object.assign(form, emptyForm()) formError.value = '' @@ -162,8 +199,11 @@ export function useOrdersPage() { closeDetailPanel, closeDetailModal, settlementLabel, + changeStatus, + statusSaving, goPricing, goChecks, + goCashReceipt, openAddModal, closeAddModal, submitAddOrder, diff --git a/src/services/api.ts b/src/services/api.ts index 30ee5cd..e3a54e5 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -14,6 +14,7 @@ import type { OrderItem, OrderStatus, Product, + RawOrderStatus, SettlementMethod, Shipment, ShipmentStatus, @@ -21,13 +22,33 @@ import type { } from '@/types' /** Normalize admin list order status badges */ -export function mapAdminOrder( +export function mapAdminOrder< + T extends { + status: string + adminStatus?: string + allowedTransitions?: string[] + settlementMethod?: string | null + settlementType?: string | null + meterage?: number + totalMeterage?: number + }, +>( order: T, -): T & { status: OrderStatus; settlementMethod: SettlementMethod; meterage: number } { +): T & { + status: OrderStatus + rawStatus: RawOrderStatus + allowedTransitions: RawOrderStatus[] + settlementMethod: SettlementMethod | null + meterage: number +} { return { ...order, status: (order.adminStatus ?? order.status) as OrderStatus, - settlementMethod: (order.settlementMethod ?? order.settlementType ?? 'check') as SettlementMethod, + rawStatus: order.status as RawOrderStatus, + allowedTransitions: (order.allowedTransitions ?? []) as RawOrderStatus[], + settlementMethod: (order.settlementMethod ?? + order.settlementType ?? + null) as SettlementMethod | null, meterage: order.meterage ?? order.totalMeterage ?? 0, } } @@ -213,7 +234,7 @@ export type UpdateOrderPricingPayload = { export type ApiOrder = Order & { adminStatus?: string - settlementType?: SettlementMethod + settlementType?: SettlementMethod | null totalMeterage?: number shippingCost?: number | null finalAmount?: number | null @@ -221,6 +242,17 @@ export type ApiOrder = Order & { checkRassDate?: string items?: OrderItem[] checks?: Check[] + /** وضعیت‌های مجاز بعدی از دید ماشین وضعیت سرور */ + allowedTransitions?: string[] + cashReceipt?: { id: string } | null +} + +export type UpdateOrderStatusPayload = { + status: string + note?: string + reason?: string + /** گذر خارج از ماشین وضعیت؛ سرور reason را اجباری می‌کند */ + force?: boolean } export const ordersApi = { @@ -230,7 +262,7 @@ export const ordersApi = { create: (body: AdminCreateOrderPayload) => apiRequest('/admin/orders', { body }), updatePricing: (id: string, body: UpdateOrderPricingPayload) => apiRequest(`/admin/orders/${id}/pricing`, { method: 'PATCH', body }), - updateStatus: (id: string, body: { status: string; note?: string; reason?: string }) => + updateStatus: (id: string, body: UpdateOrderStatusPayload) => apiRequest(`/admin/orders/${id}/status`, { method: 'PATCH', body }), } @@ -330,3 +362,38 @@ export const dashboardApi = { get: () => apiRequest('/admin/dashboard'), reports: () => apiRequest('/admin/reports'), } + +/* ── System settings ──────────────────────────────────────── */ + +export type SystemSettings = { + id: string + maxMeteragePerInvoice: number + cashInvoiceValidityHours: number + defaultSettlementCheck: boolean + defaultSettlementCash: boolean + smsOrderRegistered: boolean + smsPricingReady: boolean + smsPaymentReminder: boolean + smsCheckReview: boolean + smsShippingUpdate: boolean + supportPhone: string | null + supportEmail: string | null + updatedAt: string +} + +export type UpdateSettingsPayload = Partial> + +export const settingsApi = { + get: () => apiRequest('/admin/settings'), + update: (body: UpdateSettingsPayload) => + apiRequest('/admin/settings', { method: 'PATCH', body }), +} + +/* ── Admin notifications ──────────────────────────────────── */ + +export const notificationsApi = { + markRead: (id: string) => + apiRequest<{ ok: boolean }>(`/admin/notifications/${id}/read`, { method: 'PATCH' }), + markAllRead: () => + apiRequest<{ ok: boolean }>('/admin/notifications/read-all', { method: 'PATCH' }), +} diff --git a/src/types/index.ts b/src/types/index.ts index 714d198..9ed333f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,9 @@ +/** سطل‌های نمایشی پنل ادمین که سرور از وضعیت واقعی سفارش می‌سازد */ export type OrderStatus = | 'pending_review' + | 'awaiting_customer' | 'pending_check' + | 'pending_payment' | 'needs_correction' | 'check_correction' | 'final_approval' @@ -10,6 +13,35 @@ export type OrderStatus = | 'rejected' | 'expired' +/** وضعیت واقعی سفارش در ماشین وضعیت سرور */ +export type RawOrderStatus = + | 'cart' + | 'submitted' + | 'awaiting_review' + | 'pricing' + | 'awaiting_settlement' + | 'awaiting_settlement_fix' + | 'cash_in_progress' + | 'cash_invoice_issued' + | 'awaiting_cash_payment' + | 'cash_payment_submitted' + | 'awaiting_cash_confirmation' + | 'cash_confirmed' + | 'check_in_progress' + | 'awaiting_check_review' + | 'check_needs_fix' + | 'check_rejected' + | 'checks_approved' + | 'final_approved' + | 'preparing' + | 'ready_to_ship' + | 'shipped' + | 'delivered' + | 'needs_correction' + | 'cancelled' + | 'rejected' + | 'expired' + export type SettlementMethod = 'check' | 'cash' export type CheckStatus = 'pending_review' | 'approved' | 'rejected' @@ -52,10 +84,12 @@ export interface Order { customerName: string companyName: string companyId: string - settlementMethod: SettlementMethod + settlementMethod: SettlementMethod | null totalAmount: number | null meterage: number status: OrderStatus + rawStatus?: RawOrderStatus + allowedTransitions?: RawOrderStatus[] createdAt: string customerPhone?: string customerNationalId?: string diff --git a/src/utils/orderStatus.ts b/src/utils/orderStatus.ts index 3a61931..43b1332 100644 --- a/src/utils/orderStatus.ts +++ b/src/utils/orderStatus.ts @@ -1,4 +1,4 @@ -import type { OrderStatus } from '@/types' +import type { OrderStatus, RawOrderStatus } from '@/types' export interface StatusMeta { label: string @@ -7,7 +7,9 @@ export interface StatusMeta { export const ORDER_STATUS_META: Record = { pending_review: { label: 'در انتظار بررسی', tone: 'warning' }, + awaiting_customer: { label: 'در انتظار مشتری', tone: 'info' }, pending_check: { label: 'در انتظار چک', tone: 'purple' }, + pending_payment: { label: 'بررسی رسید پرداخت', tone: 'purple' }, needs_correction: { label: 'نیاز به اصلاح', tone: 'danger' }, check_correction: { label: 'نیاز به اصلاح چک', tone: 'warning' }, final_approval: { label: 'تأیید نهایی', tone: 'success' }, @@ -18,12 +20,49 @@ export const ORDER_STATUS_META: Record = { expired: { label: 'منقضی شده', tone: 'neutral' }, } +/** برچسب وضعیت واقعی سفارش؛ باید با ORDER_STATUS_LABELS سرور یکسان بماند. */ +export const RAW_ORDER_STATUS_LABELS: Record = { + cart: 'سبد خرید', + submitted: 'ثبت‌شده', + awaiting_review: 'در انتظار بررسی', + pricing: 'قیمت‌گذاری', + awaiting_settlement: 'در انتظار تسویه', + awaiting_settlement_fix: 'اصلاح تسویه', + cash_in_progress: 'در حال ثبت نقدی', + cash_invoice_issued: 'صدور فاکتور نقدی', + awaiting_cash_payment: 'در انتظار پرداخت نقدی', + cash_payment_submitted: 'رسید نقدی ارسال‌شده', + awaiting_cash_confirmation: 'تأیید پرداخت نقدی', + cash_confirmed: 'پرداخت نقدی تأیید شد', + check_in_progress: 'در حال ثبت چک', + awaiting_check_review: 'بررسی چک', + check_needs_fix: 'اصلاح چک', + check_rejected: 'رد چک', + checks_approved: 'تأیید چک‌ها', + final_approved: 'تأیید نهایی', + preparing: 'آماده‌سازی', + ready_to_ship: 'آماده ارسال', + shipped: 'ارسال‌شده', + delivered: 'تحویل‌شده', + needs_correction: 'نیاز به اصلاح', + cancelled: 'لغو شده', + rejected: 'رد شده', + expired: 'منقضی', +} + +export function rawStatusLabel(status?: string): string { + if (!status) return '—' + return RAW_ORDER_STATUS_LABELS[status as RawOrderStatus] ?? status +} + export const ORDER_TABS = [ { key: 'all', label: 'همه' }, { key: 'pending_review', label: 'در انتظار بررسی' }, + { key: 'awaiting_customer', label: 'در انتظار مشتری' }, { key: 'pending_check', label: 'در انتظار چک' }, - { key: 'needs_correction', label: 'نیاز به اصلاح' }, + { key: 'pending_payment', label: 'بررسی رسید' }, { key: 'check_correction', label: 'اصلاح چک' }, + { key: 'needs_correction', label: 'نیاز به اصلاح' }, { key: 'final_approval', label: 'تأیید نهایی' }, { key: 'shipped', label: 'ارسال شده' }, { key: 'delivered', label: 'تحویل شده' }, diff --git a/src/views/cash-payments/CashPaymentsView.vue b/src/views/cash-payments/CashPaymentsView.vue index dda9a34..e5fd921 100644 --- a/src/views/cash-payments/CashPaymentsView.vue +++ b/src/views/cash-payments/CashPaymentsView.vue @@ -1,5 +1,6 @@ @@ -473,6 +539,13 @@ async function savePricing() { color: var(--color-text-muted); } +.pricing-page__invoice-hint { + margin: 12px 0 0; + font-size: 0.75rem; + line-height: 1.8; + color: var(--color-text-muted); +} + .pricing-page__line-total { grid-column: 1 / -1; margin: 0; diff --git a/src/views/orders/components/CompactOrdersPage.vue b/src/views/orders/components/CompactOrdersPage.vue index 8a3df87..2dfb88a 100644 --- a/src/views/orders/components/CompactOrdersPage.vue +++ b/src/views/orders/components/CompactOrdersPage.vue @@ -19,6 +19,9 @@ const { settlementLabel, goPricing, goChecks, + goCashReceipt, + changeStatus, + statusSaving, openAddModal, } = useOrdersPageContext() @@ -106,8 +109,11 @@ const { diff --git a/src/views/orders/components/DesktopOrdersPage.vue b/src/views/orders/components/DesktopOrdersPage.vue index fba7810..ea8aa33 100644 --- a/src/views/orders/components/DesktopOrdersPage.vue +++ b/src/views/orders/components/DesktopOrdersPage.vue @@ -20,6 +20,9 @@ const { settlementLabel, goPricing, goChecks, + goCashReceipt, + changeStatus, + statusSaving, openAddModal, } = useOrdersPageContext() @@ -34,6 +37,12 @@ function handleChecks() { closeDetailModal() goChecks(selectedOrder.value) } + +function handleCashReceipt() { + if (!selectedOrder.value) return + closeDetailModal() + goCashReceipt(selectedOrder.value) +} diff --git a/src/views/orders/components/OrderDetailContent.vue b/src/views/orders/components/OrderDetailContent.vue index 60a2250..9b4b5a4 100644 --- a/src/views/orders/components/OrderDetailContent.vue +++ b/src/views/orders/components/OrderDetailContent.vue @@ -1,19 +1,70 @@ @@ -178,6 +289,40 @@ defineEmits<{ color: var(--color-text-muted); } +.order-detail__raw-status { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.order-detail__status-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.order-detail__manual { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + color: var(--color-text-muted); + cursor: pointer; +} + +.order-detail__manual input { + width: 15px; + height: 15px; + accent-color: var(--color-primary); +} + +.order-detail__status-hint { + margin: 0 0 12px; + font-size: 0.75rem; + line-height: 1.7; + color: var(--color-text-muted); +} + .order-detail__footer { display: flex; flex-direction: column; diff --git a/src/views/settings/SettingsView.vue b/src/views/settings/SettingsView.vue index c79e19c..2a5c79e 100644 --- a/src/views/settings/SettingsView.vue +++ b/src/views/settings/SettingsView.vue @@ -1,24 +1,62 @@ @@ -29,8 +67,13 @@ function saveSettings() {

تنظیمات

سیستم / تنظیمات

- @@ -80,6 +123,10 @@ function saveSettings() { پیامک ثبت سفارش +