Move settlement after invoice and wire real notification inbox.

Submit orders without choosing payment first, route cash/check flows under /orders/:id, show status-based next actions, and connect the header bell to unread notifications.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-09 12:00:18 +03:30
co-authored by Cursor
parent 6ee3c6f1f8
commit 7acd225fad
23 changed files with 805 additions and 333 deletions
+13
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { useIntervalFn } from '@vueuse/core'
import { clamp } from 'lodash-es'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
@@ -8,11 +9,13 @@ import { useAuthStore } from '@/stores/auth'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import { useCatalogStore } from '@/stores/catalog'
import { useNotificationStore } from '@/stores/notification'
const authStore = useAuthStore()
const cartStore = useCartStore()
const orderStore = useOrderStore()
const catalogStore = useCatalogStore()
const notificationStore = useNotificationStore()
const router = useRouter()
const transitionName = ref('page-fade')
@@ -40,9 +43,19 @@ onMounted(async () => {
catalogStore.loadCompanies(),
cartStore.hydrate(),
orderStore.hydrate(),
notificationStore.hydrate(),
])
}
})
// خبرهای سفارش از سمت ادمین می‌آید، پس شمارندهٔ زنگ به‌آرامی تازه می‌شود
useIntervalFn(
() => {
if (authStore.isAuthenticated) void notificationStore.refreshUnreadCount()
},
60_000,
{ immediate: false },
)
</script>
<template>
+3 -3
View File
@@ -5,9 +5,9 @@ defineProps<{
const steps = [
{ num: 1, label: 'سبد خرید' },
{ num: 2, label: 'انتخاب تسویه' },
{ num: 3, label: 'ثبت اطلاعات' },
{ num: 4, label: 'تأیید نهایی' },
{ num: 2, label: 'ثبت سفارش' },
{ num: 3, label: 'صدور فاکتور' },
{ num: 4, label: 'تسویه' },
]
</script>
+35 -8
View File
@@ -3,18 +3,27 @@ import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useCartStore } from '@/stores/cart'
import { useNotificationStore } from '@/stores/notification'
import IconSearch from '@/components/icons/IconSearch.vue'
import IconBell from '@/components/icons/IconBell.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import { toPersianDigits } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const cartStore = useCartStore()
const notificationStore = useNotificationStore()
const searchQuery = ref('')
const showUserMenu = ref(false)
const unreadLabel = computed(() =>
toPersianDigits(
notificationStore.unreadCount > 9 ? '9+' : String(notificationStore.unreadCount),
),
)
const initials = computed(() => {
const first = authStore.user?.firstName?.[0] ?? ''
const last = authStore.user?.lastName?.[0] ?? ''
@@ -73,9 +82,20 @@ function logout() {
<div class="desktop-header__actions">
<span v-if="companyLabel" class="desktop-header__company">{{ companyLabel }}</span>
<button type="button" class="desktop-header__icon-btn" aria-label="اعلان‌ها">
<button
type="button"
class="desktop-header__icon-btn"
aria-label="اعلان‌ها"
@click="router.push('/profile/notifications')"
>
<IconBell :size="20" />
<span class="desktop-header__notif-dot" aria-hidden="true" />
<span
v-if="notificationStore.hasUnread"
class="desktop-header__notif-badge"
:aria-label="`${notificationStore.unreadCount} اعلان خوانده‌نشده`"
>
{{ unreadLabel }}
</span>
</button>
<div class="desktop-header__user-wrap">
@@ -200,14 +220,21 @@ function logout() {
justify-content: center;
}
.desktop-header__notif-dot {
.desktop-header__notif-badge {
position: absolute;
top: 8px;
inset-inline-end: 9px;
width: 8px;
height: 8px;
border-radius: 50%;
top: 4px;
inset-inline-end: 4px;
min-width: 18px;
height: 18px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background: var(--color-danger);
color: var(--color-on-primary, #fff);
font-size: 0.625rem;
font-weight: 700;
border: 2px solid var(--color-surface);
}
@@ -11,7 +11,8 @@ const checksTotal = computed(() =>
props.order.checks?.reduce((sum, check) => sum + check.amount, 0) ?? 0,
)
function settlementLabel(type: 'cash' | 'check') {
function settlementLabel(type: 'cash' | 'check' | null) {
if (!type) return 'در انتظار انتخاب مشتری'
return type === 'cash' ? 'تسویه نقدی' : 'تسویه چکی'
}
</script>
@@ -19,8 +20,10 @@ function settlementLabel(type: 'cash' | 'check') {
<template>
<div class="tracking-summary">
<div class="tracking-summary__row">
<span>مبلغ فاکتور (تقریبی)</span>
<strong>{{ order.finalAmount ? formatNumber(order.finalAmount) : '—' }}</strong>
<span>مبلغ فاکتور</span>
<strong>
{{ order.finalAmount ? formatNumber(order.finalAmount) : 'در انتظار قیمت‌گذاری' }}
</strong>
</div>
<div class="tracking-summary__row">
<span>نوع تسویه</span>
+27 -2
View File
@@ -1,12 +1,16 @@
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import { confirm } from '@/composables/useConfirm'
export function useCartPage() {
const router = useRouter()
const cartStore = useCartStore()
const orderStore = useOrderStore()
const showFullDialog = ref(false)
const submitting = ref(false)
const submitError = ref('')
const items = computed(() => cartStore.activeCart?.items ?? [])
const isEmpty = computed(() => items.value.length === 0)
@@ -27,18 +31,37 @@ export function useCartPage() {
cartStore.removeItem(itemId)
}
/**
* ثبت سفارش بدون انتخاب تسویه؛ قیمت‌گذاری و انتخاب روش تسویه
* بعد از بررسی مدیریت انجام می‌شود.
*/
async function submitOrder() {
if (!cartStore.activeCart || items.value.length === 0 || submitting.value) return
submitting.value = true
submitError.value = ''
try {
const order = await orderStore.submitOrder(cartStore.activeCart)
await cartStore.hydrate()
router.replace(`/orders/${order.id}/success`)
} catch (e) {
submitError.value = e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.'
} finally {
submitting.value = false
}
}
function proceedToCheckout() {
if (items.value.length === 0) return
if (cartStore.isFull) {
showFullDialog.value = true
return
}
router.push('/checkout/settlement')
void submitOrder()
}
function proceedAnyway() {
showFullDialog.value = false
router.push('/checkout/settlement')
void submitOrder()
}
async function createNewInvoice() {
@@ -62,6 +85,8 @@ export function useCartPage() {
isEmpty,
companyName,
showFullDialog,
submitting,
submitError,
removeItem,
proceedToCheckout,
proceedAnyway,
+31 -30
View File
@@ -46,36 +46,6 @@ const router = createRouter({
component: () => import('@/views/cart/CartView.vue'),
meta: { requiresAuth: true },
},
{
path: '/checkout/settlement',
name: 'settlement',
component: () => import('@/views/checkout/SettlementTypeView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/checkout/cash',
name: 'cash-settlement',
component: () => import('@/views/checkout/CashSettlementView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/checkout/check-mode',
name: 'check-mode',
component: () => import('@/views/checkout/CheckModeSelectView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/checkout/check-form',
name: 'check-form',
component: () => import('@/views/checkout/CheckFormView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/checkout/check-list',
name: 'check-list',
component: () => import('@/views/checkout/CheckListView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/orders',
name: 'orders',
@@ -94,6 +64,37 @@ const router = createRouter({
component: () => import('@/views/orders/OrderSuccessView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
// مراحل تسویه بعد از صدور فاکتور، روی همان سفارش
{
path: '/orders/:id/settlement',
name: 'settlement',
component: () => import('@/views/checkout/SettlementTypeView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/orders/:id/cash',
name: 'cash-settlement',
component: () => import('@/views/checkout/CashSettlementView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/orders/:id/check-mode',
name: 'check-mode',
component: () => import('@/views/checkout/CheckModeSelectView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/orders/:id/check-form',
name: 'check-form',
component: () => import('@/views/checkout/CheckFormView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/orders/:id/check-list',
name: 'check-list',
component: () => import('@/views/checkout/CheckListView.vue'),
meta: { requiresAuth: true, hideNav: true },
},
{
path: '/profile',
name: 'profile',
+3
View File
@@ -17,6 +17,7 @@ import {
import { useCartStore } from './cart'
import { useOrderStore } from './order'
import { useCatalogStore } from './catalog'
import { useNotificationStore } from './notification'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(getCurrentUser())
@@ -82,6 +83,7 @@ export const useAuthStore = defineStore('auth', () => {
useCatalogStore().loadCompanies(),
useCartStore().hydrate(),
useOrderStore().hydrate(),
useNotificationStore().hydrate(),
])
} else {
error.value = result.message
@@ -95,6 +97,7 @@ export const useAuthStore = defineStore('auth', () => {
clearPendingRegister()
useCartStore().resetLocal()
useOrderStore().resetLocal()
useNotificationStore().resetLocal()
}
async function updateProfile(data: Partial<Pick<User, 'firstName' | 'lastName' | 'nationalId'>>) {
+84
View File
@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { apiRequest, getAccessToken } from '@/services/http'
import type { AppNotification } from '@/types'
export const useNotificationStore = defineStore('notification', () => {
const items = ref<AppNotification[]>([])
const unreadCount = ref(0)
const loading = ref(false)
const hasUnread = computed(() => unreadCount.value > 0)
async function hydrate() {
if (!getAccessToken()) {
items.value = []
unreadCount.value = 0
return
}
loading.value = true
try {
items.value = await apiRequest<AppNotification[]>('/notifications')
unreadCount.value = items.value.filter((n) => !n.isRead).length
} catch {
items.value = []
unreadCount.value = 0
} finally {
loading.value = false
}
}
/** فقط شمارنده؛ برای زنگ هدر که نیازی به کل فهرست ندارد. */
async function refreshUnreadCount() {
if (!getAccessToken()) {
unreadCount.value = 0
return
}
try {
const res = await apiRequest<{ count: number }>('/notifications/unread-count')
unreadCount.value = res.count
} catch {
/* شمارنده حیاتی نیست */
}
}
async function markRead(id: string) {
const item = items.value.find((n) => n.id === id)
if (!item || item.isRead) return
item.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
try {
await apiRequest(`/notifications/${id}/read`, { method: 'PATCH' })
} catch {
await hydrate()
}
}
async function markAllRead() {
if (!unreadCount.value) return
items.value = items.value.map((n) => ({ ...n, isRead: true }))
unreadCount.value = 0
try {
await apiRequest('/notifications/read-all', { method: 'PATCH' })
} catch {
await hydrate()
}
}
function resetLocal() {
items.value = []
unreadCount.value = 0
}
return {
items,
unreadCount,
loading,
hasUnread,
hydrate,
refreshUnreadCount,
markRead,
markAllRead,
resetLocal,
}
})
+70 -49
View File
@@ -18,7 +18,7 @@ export const useOrderStore = defineStore('order', () => {
const draftChecks = ref<CheckData[]>([])
const draftSettlementType = ref<SettlementType | null>(null)
const draftCheckMode = ref<CheckRegistrationMode | null>(null)
const checkoutCartId = ref<string | null>(null)
const draftOrderId = ref<string | null>(null)
const loading = ref(false)
const userOrders = computed(
@@ -32,11 +32,27 @@ export const useOrderStore = defineStore('order', () => {
return orders.value.find((o) => o.id === id)
}
function startCheckout(cart: Cart, settlementType: SettlementType) {
checkoutCartId.value = cart.id
draftSettlementType.value = settlementType
/** شروع مرحله ثبت چک روی سفارشی که فاکتورش صادر شده است. */
function startCheckDraft(orderId: string, mode?: CheckRegistrationMode) {
draftOrderId.value = orderId
draftSettlementType.value = 'check'
draftChecks.value = []
draftCheckMode.value = mode ?? null
}
/** برای اصلاح چک، چک‌های ثبت‌شده سفارش به فرم برگردانده می‌شوند. */
function loadChecksIntoDraft(order: Order) {
draftOrderId.value = order.id
draftSettlementType.value = 'check'
draftCheckMode.value = order.checkMode ?? 'full'
draftChecks.value = (order.checks ?? []).map((c) => ({ ...c }))
}
function clearCheckDraft() {
draftOrderId.value = null
draftChecks.value = []
draftCheckMode.value = null
draftSettlementType.value = null
}
function setCheckMode(mode: CheckRegistrationMode) {
@@ -79,31 +95,30 @@ export const useOrderStore = defineStore('order', () => {
function resetLocal() {
orders.value = []
draftChecks.value = []
draftSettlementType.value = null
draftCheckMode.value = null
checkoutCartId.value = null
clearCheckDraft()
}
function upsertOrder(order: Order) {
const idx = orders.value.findIndex((o) => o.id === order.id)
if (idx >= 0) orders.value[idx] = order
else orders.value.unshift(order)
}
async function fetchOrder(id: string): Promise<Order | null> {
try {
const order = await apiRequest<Order>(`/orders/${id}`)
const idx = orders.value.findIndex((o) => o.id === id)
if (idx >= 0) orders.value[idx] = order
else orders.value.unshift(order)
upsertOrder(order)
return order
} catch {
return null
}
}
async function submitOrder(
_userId: string,
cart: Cart,
settlementType: SettlementType,
checks?: CheckData[],
checkMode?: CheckRegistrationMode,
): Promise<Order> {
/**
* سفارش بدون روش تسویه ثبت می‌شود؛ مشتری بعد از قیمت‌گذاری مدیریت
* روش تسویه را انتخاب می‌کند.
*/
async function submitOrder(cart: Cart): Promise<Order> {
const companyId = cart.companyId || cart.items[0]?.companyId || ''
if (!companyId) {
throw new Error('شرکت سبد مشخص نیست.')
@@ -120,32 +135,12 @@ export const useOrderStore = defineStore('order', () => {
const body = {
companyId,
settlementType,
checkMode: settlementType === 'check' ? (checkMode ?? 'full') : undefined,
notes: cart.notes,
items: itemsForOrder.map((item) => ({
productId: item.productId,
meterage: item.meterage,
notes: item.notes,
})),
checks:
settlementType === 'check' && checks
? checks.map((c) => ({
amount: c.amount,
dueDate: c.dueDate,
sayadId: c.sayadId,
checkNumber: c.checkNumber,
ownerNationalId: c.ownerNationalId,
ownerFirstName: c.ownerFirstName,
ownerLastName: c.ownerLastName,
bankName: c.bankName,
branchName: c.branchName,
frontImage: c.frontImage,
backImage: c.backImage,
notes: c.notes,
mode: c.mode,
}))
: undefined,
}
try {
@@ -154,16 +149,34 @@ export const useOrderStore = defineStore('order', () => {
body,
})
orders.value.unshift(order)
draftChecks.value = []
draftSettlementType.value = null
draftCheckMode.value = null
checkoutCartId.value = null
clearCheckDraft()
return order
} catch (e) {
throw new Error(e instanceof ApiError ? e.message : 'ثبت سفارش ناموفق بود.')
}
}
/** انتخاب روش تسویه پس از صدور فاکتور. */
async function chooseSettlement(
orderId: string,
settlementType: SettlementType,
checkMode?: CheckRegistrationMode,
): Promise<Order> {
try {
const order = await apiRequest<Order>(`/orders/${orderId}/settlement`, {
method: 'POST',
body: {
settlementType,
checkMode: settlementType === 'check' ? (checkMode ?? 'full') : undefined,
},
})
upsertOrder(order)
return order
} catch (e) {
throw new Error(e instanceof ApiError ? e.message : 'ثبت روش تسویه ناموفق بود.')
}
}
async function submitCashReceipt(
orderId: string,
receipt: Omit<CashPaymentReceipt, 'id' | 'submittedAt'>,
@@ -191,11 +204,16 @@ export const useOrderStore = defineStore('order', () => {
}
}
async function updateOrderChecks(orderId: string, checks: CheckData[]) {
async function updateOrderChecks(
orderId: string,
checks: CheckData[],
checkMode?: CheckRegistrationMode,
): Promise<Order> {
try {
const order = await apiRequest<Order>(`/orders/${orderId}/checks`, {
method: 'PATCH',
body: {
checkMode,
checks: checks.map((c) => ({
amount: c.amount,
dueDate: c.dueDate,
@@ -213,11 +231,11 @@ export const useOrderStore = defineStore('order', () => {
})),
},
})
const idx = orders.value.findIndex((o) => o.id === orderId)
if (idx >= 0) orders.value[idx] = order
upsertOrder(order)
clearCheckDraft()
return order
} catch {
return null
} catch (e) {
throw new Error(e instanceof ApiError ? e.message : 'ثبت چک‌ها ناموفق بود.')
}
}
@@ -230,17 +248,20 @@ export const useOrderStore = defineStore('order', () => {
draftChecks,
draftSettlementType,
draftCheckMode,
checkoutCartId,
draftOrderId,
loading,
userOrders,
getOrderById,
startCheckout,
startCheckDraft,
loadChecksIntoDraft,
clearCheckDraft,
setCheckMode,
addDraftCheck,
updateDraftCheck,
removeDraftCheck,
getDraftRass,
submitOrder,
chooseSettlement,
submitCashReceipt,
updateOrderChecks,
hydrate,
+22 -6
View File
@@ -98,7 +98,6 @@ export interface CartItem {
productCode: string
color: string
meterage: number
unitPrice?: number
notes?: string
companyId: string
companyName: string
@@ -175,17 +174,20 @@ export interface Order {
companyName: string
items: OrderItem[]
totalMeterage: number
settlementType: SettlementType
/** تا وقتی مشتری بعد از قیمت‌گذاری روش تسویه را انتخاب نکرده، خالی است. */
settlementType: SettlementType | null
checkMode?: CheckRegistrationMode
checks?: CheckData[]
checkRassDays?: number
checkRassDate?: string
status: OrderStatus
notes?: string
totalAmount?: number
shippingCost?: number
totalDiscount?: number
finalAmount?: number
totalAmount?: number | null
shippingCost?: number | null
totalDiscount?: number | null
finalAmount?: number | null
/** سرور تا پایان قیمت‌گذاری مبالغ را null برمی‌گرداند. */
pricesVisible?: boolean
cashInvoiceExpiresAt?: string
cashReceipt?: CashPaymentReceipt
statusHistory: OrderStatusHistoryEntry[]
@@ -193,6 +195,20 @@ export interface Order {
updatedAt: string
}
export type NotificationTone = 'info' | 'warning' | 'success' | 'danger'
export interface AppNotification {
id: string
orderId?: string
event: string
title: string
body: string
tone: NotificationTone
link?: string
isRead: boolean
sentAt: string
}
export interface CheckRassResult {
checkCount: number
totalAmount: number
+14 -1
View File
@@ -2,6 +2,7 @@
import { useRouter } from 'vue-router'
import { useCartPage } from '@/composables/useCartPage'
import BaseButton from '@/components/ui/BaseButton.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import MobileCartPage from '@/views/cart/components/MobileCartPage.vue'
import DesktopCartPage from '@/views/cart/components/DesktopCartPage.vue'
@@ -13,6 +14,8 @@ const {
isEmpty,
companyName,
showFullDialog,
submitting,
submitError,
removeItem,
proceedToCheckout,
proceedAnyway,
@@ -31,6 +34,8 @@ function goToOrdering() {
<template>
<div class="cart-view">
<FormErrorBanner v-if="submitError" :message="submitError" class="cart-view__error" />
<MobileCartPage
class="mobile-only"
:items="items"
@@ -39,6 +44,7 @@ function goToOrdering() {
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
:submitting="submitting"
@back="router.push('/')"
@remove="removeItem"
@checkout="proceedToCheckout"
@@ -53,6 +59,7 @@ function goToOrdering() {
:max-meterage="cartStore.maxMeterage"
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
:submitting="submitting"
@remove="removeItem"
@checkout="proceedToCheckout"
@select-company="router.push('/')"
@@ -66,7 +73,9 @@ function goToOrdering() {
سقف متراژ این فاکتور تکمیل شده است. آیا مایلید ادامه محصولات را در فاکتور جدید ثبت کنید؟
</p>
<div class="dialog-actions">
<BaseButton variant="outline" @click="proceedAnyway">تسویه همین فاکتور</BaseButton>
<BaseButton variant="outline" :loading="submitting" @click="proceedAnyway">
ثبت همین فاکتور
</BaseButton>
<BaseButton @click="createNewInvoice">فاکتور جدید</BaseButton>
</div>
</div>
@@ -83,6 +92,10 @@ function goToOrdering() {
overflow: hidden;
}
.cart-view__error {
margin: 0 var(--page-padding);
}
.dialog h3 {
margin: 0 0 12px;
}
+26 -15
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import CheckoutStepper from '@/components/layout/CheckoutStepper.vue'
import IconCart from '@/components/icons/IconCart.vue'
@@ -11,16 +10,17 @@ import IconClipboardEmpty from '@/components/icons/IconClipboardEmpty.vue'
import IconLightbulb from '@/components/icons/IconLightbulb.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import { formatMeterage, formatNumber, formatCurrency } from '@/utils/format'
import { formatMeterage, formatNumber } from '@/utils/format'
import type { CartItem } from '@/types'
const props = defineProps<{
defineProps<{
items: CartItem[]
companyName: string
totalMeterage: number
maxMeterage: number
remainingCapacity: number
isEmpty: boolean
submitting?: boolean
}>()
const emit = defineEmits<{
@@ -32,10 +32,6 @@ const emit = defineEmits<{
const router = useRouter()
const payableAmount = computed(() =>
props.items.reduce((sum, item) => sum + (item.unitPrice ?? 0) * item.meterage, 0),
)
const quickLinks = [
{
title: 'انتخاب شرکت',
@@ -160,25 +156,28 @@ const quickLinks = [
</span>
<strong>{{ formatMeterage(totalMeterage) }}</strong>
</div>
<div class="cart-summary-card__row">
<div class="cart-summary-card__row cart-summary-card__row--hint">
<span class="cart-summary-card__row-label">
<IconOrders :size="16" />
مبلغ قابل پرداخت
مبلغ فاکتور
</span>
<span class="cart-summary-card__hint-value">
پس از قیمت‌گذاری مدیریت
</span>
<strong>{{
payableAmount > 0 ? formatCurrency(payableAmount) : '۰ تومان'
}}</strong>
</div>
</div>
<button
type="button"
class="cart-summary-card__cta"
:disabled="isEmpty"
:disabled="isEmpty || submitting"
@click="emit('checkout')"
>
<IconChevronLeft :size="16" />
ادامه و انتخاب تسویه
{{ submitting ? 'در حال ثبت…' : 'ثبت سفارش' }}
</button>
<p class="cart-summary-card__note">
قیمت‌گذاری توسط مدیریت انجام می‌شود؛ پس از صدور فاکتور روش تسویه را انتخاب می‌کنید.
</p>
</div>
<div class="cart-guide-card">
@@ -475,7 +474,7 @@ const quickLinks = [
gap: 12px;
padding: 10px 0;
font-size: 0.8125rem;
border-bottom: 1px solid #f1f5f9;
border-bottom: 1px solid var(--color-border);
}
.cart-summary-card__row:last-child {
@@ -494,6 +493,18 @@ const quickLinks = [
color: var(--color-text);
}
.cart-summary-card__hint-value {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.cart-summary-card__note {
margin: 10px 0 0;
font-size: 0.75rem;
line-height: 1.6;
color: var(--color-text-muted);
}
.cart-summary-card__cta {
display: flex;
align-items: center;
+13 -1
View File
@@ -13,6 +13,7 @@ defineProps<{
maxMeterage: number
remainingCapacity: number
isEmpty: boolean
submitting?: boolean
}>()
const emit = defineEmits<{
@@ -78,7 +79,11 @@ const emit = defineEmits<{
<strong>{{ formatMeterage(remainingCapacity) }}</strong>
</div>
</div>
<BaseButton block @click="emit('checkout')">ادامه و انتخاب نوع تسویه</BaseButton>
<p class="cart-mobile-page__hint">
پس از ثبت، مدیریت سفارش را قیمت‌گذاری می‌کند و فاکتور برای انتخاب روش تسویه به شما اعلام
می‌شود.
</p>
<BaseButton block :loading="submitting" @click="emit('checkout')">ثبت سفارش</BaseButton>
</div>
</div>
@@ -108,6 +113,13 @@ const emit = defineEmits<{
margin-bottom: 12px;
}
.cart-mobile-page__hint {
margin: 0 0 10px;
font-size: 0.75rem;
line-height: 1.6;
color: var(--color-text-muted);
}
.cart-mobile-page__title {
margin: 0;
font-size: 1.0625rem;
+53 -79
View File
@@ -1,48 +1,54 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import { useAuthStore } from '@/stores/auth'
import { useCatalogStore } from '@/stores/catalog'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import IconSuccessCircle from '@/components/icons/IconSuccessCircle.vue'
import { toPersianDigits } from '@/utils/format'
import type { BankAccount, Order } from '@/types'
import { formatCurrency, formatDateTime } from '@/utils/format'
import type { BankAccount } from '@/types'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const orderStore = useOrderStore()
const authStore = useAuthStore()
const catalogStore = useCatalogStore()
const bankAccounts = ref<BankAccount[]>([])
const submitting = ref(false)
const formError = ref('')
const createdOrder = ref<Order | null>(null)
const companyId = computed(() => {
const firstItem = cartStore.activeCart?.items[0]
return firstItem?.companyId || cartStore.selectedCompanyId || cartStore.activeCart?.companyId || ''
})
const companyName = computed(
() => cartStore.companyNamesLabel || cartStore.activeCart?.companyName || '',
const orderId = computed(() => route.params.id as string)
const order = computed(() => orderStore.getOrderById(orderId.value))
const companyId = computed(() => order.value?.companyId ?? '')
const companyName = computed(() => order.value?.companyName ?? '')
watch(
orderId,
async (id) => {
if (id && !orderStore.getOrderById(id)) await orderStore.fetchOrder(id)
},
{ immediate: true },
)
onMounted(async () => {
if (!catalogStore.getCompany(companyId.value)) {
await catalogStore.loadCompanies()
}
if (companyId.value) {
bankAccounts.value = await catalogStore.loadBankAccounts(companyId.value)
}
})
watch(
companyId,
async (id) => {
if (!id) {
bankAccounts.value = []
return
}
if (!catalogStore.getCompany(id)) await catalogStore.loadCompanies()
bankAccounts.value = await catalogStore.loadBankAccounts(id)
},
{ immediate: true },
)
const invoiceLabel = computed(() =>
order.value?.finalAmount ? formatCurrency(order.value.finalAmount) : '—',
)
const expiryMessage = computed(() => {
const hour = catalogStore.getCompany(companyId.value)?.cashInvoiceValidUntilHour ?? 17
const time = toPersianDigits(String(hour).padStart(2, '0'))
return `پس از ثبت، فاکتور تا ساعت ${time}:۰۰ امروز معتبر است.`
const expiry = order.value?.cashInvoiceExpiresAt
if (!expiry) return 'مبلغ فاکتور را به یکی از حساب‌های زیر واریز و رسید را ثبت کنید.'
return `مهلت پرداخت این فاکتور تا ${formatDateTime(expiry)} است.`
})
const accountsSectionTitle = computed(() => {
@@ -61,34 +67,12 @@ function formatIban(value: string) {
return clean.replace(/(.{4})/g, '$1 ').trim()
}
async function createOrder(): Promise<Order | null> {
if (createdOrder.value) return createdOrder.value
if (!authStore.user || !cartStore.activeCart || submitting.value) return null
formError.value = ''
submitting.value = true
try {
const order = await orderStore.submitOrder(authStore.user.id, cartStore.activeCart, 'cash')
createdOrder.value = order
await cartStore.hydrate()
return order
} catch (e) {
formError.value = e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.'
return null
} finally {
submitting.value = false
}
function viewOrder() {
router.replace(`/orders/${orderId.value}?track=1`)
}
async function viewOrder() {
const order = await createOrder()
if (!order) return
router.replace(`/orders/${order.id}?track=1`)
}
async function registerReceipt() {
const order = await createOrder()
if (!order) return
router.replace(`/orders/${order.id}?track=1&receipt=1`)
function registerReceipt() {
router.replace(`/orders/${orderId.value}?track=1&receipt=1`)
}
</script>
@@ -99,17 +83,13 @@ async function registerReceipt() {
title="تسویه نقدی"
show-back
centered
@back="router.push('/checkout/settlement')"
@back="router.push(`/orders/${orderId}`)"
/>
<div class="cash-page__scroll page__scroll">
<FormErrorBanner :message="formError" />
<div class="cash-page__success" :class="{ 'cash-page__success--done': createdOrder }">
<IconSuccessCircle v-if="createdOrder" :size="56" />
<p class="cash-page__success-title">
{{ createdOrder ? 'فاکتور شما ثبت شد.' : 'آماده ثبت فاکتور نقدی' }}
</p>
<div class="cash-page__success cash-page__success--done">
<IconSuccessCircle :size="56" />
<p class="cash-page__success-title">مبلغ قابل پرداخت {{ invoiceLabel }}</p>
<p class="cash-page__success-subtitle">{{ expiryMessage }}</p>
</div>
@@ -138,11 +118,9 @@ async function registerReceipt() {
</div>
<div class="cash-page__footer">
<BaseButton variant="outline" block :loading="submitting" @click="viewOrder">
ثبت و مشاهده سفارش
</BaseButton>
<BaseButton block class="cash-page__submit" :loading="submitting" @click="registerReceipt">
ثبت سفارش و رسید پرداخت
<BaseButton variant="outline" block @click="viewOrder">مشاهده سفارش</BaseButton>
<BaseButton block class="cash-page__submit" @click="registerReceipt">
ثبت رسید پرداخت
</BaseButton>
</div>
</div>
@@ -154,15 +132,11 @@ async function registerReceipt() {
<p class="desktop-page__subtitle">{{ expiryMessage }}</p>
</header>
<div class="cash-page__success" :class="{ 'cash-page__success--done': createdOrder }">
<IconSuccessCircle v-if="createdOrder" :size="56" />
<p class="cash-page__success-title">
{{ createdOrder ? 'فاکتور شما ثبت شد.' : 'آماده ثبت فاکتور نقدی' }}
</p>
<div class="cash-page__success cash-page__success--done">
<IconSuccessCircle :size="56" />
<p class="cash-page__success-title">مبلغ قابل پرداخت {{ invoiceLabel }}</p>
</div>
<FormErrorBanner :message="formError" />
<section class="accounts-panel">
<h2 class="accounts-panel__title">{{ accountsSectionTitle }}</h2>
<div class="desktop-grid-2">
@@ -184,11 +158,11 @@ async function registerReceipt() {
</section>
<div class="cash-desktop-page__actions">
<BaseButton variant="outline" class="desktop-btn-compact" :loading="submitting" @click="viewOrder">
ثبت و مشاهده سفارش
<BaseButton variant="outline" class="desktop-btn-compact" @click="viewOrder">
مشاهده سفارش
</BaseButton>
<BaseButton class="desktop-btn-compact" :loading="submitting" @click="registerReceipt">
ثبت سفارش و رسید پرداخت
<BaseButton class="desktop-btn-compact" @click="registerReceipt">
ثبت رسید پرداخت
</BaseButton>
</div>
</div>
+5 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import PageHeader from '@/components/layout/PageHeader.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
@@ -22,9 +22,11 @@ import {
fileToBase64,
} from '@/utils/validation'
const route = useRoute()
const router = useRouter()
const orderStore = useOrderStore()
const orderId = computed(() => route.params.id as string)
const isFull = computed(() => orderStore.draftCheckMode === 'full')
const amount = ref('')
@@ -137,7 +139,7 @@ function saveCheck() {
})
resetForm()
router.push('/checkout/check-list')
router.push(`/orders/${orderId.value}/check-list`)
}
function resetForm() {
@@ -163,7 +165,7 @@ function resetForm() {
<PageHeader
:title="isFull ? 'ثبت چک - حالت کامل' : 'ثبت چک - حالت مختصر'"
show-back
@back="router.push('/checkout/check-mode')"
@back="router.push(`/orders/${orderId}/check-mode`)"
/>
<div class="page__scroll checkout-page__scroll">
+35 -33
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import { useAuthStore } from '@/stores/auth'
import BaseButton from '@/components/ui/BaseButton.vue'
@@ -15,43 +14,50 @@ import type { CheckData } from '@/types'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const orderStore = useOrderStore()
const authStore = useAuthStore()
const formError = ref('')
const submitting = ref(false)
const isCheckout = computed(() => route.name === 'check-list')
const orderId = computed(() => (route.params.id as string | undefined) ?? '')
const order = computed(() =>
orderId.value ? orderStore.getOrderById(orderId.value) : undefined,
)
watch(
orderId,
async (id) => {
if (!id || !isCheckout.value) return
const current = orderStore.getOrderById(id) ?? (await orderStore.fetchOrder(id))
if (!current) return
// در مسیر اصلاح، چک‌های ثبت‌شده به فرم برمی‌گردند تا مشتری ویرایش کند
if (orderStore.draftChecks.length === 0 && (current.checks?.length ?? 0) > 0) {
orderStore.loadChecksIntoDraft(current)
} else if (orderStore.draftOrderId !== id) {
orderStore.startCheckDraft(id, current.checkMode ?? undefined)
}
},
{ immediate: true },
)
const checks = computed<CheckData[]>(() => {
if (orderStore.draftChecks.length > 0) return orderStore.draftChecks
if (isCheckout.value) return order.value?.checks ?? []
if (!authStore.user) return []
return orderStore
.userOrders(authStore.user.id)
.filter((order) => order.settlementType === 'check' && order.checks?.length)
.flatMap((order) => order.checks ?? [])
.filter((o) => o.settlementType === 'check' && o.checks?.length)
.flatMap((o) => o.checks ?? [])
})
const rass = computed(() => {
if (orderStore.draftChecks.length > 0) return orderStore.getDraftRass()
if (checks.value.length === 0) {
return calculateCheckRass([], new Date())
}
return calculateCheckRass(checks.value, new Date())
})
const invoiceAmount = computed(() => {
if (!authStore.user) return 0
const activeOrder = orderStore
.userOrders(authStore.user.id)
.find((order) => order.settlementType === 'check' && order.finalAmount)
if (activeOrder?.finalAmount) return activeOrder.finalAmount
if (isCheckout.value && rass.value.totalAmount > 0) return rass.value.totalAmount
return rass.value.totalAmount
})
const invoiceAmount = computed(() => order.value?.finalAmount ?? 0)
const isAmountComplete = computed(
() => invoiceAmount.value > 0 && rass.value.totalAmount >= invoiceAmount.value,
@@ -65,11 +71,11 @@ const openMenuId = computed({
})
function goBack() {
router.push(isCheckout.value ? '/checkout/check-form' : '/profile')
router.push(isCheckout.value ? `/orders/${orderId.value}` : '/profile')
}
function addAnother() {
router.push('/checkout/check-form')
router.push(`/orders/${orderId.value}/check-form`)
}
async function removeCheck(id: string) {
@@ -89,24 +95,20 @@ function toggleMenu(id: string) {
openMenuId.value = openMenuId.value === id ? '' : id
}
async function submitOrder() {
if (!authStore.user || !cartStore.activeCart || checks.value.length === 0) return
if (submitting.value) return
async function submitChecks() {
if (!orderId.value || orderStore.draftChecks.length === 0 || submitting.value) return
formError.value = ''
submitting.value = true
try {
const order = await orderStore.submitOrder(
authStore.user.id,
cartStore.activeCart,
'check',
await orderStore.updateOrderChecks(
orderId.value,
[...orderStore.draftChecks],
orderStore.draftCheckMode ?? 'brief',
)
await cartStore.hydrate()
router.replace(`/orders/${order.id}/success`)
router.replace(`/orders/${orderId.value}?track=1`)
} catch (e) {
formError.value = e instanceof Error ? e.message : 'ثبت سفارش ناموفق بود.'
formError.value = e instanceof Error ? e.message : 'ثبت چک‌ها ناموفق بود.'
} finally {
submitting.value = false
}
@@ -203,7 +205,7 @@ async function submitOrder() {
block
:disabled="checks.length === 0"
:loading="submitting"
@click="submitOrder"
@click="submitChecks"
>
ارسال برای بررسی
</BaseButton>
@@ -283,7 +285,7 @@ async function submitOrder() {
block
:disabled="checks.length === 0"
:loading="submitting"
@click="submitOrder"
@click="submitChecks"
>
ارسال برای بررسی
</BaseButton>
+17 -3
View File
@@ -1,16 +1,30 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import PageHeader from '@/components/layout/PageHeader.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import IconEditPen from '@/components/icons/IconEditPen.vue'
const route = useRoute()
const router = useRouter()
const orderStore = useOrderStore()
const orderId = computed(() => route.params.id as string)
watch(
orderId,
async (id) => {
if (!id) return
if (!orderStore.getOrderById(id)) await orderStore.fetchOrder(id)
if (orderStore.draftOrderId !== id) orderStore.startCheckDraft(id)
},
{ immediate: true },
)
function selectMode(mode: 'full' | 'brief') {
orderStore.setCheckMode(mode)
router.push('/checkout/check-form')
router.push(`/orders/${orderId.value}/check-form`)
}
</script>
@@ -21,7 +35,7 @@ function selectMode(mode: 'full' | 'brief') {
subtitle="نحوه ثبت اطلاعات چک‌ها را انتخاب کنید"
show-back
centered
@back="router.push('/checkout/settlement')"
@back="router.push(`/orders/${orderId}`)"
/>
<div class="page__scroll check-mode-page__scroll">
+111 -16
View File
@@ -1,23 +1,52 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrderStore } from '@/stores/order'
import PageHeader from '@/components/layout/PageHeader.vue'
import CheckoutStepper from '@/components/layout/CheckoutStepper.vue'
import FormErrorBanner from '@/components/ui/FormErrorBanner.vue'
import IconCashSettlement from '@/components/icons/IconCashSettlement.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
import { formatCurrency } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const cartStore = useCartStore()
const orderStore = useOrderStore()
function selectType(type: 'cash' | 'check') {
if (!cartStore.activeCart) {
router.push('/cart')
return
const orderId = computed(() => route.params.id as string)
const order = computed(() => orderStore.getOrderById(orderId.value))
const submitting = ref(false)
const formError = ref('')
watch(
orderId,
async (id) => {
if (id && !orderStore.getOrderById(id)) await orderStore.fetchOrder(id)
},
{ immediate: true },
)
const invoiceLabel = computed(() =>
order.value?.finalAmount ? formatCurrency(order.value.finalAmount) : '—',
)
async function selectType(type: 'cash' | 'check') {
if (submitting.value) return
submitting.value = true
formError.value = ''
try {
await orderStore.chooseSettlement(orderId.value, type)
if (type === 'cash') {
router.replace(`/orders/${orderId.value}/cash`)
} else {
orderStore.startCheckDraft(orderId.value)
router.replace(`/orders/${orderId.value}/check-mode`)
}
} catch (e) {
formError.value = e instanceof Error ? e.message : 'ثبت روش تسویه ناموفق بود.'
} finally {
submitting.value = false
}
orderStore.startCheckout(cartStore.activeCart, type)
router.push(type === 'cash' ? '/checkout/cash' : '/checkout/check-mode')
}
</script>
@@ -29,18 +58,36 @@ function selectType(type: 'cash' | 'check') {
subtitle="نوع تسویه را انتخاب کنید"
show-back
centered
@back="router.push('/cart')"
@back="router.push(`/orders/${orderId}`)"
/>
<div class="page__scroll">
<FormErrorBanner :message="formError" />
<div class="settlement-invoice">
<span class="settlement-invoice__label">مبلغ فاکتور</span>
<strong class="settlement-invoice__value">{{ invoiceLabel }}</strong>
<span v-if="order" class="settlement-invoice__meta">سفارش {{ order.orderNumber }}</span>
</div>
<div class="settlement-mobile-options">
<button type="button" class="settlement-card" @click="selectType('cash')">
<button
type="button"
class="settlement-card"
:disabled="submitting"
@click="selectType('cash')"
>
<span class="settlement-card__icon settlement-card__icon--cash">
<IconCashSettlement :size="36" />
</span>
<h3>تسویه نقدی</h3>
<p>پرداخت از طریق واریز به حساب شرکت</p>
</button>
<button type="button" class="settlement-card" @click="selectType('check')">
<button
type="button"
class="settlement-card"
:disabled="submitting"
@click="selectType('check')"
>
<span class="settlement-card__icon settlement-card__icon--check">
<IconCheckBook :size="36" />
</span>
@@ -53,20 +100,35 @@ function selectType(type: 'cash' | 'check') {
<!-- Desktop -->
<div class="desktop-checkout-shell desktop-only">
<CheckoutStepper :current-step="2" />
<CheckoutStepper :current-step="4" />
<header class="desktop-page__head">
<h1 class="desktop-page__title">انتخاب نوع تسویه</h1>
<p class="desktop-page__subtitle">نوع تسویه را انتخاب کنید</p>
<p class="desktop-page__subtitle">
مبلغ فاکتور {{ invoiceLabel }}{{ order ? ` · سفارش ${order.orderNumber}` : '' }}
</p>
</header>
<FormErrorBanner :message="formError" />
<div class="desktop-settlement-grid">
<button type="button" class="settlement-card settlement-card--desktop" @click="selectType('cash')">
<button
type="button"
class="settlement-card settlement-card--desktop"
:disabled="submitting"
@click="selectType('cash')"
>
<span class="settlement-card__icon settlement-card__icon--cash">
<IconCashSettlement :size="40" />
</span>
<h3>تسویه نقدی</h3>
<p>پرداخت از طریق واریز به حساب شرکت</p>
</button>
<button type="button" class="settlement-card settlement-card--desktop" @click="selectType('check')">
<button
type="button"
class="settlement-card settlement-card--desktop"
:disabled="submitting"
@click="selectType('check')"
>
<span class="settlement-card__icon settlement-card__icon--check">
<IconCheckBook :size="40" />
</span>
@@ -105,6 +167,39 @@ function selectType(type: 'cash' | 'check') {
color: var(--color-text);
}
.settlement-card:disabled {
opacity: 0.6;
cursor: progress;
}
.settlement-invoice {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 18px;
margin-bottom: 8px;
border-radius: 16px;
background: var(--color-primary-light);
border: 1px solid var(--color-primary);
}
.settlement-invoice__label {
font-size: 0.8125rem;
color: var(--color-primary);
}
.settlement-invoice__value {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-primary);
}
.settlement-invoice__meta {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.settlement-card--desktop {
min-height: 220px;
border: 1px solid var(--color-border);
+77 -4
View File
@@ -111,10 +111,46 @@ function formatOrderDateTime(date: string) {
return `${timePart} - ${datePart}`
}
function settlementLabel(type: 'cash' | 'check') {
function settlementLabel(type: 'cash' | 'check' | null) {
if (!type) return 'در انتظار انتخاب مشتری'
return type === 'cash' ? 'تسویه نقدی' : 'تسویه چکی'
}
type NextAction = { label: string; to?: string; receipt?: boolean }
/** اقدام بعدی که در توپ زمین مشتری است. */
const nextAction = computed<NextAction | null>(() => {
const current = order.value
if (!current) return null
switch (current.status) {
case 'awaiting_settlement':
case 'awaiting_settlement_fix':
return { label: 'انتخاب روش تسویه', to: `/orders/${current.id}/settlement` }
case 'check_in_progress':
return { label: 'ثبت چک‌ها', to: `/orders/${current.id}/check-mode` }
case 'check_needs_fix':
case 'check_rejected':
return { label: 'اصلاح چک‌ها', to: `/orders/${current.id}/check-list` }
case 'awaiting_cash_payment':
case 'cash_invoice_issued':
return { label: 'ثبت رسید پرداخت', receipt: true }
default:
return null
}
})
function runNextAction() {
const action = nextAction.value
if (!action) return
if (action.receipt) {
showTracking.value = true
showOrderDetails.value = true
showReceiptForm.value = true
return
}
if (action.to) router.push(action.to)
}
function openTracking() {
showTracking.value = true
showOrderDetails.value = false
@@ -236,11 +272,18 @@ async function submitReceipt() {
<div class="order-info-card__row order-info-card__row--last">
<span class="order-info-card__key">مبلغ فاکتور</span>
<strong class="order-info-card__amount">
{{ order.finalAmount ? formatNumber(order.finalAmount) : '—' }}
{{ order.finalAmount ? formatNumber(order.finalAmount) : 'پس از قیمت‌گذاری مدیریت' }}
</strong>
</div>
</div>
<div v-if="nextAction" class="order-next-action">
<span class="order-next-action__text">برای ادامه سفارش، این مرحله باقی مانده است.</span>
<BaseButton class="order-next-action__btn" @click="runNextAction">
{{ nextAction.label }}
</BaseButton>
</div>
<div v-if="showTracking" class="order-detail-page__tracking">
<button type="button" class="order-detail-back desktop-only" @click="router.push('/orders')">
← بازگشت به سفارش‌ها
@@ -286,11 +329,11 @@ async function submitReceipt() {
</div>
<BaseButton
v-if="!order.cashReceipt && order.status !== 'expired'"
v-if="nextAction?.receipt"
block
@click="showReceiptForm = true"
>
ثبت رسید پرداخت
{{ order.cashReceipt ? 'ثبت رسید جدید' : 'ثبت رسید پرداخت' }}
</BaseButton>
<div v-if="order.cashReceipt" class="tracking-card">
@@ -550,6 +593,36 @@ async function submitReceipt() {
text-align: left;
}
.order-next-action {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
padding: 16px;
border-radius: 16px;
background: var(--color-primary-light);
border: 1px solid var(--color-primary);
}
.order-next-action__text {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--color-primary);
}
@media (min-width: 768px) {
.order-next-action {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.order-next-action__btn {
width: auto;
flex-shrink: 0;
}
}
.order-detail-page__footer {
flex-shrink: 0;
padding: 12px 0;
+4 -4
View File
@@ -18,8 +18,8 @@ const order = computed(() => orderStore.getOrderById(route.params.id as string))
<div class="success-icon">✓</div>
<h1 class="page-title text-center">سفارش ثبت شد</h1>
<p class="text-center text-muted mb-4">
سفارش شما با موفقیت ثبت شد. اطلاعات محصولات و شرایط تسویه توسط واحد فروش بررسی شده و نتیجه از
طریق پیامک و پنل کاربری اعلام می‌شود.
سفارش شما با موفقیت ثبت شد. کارشناسان ما سفارش را بررسی و قیمت‌گذاری می‌کنند و به‌محض صدور
فاکتور، از طریق پیامک و پنل کاربری به شما اطلاع می‌دهیم تا روش تسویه را انتخاب کنید.
</p>
<div v-if="order" class="card mb-4">
@@ -36,8 +36,8 @@ const order = computed(() => orderStore.getOrderById(route.params.id as string))
<span>{{ formatMeterage(order.totalMeterage) }}</span>
</div>
<div class="info-row">
<span>نوع تسویه:</span>
<span>{{ order.settlementType === 'cash' ? 'نقدی' : 'چکی' }}</span>
<span>وضعیت:</span>
<span>در انتظار قیمت‌گذاری</span>
</div>
</div>
+144 -72
View File
@@ -1,118 +1,190 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import BaseButton from '@/components/ui/BaseButton.vue'
import BasicIcon from '@/components/BasicPacks/BasicIcon.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import ProfileSubpageLayout from '@/views/profile/components/ProfileSubpageLayout.vue'
import { useNotificationStore } from '@/stores/notification'
import { formatDateTime } from '@/utils/format'
import type { AppNotification, NotificationTone } from '@/types'
const emailOrders = ref(true)
const emailChecks = ref(true)
const smsOrders = ref(true)
const smsMarketing = ref(false)
const saved = ref(false)
const router = useRouter()
const notificationStore = useNotificationStore()
function save() {
saved.value = true
setTimeout(() => {
saved.value = false
}, 2000)
const items = computed(() => notificationStore.items)
const hasUnread = computed(() => notificationStore.hasUnread)
const toneIcons: Record<NotificationTone, string> = {
info: 'vuesax-linear:information',
success: 'vuesax-linear:tick-circle',
warning: 'vuesax-linear:timer',
danger: 'vuesax-linear:close-circle',
}
onMounted(() => {
void notificationStore.hydrate()
})
async function open(item: AppNotification) {
await notificationStore.markRead(item.id)
if (item.link) router.push(item.link)
}
</script>
<template>
<ProfileSubpageLayout title="اعلان‌ها" subtitle="مدیریت اعلان‌های ایمیل و پیامک">
<div class="notify-list">
<label class="notify-row">
<span class="notify-row__text">
<strong>ایمیل وضعیت سفارش</strong>
<span>اطلاع از تأیید، ارسال و تکمیل سفارش</span>
<ProfileSubpageLayout title="اعلان‌ها" subtitle="آخرین خبرهای سفارش‌های شما">
<div v-if="items.length" class="notif-box">
<div class="notif-box__head">
<span class="notif-box__count">
{{ hasUnread ? `${notificationStore.unreadCount} اعلان خوانده‌نشده` : 'همه خوانده شده' }}
</span>
<input v-model="emailOrders" type="checkbox" class="notify-row__switch" />
</label>
<BaseButton
v-if="hasUnread"
variant="outline"
class="notif-box__mark"
@click="notificationStore.markAllRead()"
>
خواندن همه
</BaseButton>
</div>
<label class="notify-row">
<span class="notify-row__text">
<strong>ایمیل سررسید چک</strong>
<span>یادآوری قبل از موعد چک‌های ثبت‌شده</span>
<button
v-for="item in items"
:key="item.id"
type="button"
class="notif-row"
:class="[`notif-row--${item.tone}`, { 'notif-row--unread': !item.isRead }]"
@click="open(item)"
>
<span class="notif-row__icon" aria-hidden="true">
<BasicIcon :icon="toneIcons[item.tone]" :size="22" :stroke-width="1.5" />
</span>
<input v-model="emailChecks" type="checkbox" class="notify-row__switch" />
</label>
<label class="notify-row">
<span class="notify-row__text">
<strong>پیامک وضعیت سفارش</strong>
<span>ارسال پیامک برای تغییر وضعیت سفارش</span>
<span class="notif-row__body">
<strong class="notif-row__title">{{ item.title }}</strong>
<span class="notif-row__text">{{ item.body }}</span>
<span class="notif-row__time">{{ formatDateTime(item.sentAt) }}</span>
</span>
<input v-model="smsOrders" type="checkbox" class="notify-row__switch" />
</label>
<label class="notify-row">
<span class="notify-row__text">
<strong>پیامک‌های تبلیغاتی</strong>
<span>تخفیف‌ها و پیشنهادهای ویژه</span>
</span>
<input v-model="smsMarketing" type="checkbox" class="notify-row__switch" />
</label>
<span v-if="!item.isRead" class="notif-row__dot" aria-label="خوانده‌نشده" />
</button>
</div>
<BaseButton block class="notify-save" @click="save">ذخیره تنظیمات</BaseButton>
<p v-if="saved" class="notify-saved">تنظیمات اعلان ذخیره شد ✓</p>
<EmptyState
v-else
icon="vuesax-linear:notification-bing"
title="اعلانی ندارید"
message="وقتی فاکتور سفارشتان صادر شود یا وضعیت چک و پرداخت تغییر کند، همین‌جا خبردار می‌شوید."
>
<BaseButton variant="outline" @click="router.push('/orders')">سفارش‌های من</BaseButton>
</EmptyState>
</ProfileSubpageLayout>
</template>
<style scoped>
.notify-list {
.notif-box {
display: flex;
flex-direction: column;
gap: 4px;
gap: 10px;
}
.notify-row {
.notif-box__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid var(--color-border);
gap: 12px;
padding-bottom: 4px;
}
.notif-box__count {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.notif-box__mark {
width: auto;
padding-inline: 14px;
}
.notif-row {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 14px;
text-align: right;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
font-family: inherit;
transition: border-color 0.15s;
}
.notify-row:last-of-type {
border-bottom: none;
.notif-row:hover {
border-color: var(--color-primary);
}
.notify-row__text {
.notif-row--unread {
background: var(--color-elevated);
}
.notif-row__icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-info-light);
color: var(--color-primary);
}
.notif-row--success .notif-row__icon {
background: var(--color-success-light);
color: var(--color-success);
}
.notif-row--warning .notif-row__icon {
background: var(--color-warning-light);
color: var(--color-warning);
}
.notif-row--danger .notif-row__icon {
background: var(--color-danger-light);
color: var(--color-danger);
}
.notif-row__body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.notify-row__text strong {
.notif-row__title {
font-size: 0.875rem;
font-weight: 600;
font-weight: 700;
}
.notify-row__text span {
font-size: 0.75rem;
.notif-row__text {
font-size: 0.8125rem;
line-height: 1.6;
color: var(--color-text-muted);
line-height: 1.5;
}
.notify-row__switch {
width: 18px;
height: 18px;
.notif-row__time {
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.notif-row__dot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 50%;
background: var(--color-danger);
flex-shrink: 0;
accent-color: var(--color-primary);
cursor: pointer;
}
.notify-save {
margin-top: 16px;
}
.notify-saved {
margin: 8px 0 0;
font-size: 0.875rem;
text-align: center;
color: var(--color-success);
}
</style>
@@ -111,7 +111,7 @@ const settingsItems: Array<{
{
id: 'notifications',
label: 'اعلان‌ها',
description: 'مدیریت اعلان‌های ایمیل و پیامک',
description: 'خبرهای فاکتور، چک و ارسال سفارش',
icon: IconBell,
path: '/profile/notifications',
},
@@ -3,6 +3,8 @@ import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useOrderStore } from '@/stores/order'
import { useNotificationStore } from '@/stores/notification'
import IconBell from '@/components/icons/IconBell.vue'
import IconChevronLeft from '@/components/icons/IconChevronLeft.vue'
import IconProfile from '@/components/icons/IconProfile.vue'
import IconCheckBook from '@/components/icons/IconCheckBook.vue'
@@ -12,10 +14,19 @@ import IconSettings from '@/components/icons/IconSettings.vue'
const router = useRouter()
const auth = useAuthStore()
const orderStore = useOrderStore()
const notificationStore = useNotificationStore()
const draftCheckCount = computed(() => orderStore.draftChecks.length)
const menuItems = computed(() => [
{
id: 'notifications',
label: 'اعلان‌ها',
description: 'خبرهای فاکتور، چک و ارسال',
path: '/profile/notifications',
icon: IconBell,
badge: notificationStore.unreadCount,
},
{
id: 'edit',
label: 'اطلاعات کاربری',