feat: require payment method when submitting a preorder

Price depends on cash or check terms, so the customer chooses them before admin pricing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-15 11:24:52 +03:30
co-authored by Cursor
parent c225a58099
commit 881e6a2c87
10 changed files with 284 additions and 20 deletions
@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { SettlementType } from '@/types'
defineProps<{
settlementType: SettlementType | null
checkTermMonths: number | null
}>()
const emit = defineEmits<{
'update:settlementType': [value: SettlementType]
'update:checkTermMonths': [value: number]
}>()
const methods: { value: SettlementType; title: string; hint: string }[] = [
{ value: 'cash', title: 'نقدی', hint: 'کل مبلغ به‌صورت نقدی' },
{ value: 'cash_check', title: 'نصف نقد، نصف چکی', hint: 'نیمی نقد و نیمی چک' },
{ value: 'check', title: 'چکی', hint: 'کل مبلغ با چک' },
]
const terms = [1, 2, 3, 4]
</script>
<template>
<fieldset class="pay-plan">
<legend class="pay-plan__legend">روش پرداخت</legend>
<p class="pay-plan__note">قیمت پیش‌سفارش به روش پرداخت بستگی دارد. قبل از اعلام قیمت باید مشخص شود.</p>
<div class="pay-plan__methods">
<button
v-for="method in methods"
:key="method.value"
type="button"
class="pay-plan__method"
:class="{ 'pay-plan__method--active': settlementType === method.value }"
@click="emit('update:settlementType', method.value)"
>
<strong>{{ method.title }}</strong>
<span>{{ method.hint }}</span>
</button>
</div>
<div v-if="settlementType === 'check' || settlementType === 'cash_check'" class="pay-plan__terms">
<span class="pay-plan__terms-label">مدت چک</span>
<div class="pay-plan__terms-row">
<button
v-for="month in terms"
:key="month"
type="button"
class="pay-plan__term"
:class="{ 'pay-plan__term--active': checkTermMonths === month }"
@click="emit('update:checkTermMonths', month)"
>
{{ month }} ماهه
</button>
</div>
</div>
</fieldset>
</template>
<style scoped>
.pay-plan {
border: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.pay-plan__legend {
font-weight: 700;
font-size: 14px;
}
.pay-plan__note {
margin: 0;
color: var(--color-text-secondary, #667085);
font-size: 12px;
line-height: 1.6;
}
.pay-plan__methods,
.pay-plan__terms-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.pay-plan__method,
.pay-plan__term {
text-align: right;
border: 1px solid var(--color-border, #e4e7ec);
background: #fff;
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
}
.pay-plan__method strong {
display: block;
}
.pay-plan__method span {
color: var(--color-text-secondary, #667085);
font-size: 12px;
}
.pay-plan__method--active,
.pay-plan__term--active {
border-color: var(--color-primary, #1570ef);
background: color-mix(in srgb, var(--color-primary, #1570ef) 8%, white);
}
.pay-plan__terms-label {
font-size: 13px;
font-weight: 600;
}
.pay-plan__terms-row {
flex-direction: row;
flex-wrap: wrap;
}
.pay-plan__term {
flex: 1;
text-align: center;
min-width: 72px;
}
</style>
+22 -3
View File
@@ -3,6 +3,7 @@ import { useRouter } from 'vue-router'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import { confirm } from '@/composables/useConfirm'
import type { SettlementType } from '@/types'
export function useCartPage() {
const router = useRouter()
@@ -11,6 +12,8 @@ export function useCartPage() {
const showFullDialog = ref(false)
const submitting = ref(false)
const submitError = ref('')
const settlementType = ref<SettlementType | null>(null)
const checkTermMonths = ref<number | null>(null)
const items = computed(() => cartStore.activeCart?.items ?? [])
const isEmpty = computed(() => items.value.length === 0)
@@ -33,11 +36,22 @@ export function useCartPage() {
}
/**
* ثبت سفارش بدون آدرس و بدون انتخاب تسویه.
* سفارش هنوز نهایی نیست؛ قیمت‌گذاری و روش تسویه بعد از بررسی مدیریت است.
* پیش‌سفارش با روش پرداخت (و مدت چک در صورت نیاز) ثبت می‌شود.
* قیمت‌گذاری مدیریت بعد از این انتخاب انجام می‌شود.
*/
async function submitOrder() {
if (!cartStore.activeCart || items.value.length === 0 || submitting.value) return
if (!settlementType.value) {
submitError.value = 'قبل از ثبت پیش‌سفارش روش پرداخت را انتخاب کنید.'
return
}
if (
(settlementType.value === 'check' || settlementType.value === 'cash_check') &&
!checkTermMonths.value
) {
submitError.value = 'مدت چک را از ۱ تا ۴ ماه انتخاب کنید.'
return
}
submitting.value = true
submitError.value = ''
try {
@@ -46,7 +60,10 @@ export function useCartPage() {
submitError.value = 'سبد خرید خالی است یا با سرور همگام نشد.'
return
}
const order = await orderStore.submitOrder(cartStore.activeCart)
const order = await orderStore.submitOrder(cartStore.activeCart, {
settlementType: settlementType.value,
checkTermMonths: checkTermMonths.value,
})
await cartStore.hydrate()
router.replace(`/orders/${order.id}/success`)
} catch (e) {
@@ -90,6 +107,8 @@ export function useCartPage() {
showFullDialog,
submitting,
submitError,
settlementType,
checkTermMonths,
removeItem,
proceedToCheckout,
proceedAnyway,
+31 -4
View File
@@ -117,11 +117,13 @@ export const useOrderStore = defineStore('order', () => {
}
/**
* سفارش بدون روش تسویه ثبت می‌شود؛ مشتری بعد از قیمت‌گذاری مدیریت
* روش تسویه را انتخاب می‌کند.
* پیش‌سفارش همراه روش پرداخت ثبت می‌شود تا قیمت‌گذاری بر همان اساس انجام شود.
* اقلام همه شرکت‌ها در یک فاکتور واحد ثبت می‌شوند.
*/
async function submitOrder(cart: Cart): Promise<Order> {
async function submitOrder(
cart: Cart,
payment: { settlementType: SettlementType; checkTermMonths?: number | null },
): Promise<Order> {
if (!cart.items.length) {
throw new Error('سبد خرید خالی است.')
}
@@ -143,6 +145,9 @@ export const useOrderStore = defineStore('order', () => {
method: 'POST',
body: {
companyId: companyIds[0],
settlementType: payment.settlementType,
checkTermMonths:
payment.settlementType === 'cash' ? undefined : payment.checkTermMonths ?? undefined,
notes: notes || undefined,
items: cart.items.map((item) => ({
productId: item.productId,
@@ -159,7 +164,28 @@ export const useOrderStore = defineStore('order', () => {
}
}
/** انتخاب روش تسویه پس از صدور فاکتور. */
async function setPaymentPlan(
orderId: string,
settlementType: SettlementType,
checkTermMonths?: number | null,
): Promise<Order> {
try {
const order = await apiRequest<Order>(`/orders/${orderId}/payment-plan`, {
method: 'POST',
body: {
settlementType,
checkTermMonths:
settlementType === 'cash' ? undefined : checkTermMonths ?? undefined,
},
})
upsertOrder(order)
return order
} catch (e) {
throw new Error(e instanceof ApiError ? e.message : 'ثبت روش پرداخت ناموفق بود.')
}
}
/** انتخاب روش تسویه پس از صدور فاکتور؛ فقط برای سفارش‌هایی که روش پرداخت ندارند. */
async function chooseSettlement(
orderId: string,
settlementType: SettlementType,
@@ -273,6 +299,7 @@ export const useOrderStore = defineStore('order', () => {
removeDraftCheck,
getDraftRass,
submitOrder,
setPaymentPlan,
chooseSettlement,
submitCashReceipt,
updateOrderChecks,
+3 -1
View File
@@ -187,8 +187,10 @@ export interface Order {
companyName: string
items: OrderItem[]
totalMeterage: number
/** تا وقتی مشتری بعد از قیمت‌گذاری روش تسویه را انتخاب نکرده، خالی است. */
/** هنگام ثبت پیش‌سفارش انتخاب می‌شود. سفارش‌های قدیمی یا ثبت‌شده توسط ادمین ممکن است خالی باشند. */
settlementType: SettlementType | null
/** مدت چک به ماه (۱ تا ۴) برای روش‌های چکی */
checkTermMonths?: number | null
checkMode?: CheckRegistrationMode
checks?: CheckData[]
checkRassDays?: number
+2 -2
View File
@@ -48,13 +48,13 @@ const STATUS_HERO: Partial<Record<OrderStatus, StatusHeroConfig>> = {
variant: 'waiting',
title: 'پیش‌سفارش ثبت شد',
description:
'سفارش شما برای بررسی و قیمت‌گذاری به مدیریت ارسال شد. پس از صدور فاکتور می‌توانید روش تسویه (نقد یا چک) را انتخاب کنید.',
'سفارش شما برای بررسی و قیمت‌گذاری به مدیریت ارسال شد. روش پرداخت هنگام ثبت پیش‌سفارش مشخص شده است.',
},
awaiting_review: {
variant: 'waiting',
title: 'در انتظار بررسی مدیریت',
description:
'پیش‌سفارش شما در صف بررسی است. پس از قیمت‌گذاری و صدور فاکتور، از طریق همین صفحه برای انتخاب تسویه مطلع می‌شوید.',
'پیش‌سفارش شما در صف بررسی است. قیمت بر اساس روش پرداختی که انتخاب کرده‌اید اعلام می‌شود.',
},
pricing: {
variant: 'waiting',
+4 -4
View File
@@ -1,15 +1,15 @@
import type { OrderStatus, SettlementType } from '@/types'
export const SETTLEMENT_TYPE_LABELS: Record<SettlementType, string> = {
cash: 'تسویه نقدی',
check: 'تسویه چکی',
cash_check: 'تسویه نقدی-چکی',
cash: 'نقدی',
check: 'چکی',
cash_check: 'نصف نقد، نصف چکی',
}
export function settlementTypeLabel(
type: SettlementType | null | undefined,
): string {
if (!type) return 'در انتظار انتخاب مشتری'
if (!type) return 'هنوز انتخاب نشده'
return SETTLEMENT_TYPE_LABELS[type] ?? type
}
+10
View File
@@ -18,6 +18,8 @@ const {
showFullDialog,
submitting,
submitError,
settlementType,
checkTermMonths,
removeItem,
proceedToCheckout,
proceedAnyway,
@@ -43,6 +45,10 @@ function goToOrdering() {
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
:submitting="submitting"
:settlement-type="settlementType"
:check-term-months="checkTermMonths"
@update:settlement-type="settlementType = $event"
@update:check-term-months="checkTermMonths = $event"
@back="router.push('/')"
@remove="removeItem"
@checkout="proceedToCheckout"
@@ -59,6 +65,10 @@ function goToOrdering() {
:remaining-capacity="cartStore.remainingCapacity"
:is-empty="isEmpty"
:submitting="submitting"
:settlement-type="settlementType"
:check-term-months="checkTermMonths"
@update:settlement-type="settlementType = $event"
@update:check-term-months="checkTermMonths = $event"
@remove="removeItem"
@checkout="proceedToCheckout"
@select-company="goToOrdering()"
+13 -2
View File
@@ -10,7 +10,8 @@ import IconLightbulb from '@/components/icons/IconLightbulb.vue'
import IconLayoutGrid from '@/components/icons/IconLayoutGrid.vue'
import IconDiamond from '@/components/icons/IconDiamond.vue'
import { formatMeterage, formatNumber } from '@/utils/format'
import type { CartItem } from '@/types'
import type { CartItem, SettlementType } from '@/types'
import PreorderPaymentFields from '@/components/orders/PreorderPaymentFields.vue'
defineProps<{
items: CartItem[]
@@ -27,6 +28,8 @@ defineProps<{
remainingCapacity: number
isEmpty: boolean
submitting?: boolean
settlementType: SettlementType | null
checkTermMonths: number | null
}>()
const emit = defineEmits<{
@@ -34,6 +37,8 @@ const emit = defineEmits<{
checkout: []
selectCompany: []
back: []
'update:settlementType': [value: SettlementType]
'update:checkTermMonths': [value: number]
}>()
const router = useRouter()
@@ -180,6 +185,12 @@ const quickLinks = [
</span>
</div>
</div>
<PreorderPaymentFields
:settlement-type="settlementType"
:check-term-months="checkTermMonths"
@update:settlement-type="emit('update:settlementType', $event)"
@update:check-term-months="emit('update:checkTermMonths', $event)"
/>
<button
type="button"
class="cart-summary-card__cta"
@@ -190,7 +201,7 @@ const quickLinks = [
{{ submitting ? 'در حال ثبت…' : 'ثبت پیش‌سفارش' }}
</button>
<p class="cart-summary-card__note">
پس از ثبت سفارش، مدیریت قیمت‌گذاری می‌کند و فاکتور صادر می‌شود؛ سپس روش تسویه را انتخاب می‌کنید.
روش پرداخت الان ثبت می‌شود. مدیریت بعد از آن قیمت و متراژ را اعلام می‌کند.
</p>
</div>
+13 -2
View File
@@ -4,7 +4,8 @@ import EmptyState from '@/components/ui/EmptyState.vue'
import IconTrash from '@/components/icons/IconTrash.vue'
import IconChevronBack from '@/components/icons/IconChevronBack.vue'
import { formatMeterage, formatNumber } from '@/utils/format'
import type { CartItem } from '@/types'
import type { CartItem, SettlementType } from '@/types'
import PreorderPaymentFields from '@/components/orders/PreorderPaymentFields.vue'
defineProps<{
items: CartItem[]
@@ -21,6 +22,8 @@ defineProps<{
remainingCapacity: number
isEmpty: boolean
submitting?: boolean
settlementType: SettlementType | null
checkTermMonths: number | null
}>()
const emit = defineEmits<{
@@ -28,6 +31,8 @@ const emit = defineEmits<{
remove: [id: string]
checkout: []
selectCompany: []
'update:settlementType': [value: SettlementType]
'update:checkTermMonths': [value: number]
}>()
</script>
@@ -98,8 +103,14 @@ const emit = defineEmits<{
<strong>{{ formatMeterage(remainingCapacity) }}</strong>
</div>
</div>
<PreorderPaymentFields
:settlement-type="settlementType"
:check-term-months="checkTermMonths"
@update:settlement-type="emit('update:settlementType', $event)"
@update:check-term-months="emit('update:checkTermMonths', $event)"
/>
<p class="cart-mobile-page__hint">
پس از ثبت سفارش، کارشناسان قیمت‌گذاری می‌کنند و فاکتور برای شما صادر می‌شود.
پس از ثبت، مدیریت بر اساس همین روش پرداخت قیمت و متراژ را اعلام می‌کند.
</p>
<BaseButton block :loading="submitting" @click="emit('checkout')">ثبت پیش‌سفارش</BaseButton>
</div>
+59 -2
View File
@@ -3,7 +3,8 @@ import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useOrderStore } from '@/stores/order'
import type { Order } from '@/types'
import type { Order, SettlementType } from '@/types'
import PreorderPaymentFields from '@/components/orders/PreorderPaymentFields.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import CurrencyInput from '@/components/ui/CurrencyInput.vue'
import PersianDateInput from '@/components/ui/PersianDateInput.vue'
@@ -155,7 +156,37 @@ const trackingFooterLabel = computed(() =>
)
function settlementLabel(type: Order['settlementType']) {
return settlementTypeLabel(type)
const base = settlementTypeLabel(type)
const months = order.value?.checkTermMonths
if ((type === 'check' || type === 'cash_check') && months) {
return `${base} · چک ${months} ماهه`
}
return base
}
const planType = ref<SettlementType | null>(null)
const planMonths = ref<number | null>(null)
const planSaving = ref(false)
async function savePaymentPlan() {
const current = order.value
if (!current || !planType.value) {
formError.value = 'روش پرداخت را انتخاب کنید.'
return
}
if ((planType.value === 'check' || planType.value === 'cash_check') && !planMonths.value) {
formError.value = 'مدت چک را از ۱ تا ۴ ماه انتخاب کنید.'
return
}
planSaving.value = true
formError.value = ''
try {
await orderStore.setPaymentPlan(current.id, planType.value, planMonths.value)
} catch (e) {
formError.value = e instanceof Error ? e.message : 'ثبت روش پرداخت ناموفق بود.'
} finally {
planSaving.value = false
}
}
function showCashTracking(order: Order) {
@@ -314,6 +345,19 @@ async function submitReceipt() {
<p class="desktop-page__subtitle">{{ order.orderNumber }} · {{ order.companyName }}</p>
</header>
<div
v-if="order.status === 'awaiting_review' && !order.settlementType"
class="order-info-card desktop-only"
>
<PreorderPaymentFields
:settlement-type="planType"
:check-term-months="planMonths"
@update:settlement-type="planType = $event"
@update:check-term-months="planMonths = $event"
/>
<BaseButton :loading="planSaving" @click="savePaymentPlan">ثبت روش پرداخت</BaseButton>
</div>
<div class="order-detail-page__body">
<div v-if="!showTracking" class="order-info-card mobile-only">
<div class="order-info-card__head">
@@ -340,6 +384,19 @@ async function submitReceipt() {
</span>
</div>
<div
v-if="order.status === 'awaiting_review' && !order.settlementType"
class="order-info-card__plan"
>
<PreorderPaymentFields
:settlement-type="planType"
:check-term-months="planMonths"
@update:settlement-type="planType = $event"
@update:check-term-months="planMonths = $event"
/>
<BaseButton block :loading="planSaving" @click="savePaymentPlan">ثبت روش پرداخت</BaseButton>
</div>
<div class="order-info-card__row order-info-card__row--last">
<span class="order-info-card__key">مبلغ فاکتور</span>
<strong class="order-info-card__amount">