feat(admin): signed profit/discount fields for special price packages

Let buy/sell extras be entered as profit or discount so negative values work in the editor and table.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 13:10:59 +03:30
co-authored by Cursor
parent 8636fd1aea
commit 7440a53c80
6 changed files with 180 additions and 13 deletions
+1
View File
@@ -27,6 +27,7 @@ export function sanitizeNumericString(
const allowDecimal = options.allowDecimal !== false
let s = persian2english(String(raw ?? '').trim())
.replace(/[٬,\s]/g, '')
.replace(/[−–—]/g, '-')
.replace(/٫/g, '.')
.replace(/\//g, '.')
+7 -3
View File
@@ -2,11 +2,15 @@ import { persian2english, parseNumericValue, sanitizeNumericString } from '@core
export function usePrice(price: number | string, digit = true, unit = 'ریال') {
const n = typeof price === 'number' ? price : parseNumericValue(price)
let _price = Number.isFinite(n) ? String(n) : persian2english(String(price ?? ''))
if (digit && Number.isFinite(n)) {
const finite = Number.isFinite(n)
const sign = finite && n < 0 ? '-' : ''
const abs = finite ? Math.abs(n) : NaN
let _price = finite ? String(abs) : persian2english(String(price ?? ''))
if (digit && Number.isFinite(abs)) {
_price = _price.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
return `${_price} ${unit}`
const shown = finite ? `${sign}${_price}` : _price
return unit ? `${shown} ${unit}` : shown
}
export function useTowDecimal(num: string | number): string | number {
@@ -0,0 +1,152 @@
<template>
<div class="signed-profit" :class="rootClass">
<a-radio-group
v-model:value="mode"
button-style="solid"
class="signed-profit__modes w-full"
@change="onModeChange"
>
<a-radio-button value="profit" class="signed-profit__radio signed-profit__radio--profit">
<span class="signed-profit__sign">+</span>
سود
</a-radio-button>
<a-radio-button value="discount" class="signed-profit__radio signed-profit__radio--discount">
<span class="signed-profit__sign">−</span>
تخفیف
</a-radio-button>
</a-radio-group>
<BasicInput
:value="absDisplay"
type="price"
addon-before="ریال"
place-holder="۰"
@update:value="onAmountChange"
/>
<p class="signed-profit__hint">{{ hint }}</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import BasicInput from '@/components/BasicInput.vue'
import { parseNumericValue } from '@core/utils/convert'
const props = defineProps<{
value?: number | string | null
kind: 'buy' | 'sell'
}>()
const emit = defineEmits<{
'update:value': [value: number]
}>()
const numeric = computed(() => {
const n = typeof props.value === 'number' ? props.value : parseNumericValue(props.value)
return Number.isFinite(n) ? n : 0
})
const mode = ref<'profit' | 'discount'>('profit')
watch(
numeric,
(n) => {
if (n < 0) mode.value = 'discount'
else if (n > 0) mode.value = 'profit'
},
{ immediate: true },
)
const absDisplay = computed(() => Math.abs(numeric.value))
const rootClass = computed(() =>
mode.value === 'discount' ? 'signed-profit--discount' : 'signed-profit--profit',
)
const hint = computed(() => {
if (props.kind === 'buy') {
return mode.value === 'discount'
? 'تخفیف: از قیمت خرید مشتری کم می‌شود'
: 'سود: به قیمت خرید مشتری اضافه می‌شود'
}
return mode.value === 'discount'
? 'تخفیف: به قیمت فروش مشتری اضافه می‌شود'
: 'سود: از قیمت فروش مشتری کم می‌شود'
})
function emitSigned(abs: number, discount: boolean) {
const safe = Number.isFinite(abs) ? Math.abs(Math.round(abs)) : 0
emit('update:value', discount && safe > 0 ? -safe : safe)
}
function onModeChange() {
emitSigned(absDisplay.value, mode.value === 'discount')
}
function onAmountChange(raw: string | number) {
const n = typeof raw === 'number' ? raw : parseNumericValue(raw)
emitSigned(Number.isFinite(n) ? n : 0, mode.value === 'discount')
}
</script>
<style scoped lang="less">
.signed-profit {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.signed-profit__modes {
display: flex;
}
.signed-profit__modes :deep(.ant-radio-button-wrapper) {
flex: 1;
text-align: center;
font-weight: 700;
}
.signed-profit__sign {
display: inline-block;
margin-left: 0.3rem;
font-weight: 800;
}
.signed-profit__hint {
margin: 0;
font-size: 0.75rem;
line-height: 1.5;
color: var(--admin-text-secondary, rgba(0, 0, 0, 0.45));
}
.signed-profit--profit .signed-profit__hint {
color: #15803d;
}
.signed-profit--discount .signed-profit__hint {
color: #b91c1c;
}
.signed-profit__modes :deep(.ant-radio-button-wrapper-checked.signed-profit__radio--profit) {
background: #15803d !important;
border-color: #15803d !important;
color: #fff !important;
}
.signed-profit__modes :deep(.ant-radio-button-wrapper-checked.signed-profit__radio--discount) {
background: #b91c1c !important;
border-color: #b91c1c !important;
color: #fff !important;
}
html[data-theme='dark'] .signed-profit--profit .signed-profit__hint,
.dark .signed-profit--profit .signed-profit__hint {
color: #86efac;
}
html[data-theme='dark'] .signed-profit--discount .signed-profit__hint,
.dark .signed-profit--discount .signed-profit__hint {
color: #fca5a5;
}
</style>
@@ -43,11 +43,11 @@
</a-form-item>
<div class="grid gap-3 md:grid-cols-2">
<a-form-item label="سود فروش" name="profit_sell">
<BasicInput v-model:value="form.profit_sell" type="price" addon-before="ریال" />
<a-form-item label="سود / تخفیف فروش" name="profit_sell">
<SignedProfitInput v-model:value="form.profit_sell" kind="sell" />
</a-form-item>
<a-form-item label="سود خرید" name="profit_buy">
<BasicInput v-model:value="form.profit_buy" type="price" addon-before="ریال" />
<a-form-item label="سود / تخفیف خرید" name="profit_buy">
<SignedProfitInput v-model:value="form.profit_buy" kind="buy" />
</a-form-item>
</div>
@@ -76,6 +76,7 @@ import { debounce } from 'lodash'
import { onMounted, ref, watch } from 'vue'
import BasicInput from '@/components/BasicInput.vue'
import BasicSelect from '@/components/BasicSelect.vue'
import SignedProfitInput from './SignedProfitInput.vue'
import { syncAndLoadCategories, type CategorySelectItem } from '@/utils/categorySelect'
import {
getProductsByCategories,
@@ -87,8 +88,8 @@ export type SpecialPriceForm = {
category_names: string[]
product_ids: number[]
customer_ids: number[]
profit_buy: number | string
profit_sell: number | string
profit_buy: number
profit_sell: number
show_product: boolean
is_active: boolean
}
@@ -2,6 +2,15 @@ import type { BaseTableColumn } from '@core/type/baseTable'
import { usePrice } from '@core/utils/usePrice'
import { toJalali } from '@core/utils/useDataTime'
function formatSignedProfit(raw: unknown) {
const n = Number(raw ?? 0)
if (!Number.isFinite(n) || n === 0) {
return usePrice(0, true, '')
}
const label = n < 0 ? 'تخفیف' : 'سود'
return `${label} ${usePrice(Math.abs(n), true, '')}`
}
export function getSpecialPricePackageColumns(): BaseTableColumn[] {
return [
{
@@ -37,14 +46,14 @@ export function getSpecialPricePackageColumns(): BaseTableColumn[] {
dataIndex: 'profit_buy',
key: 'profit_buy',
type: 'Text',
customRender: ({ record }) => usePrice(record.profit_buy ?? 0, true, '')
customRender: ({ record }) => formatSignedProfit(record.profit_buy)
},
{
title: 'سود فروش',
dataIndex: 'profit_sell',
key: 'profit_sell',
type: 'Text',
customRender: ({ record }) => usePrice(record.profit_sell ?? 0, true, '')
customRender: ({ record }) => formatSignedProfit(record.profit_sell)
},
{
title: 'نمایش محصول',
@@ -88,8 +88,8 @@ async function openEdit(record: any) {
category_names: data?.category_names ?? [],
product_ids: (data?.product_ids ?? []).map(Number),
customer_ids: (data?.customer_ids ?? []).map(Number),
profit_buy: data?.package?.profit_buy ?? 0,
profit_sell: data?.package?.profit_sell ?? 0,
profit_buy: Number(data?.package?.profit_buy ?? 0),
profit_sell: Number(data?.package?.profit_sell ?? 0),
show_product: Boolean(data?.package?.show_product ?? true),
is_active: Boolean(data?.package?.is_active ?? true)
}