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 const allowDecimal = options.allowDecimal !== false
let s = persian2english(String(raw ?? '').trim()) let s = persian2english(String(raw ?? '').trim())
.replace(/[٬,\s]/g, '') .replace(/[٬,\s]/g, '')
.replace(/[−–—]/g, '-')
.replace(/٫/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 = 'ریال') { export function usePrice(price: number | string, digit = true, unit = 'ریال') {
const n = typeof price === 'number' ? price : parseNumericValue(price) const n = typeof price === 'number' ? price : parseNumericValue(price)
let _price = Number.isFinite(n) ? String(n) : persian2english(String(price ?? '')) const finite = Number.isFinite(n)
if (digit && 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, ',') _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 { 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> </a-form-item>
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
<a-form-item label="سود فروش" name="profit_sell"> <a-form-item label="سود / تخفیف فروش" name="profit_sell">
<BasicInput v-model:value="form.profit_sell" type="price" addon-before="ریال" /> <SignedProfitInput v-model:value="form.profit_sell" kind="sell" />
</a-form-item> </a-form-item>
<a-form-item label="سود خرید" name="profit_buy"> <a-form-item label="سود / تخفیف خرید" name="profit_buy">
<BasicInput v-model:value="form.profit_buy" type="price" addon-before="ریال" /> <SignedProfitInput v-model:value="form.profit_buy" kind="buy" />
</a-form-item> </a-form-item>
</div> </div>
@@ -76,6 +76,7 @@ import { debounce } from 'lodash'
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import BasicInput from '@/components/BasicInput.vue' import BasicInput from '@/components/BasicInput.vue'
import BasicSelect from '@/components/BasicSelect.vue' import BasicSelect from '@/components/BasicSelect.vue'
import SignedProfitInput from './SignedProfitInput.vue'
import { syncAndLoadCategories, type CategorySelectItem } from '@/utils/categorySelect' import { syncAndLoadCategories, type CategorySelectItem } from '@/utils/categorySelect'
import { import {
getProductsByCategories, getProductsByCategories,
@@ -87,8 +88,8 @@ export type SpecialPriceForm = {
category_names: string[] category_names: string[]
product_ids: number[] product_ids: number[]
customer_ids: number[] customer_ids: number[]
profit_buy: number | string profit_buy: number
profit_sell: number | string profit_sell: number
show_product: boolean show_product: boolean
is_active: boolean is_active: boolean
} }
@@ -2,6 +2,15 @@ import type { BaseTableColumn } from '@core/type/baseTable'
import { usePrice } from '@core/utils/usePrice' import { usePrice } from '@core/utils/usePrice'
import { toJalali } from '@core/utils/useDataTime' 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[] { export function getSpecialPricePackageColumns(): BaseTableColumn[] {
return [ return [
{ {
@@ -37,14 +46,14 @@ export function getSpecialPricePackageColumns(): BaseTableColumn[] {
dataIndex: 'profit_buy', dataIndex: 'profit_buy',
key: 'profit_buy', key: 'profit_buy',
type: 'Text', type: 'Text',
customRender: ({ record }) => usePrice(record.profit_buy ?? 0, true, '') customRender: ({ record }) => formatSignedProfit(record.profit_buy)
}, },
{ {
title: 'سود فروش', title: 'سود فروش',
dataIndex: 'profit_sell', dataIndex: 'profit_sell',
key: 'profit_sell', key: 'profit_sell',
type: 'Text', type: 'Text',
customRender: ({ record }) => usePrice(record.profit_sell ?? 0, true, '') customRender: ({ record }) => formatSignedProfit(record.profit_sell)
}, },
{ {
title: 'نمایش محصول', title: 'نمایش محصول',
@@ -88,8 +88,8 @@ async function openEdit(record: any) {
category_names: data?.category_names ?? [], category_names: data?.category_names ?? [],
product_ids: (data?.product_ids ?? []).map(Number), product_ids: (data?.product_ids ?? []).map(Number),
customer_ids: (data?.customer_ids ?? []).map(Number), customer_ids: (data?.customer_ids ?? []).map(Number),
profit_buy: data?.package?.profit_buy ?? 0, profit_buy: Number(data?.package?.profit_buy ?? 0),
profit_sell: data?.package?.profit_sell ?? 0, profit_sell: Number(data?.package?.profit_sell ?? 0),
show_product: Boolean(data?.package?.show_product ?? true), show_product: Boolean(data?.package?.show_product ?? true),
is_active: Boolean(data?.package?.is_active ?? true) is_active: Boolean(data?.package?.is_active ?? true)
} }