added some text handling 3

This commit is contained in:
2026-07-08 18:24:55 +03:30
parent 89e2643dff
commit 01437afa84
19 changed files with 683 additions and 168 deletions
+4
View File
@@ -10,6 +10,7 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
_index: typeof import('./src/core/components/_sample/_index.vue')['default']
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
@@ -21,6 +22,7 @@ declare module '@vue/runtime-core' {
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
ADivider: typeof import('ant-design-vue/es')['Divider']
AdminFieldHelp: typeof import('./src/components/AdminFieldHelp.vue')['default']
AdminHelpTooltipContent: typeof import('./src/components/AdminHelpTooltipContent.vue')['default']
AdminLabelWithHelp: typeof import('./src/components/AdminLabelWithHelp.vue')['default']
ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
@@ -41,6 +43,7 @@ declare module '@vue/runtime-core' {
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
APopover: typeof import('ant-design-vue/es')['Popover']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARow: typeof import('ant-design-vue/es')['Row']
@@ -56,6 +59,7 @@ declare module '@vue/runtime-core' {
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
AUpload: typeof import('ant-design-vue/es')['Upload']
BaseButton: typeof import('./src/core/components/BaseButton/index.ts')['default']
BaseCard: typeof import('./src/core/components/BaseCard/index.ts')['default']
BaseCollapse: typeof import('./src/components/BaseCollapse.vue')['default']
+27 -1
View File
@@ -46,9 +46,35 @@ export const getPhysicalWithdrawOptions = async (customerId: number) =>
export const createPhysicalWithdraw = async (payload: CreatePhysicalWithdrawPayload) =>
await useAxios.post<{ id: number }>('/admin/wallet/withdraw_physical', payload)
export interface PhysicalWithdrawAdjustLine {
transaction_id: number
title: string
current_value: number
available_add: number
max_value: number
min_value: number
allows_decimal: boolean
unit: string
}
export interface PhysicalWithdrawAdjustInfo {
withdraw_id: number
customer_name: string
customer_mobile: string
lines: PhysicalWithdrawAdjustLine[]
}
export interface PhysicalWithdrawAdjustItem {
transaction_id: number
value: number
}
export const getPhysicalWithdrawAdjustInfo = async (withdrawId: number) =>
await useAxios.get<PhysicalWithdrawAdjustInfo>(`/admin/wallet/withdraw_physical/${withdrawId}/adjust-info`)
export const adjustPhysicalWithdraw = async (
withdrawId: number,
payload: { transaction_id: number; direction: 'increase' | 'decrease' }
payload: { items: PhysicalWithdrawAdjustItem[] }
) => await useAxios.post(`/admin/wallet/withdraw_physical/${withdrawId}/adjust`, payload)
export const approvePhysicalWithdraw = async (withdrawId: number, sts = 51) =>
+29 -11
View File
@@ -1,23 +1,20 @@
<template>
<a-tooltip
v-if="text"
v-if="hasContent"
placement="top"
:title="text"
overlay-class-name="admin-field-help-tooltip"
:overlay-style="{ zIndex: TOOLTIP_ABOVE_MODAL_Z_INDEX }"
:overlay-inner-style="{
maxWidth: '22rem',
direction: 'rtl',
textAlign: 'right',
whiteSpace: 'pre-line',
}"
:overlay-inner-style="tooltipInnerStyle"
:mouse-enter-delay="0.15"
>
<template #title>
<AdminHelpTooltipContent :content="parsed" />
</template>
<span
class="admin-field-help"
:class="rootClass"
role="img"
:aria-label="text"
:aria-label="plainText"
tabindex="0"
>
<IosInfoBadge :size="size" />
@@ -27,11 +24,17 @@
<script setup lang="ts">
import IosInfoBadge from '@/pages/dashboard/components/IosInfoBadge.vue'
import AdminHelpTooltipContent from '@/components/AdminHelpTooltipContent.vue'
import { TOOLTIP_ABOVE_MODAL_Z_INDEX } from '@/constants/overlayZIndex'
import {
adminHelpPlainText,
parseAdminHelp,
type AdminHelpContent,
} from '@/utils/adminHelpFormat'
withDefaults(
const props = withDefaults(
defineProps<{
text?: string
text?: string | AdminHelpContent
rootClass?: string
size?: 'xs' | 'sm' | 'md'
}>(),
@@ -41,6 +44,16 @@ withDefaults(
size: 'xs',
}
)
const parsed = computed(() => parseAdminHelp(props.text))
const hasContent = computed(() => Boolean(parsed.value.description || parsed.value.formula))
const plainText = computed(() => adminHelpPlainText(parsed.value))
const tooltipInnerStyle = {
maxWidth: '24rem',
direction: 'rtl',
textAlign: 'right',
}
</script>
<style scoped lang="less">
@@ -57,4 +70,9 @@ withDefaults(
.ant-tooltip.admin-field-help-tooltip {
z-index: 1250 !important;
}
.ant-tooltip.admin-field-help-tooltip .ant-tooltip-inner {
direction: rtl;
text-align: right;
}
</style>
@@ -0,0 +1,70 @@
<template>
<div class="admin-help-tooltip-content" dir="rtl">
<p v-if="content.description" class="admin-help-tooltip-content__text">
{{ content.description }}
</p>
<div v-if="content.formula" class="admin-help-tooltip-content__formula">
<span class="admin-help-tooltip-content__formula-label">فرمول</span>
<p class="admin-help-tooltip-content__formula-text">{{ content.formula }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { AdminHelpContent } from '@/utils/adminHelpFormat'
defineProps<{
content: AdminHelpContent
}>()
</script>
<style scoped>
.admin-help-tooltip-content {
direction: rtl;
text-align: right;
line-height: 1.6;
}
.admin-help-tooltip-content__text {
margin: 0;
font-size: 0.8125rem;
color: inherit;
}
.admin-help-tooltip-content__formula {
margin-top: 0.55rem;
padding: 0.45rem 0.55rem;
border-radius: 0.45rem;
border: 1px solid rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.1);
}
.admin-help-tooltip-content__formula-label {
display: block;
margin-bottom: 0.2rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #b45309;
}
.admin-help-tooltip-content__formula-text {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.55;
color: inherit;
direction: rtl;
text-align: right;
white-space: pre-line;
}
html.is-dark .admin-help-tooltip-content__formula {
border-color: rgba(252, 211, 77, 0.35);
background: rgba(252, 211, 77, 0.08);
}
html.is-dark .admin-help-tooltip-content__formula-label {
color: #fcd34d;
}
</style>
+1 -1
View File
@@ -66,7 +66,7 @@ const localValue = computed({
get() {
let a =
props.type === 'price'
? props.value
? props.value != null && props.value !== ''
? usePrice(props.value as number, true, '').trim()
: ''
: props.value
+6 -1
View File
@@ -63,7 +63,12 @@ export interface WithdrawTransaction {
id?: number
title?: string
gold?: number
product?: { title?: string; amount?: number }
product?: {
title?: string
amount?: number
unit?: string
trade_by_quantity?: boolean
}
}
export interface Withdraw {
@@ -1,16 +1,18 @@
<template>
<a-tooltip
placement="top"
:title="tooltipText"
overlay-class-name="dashboard-help-tooltip"
:overlay-style="{ zIndex: TOOLTIP_ABOVE_MODAL_Z_INDEX }"
:overlay-inner-style="{ maxWidth: '26rem', direction: 'rtl', textAlign: 'right', whiteSpace: 'pre-line' }"
:overlay-inner-style="tooltipInnerStyle"
:mouse-enter-delay="0.15"
>
<template #title>
<AdminHelpTooltipContent :content="parsed" />
</template>
<span
class="dashboard-help-trigger inline-flex shrink-0 items-center justify-center"
role="img"
:aria-label="tooltipText"
:aria-label="plainText"
tabindex="0"
>
<IosInfoBadge :size="badgeSize" />
@@ -21,7 +23,9 @@
<script setup lang="ts">
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
import IosInfoBadge from '@/pages/dashboard/components/IosInfoBadge.vue'
import AdminHelpTooltipContent from '@/components/AdminHelpTooltipContent.vue'
import { TOOLTIP_ABOVE_MODAL_Z_INDEX } from '@/constants/overlayZIndex'
import { adminHelpPlainText } from '@/utils/adminHelpFormat'
const props = withDefaults(
defineProps<{
@@ -35,13 +39,18 @@ const props = withDefaults(
const badgeSize = computed(() => props.size)
const tooltipText = computed(() => {
const blocks = [props.help.description?.trim()].filter(Boolean)
if (props.help.formula?.trim()) {
blocks.push(props.help.formula.trim())
}
return blocks.join('\n\n')
})
const parsed = computed(() => ({
description: props.help.description?.trim() ?? '',
formula: props.help.formula?.trim() || undefined,
}))
const plainText = computed(() => adminHelpPlainText(parsed.value))
const tooltipInnerStyle = {
maxWidth: '26rem',
direction: 'rtl',
textAlign: 'right',
}
</script>
<style scoped>
@@ -56,4 +65,9 @@ const tooltipText = computed(() => {
z-index: 1250 !important;
pointer-events: none;
}
.ant-tooltip.dashboard-help-tooltip .ant-tooltip-inner {
direction: rtl;
text-align: right;
}
</style>
@@ -1,10 +1,6 @@
<template>
<section class="dashboard-income-summary">
<DashboardSectionHeader title="خلاصه درآمد معاملات" :help="help" />
<p class="dashboard-income-summary__intro">
سود اختلاف قیمت و کارمزد جدا محاسبه می‌شوند. جمع = هر دو. همه مبالغ
<strong>ریال</strong> هستند (نه تومان).
</p>
<div class="dashboard-income-summary__grid">
<article
@@ -95,13 +91,6 @@ const rows = computed(() => [
color: var(--admin-text);
}
.dashboard-income-summary__intro {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
line-height: 1.55;
color: var(--admin-text-secondary);
}
.dashboard-income-summary__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
+1 -1
View File
@@ -58,7 +58,7 @@ export const dashboardKpi = {
} as const satisfies Record<string, DashboardHelp>
export const dashboardTradingIncomeSummary = help(
'تفکیک درآمد از معاملات در بازه انتخابی: سود اختلاف قیمت و کارمزد ریالی جدا، و جمع هر دو.',
'در بازهٔ انتخابی، درآمد معاملات را به دو بخش می‌بینید: سود اختلاف قیمت و کارمزد ریالی. همهٔ مبالغ به ریال هستند (نه تومان).',
'جمع سود معاملات = سود اختلاف قیمت + کارمزد معاملات'
)
@@ -400,7 +400,13 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
support_messenger_text:
'متن راهنمای واریز در مودال شارژ حساب. متغیرها: ٪نام یا %نام = نام صاحب حساب؛ ٪کارت یا %کارت = شماره کارت؛ ٪شبا یا %شبا = شبا. فاصله بعد از ٪ اختیاری است. اگر خالی باشد از متن پیش‌فرض اپ استفاده می‌شود.',
deposit_support_message_text:
'متن پیام هنگام کلیک روی «پیام به پشتیبانی» و «پیام به پشتیبانی (sms)» در مودال واریز. همان متغیرهای ٪نام، ٪کارت و ٪شبا. پیش‌فرض: «شماره حساب».',
`متن پیام هنگام کلیک «پیام به پشتیبانی» در مودال واریز (SMS و پیام‌رسان).
متغیر:
• %نام = نام صاحب حساب
پیش‌فرض اپ:
باسلام، %نام هستم. کدپیگیری و مبلغ واریزی من:`,
deposit_account_holder:
'نام صاحب حساب واریز — در متن راهنما و پیام پشتیبانی با ٪نام یا %نام جایگزین می‌شود.',
deposit_card_number:
@@ -0,0 +1,233 @@
<template>
<BasicModal
v-model:visible="visibleProxy"
title="ویرایش مقدار برداشت"
width="28rem"
wrap-class-name="physical-withdraw-adjust-modal"
:loading="loading"
:confirm-loading="saving"
@ok="submit"
@close="close"
>
<div v-if="loading" class="py-10 text-center text-sm text-gray-500">در حال بارگذاری...</div>
<div v-else-if="info && formLines.length" class="space-y-4">
<div class="rounded-lg bg-gray-50 p-3 text-sm leading-6">
<div>سند: <span class="font-semibold">#{{ info.withdraw_id }}</span></div>
<div>مشتری: <span class="font-semibold">{{ info.customer_name || '-' }}</span></div>
<div v-if="info.customer_mobile">موبایل: {{ info.customer_mobile }}</div>
</div>
<div
v-for="line in formLines"
:key="line.transaction_id"
class="rounded-lg border border-gray-200 p-3"
>
<div class="mb-2 text-sm font-semibold">{{ line.title }}</div>
<div class="mb-3 grid grid-cols-2 gap-2 text-xs text-gray-600">
<div>مقدار فعلی: <span class="font-medium text-gray-900">{{ formatQty(line.current_value, line) }}</span></div>
<div>قابل افزودن: <span class="font-medium text-gray-900">{{ formatQty(line.available_add, line) }}</span></div>
<div>حداکثر مجاز: <span class="font-medium text-gray-900">{{ formatQty(line.max_value, line) }}</span></div>
<div>حداقل مجاز: <span class="font-medium text-gray-900">{{ formatQty(line.min_value, line) }}</span></div>
</div>
<a-form-item
class="!mb-0"
:validate-status="lineError(line.transaction_id) ? 'error' : ''"
:help="lineError(line.transaction_id)"
>
<template #label>
<AdminLabelWithHelp label="مقدار جدید" :help="physicalWithdrawAdjustValueHelp" />
</template>
<BasicInput
v-model:value="line.valueInput"
type="number"
:step="line.allows_decimal ? '0.001' : '1'"
:place-holder="line.allows_decimal ? 'مثلاً 1.250' : 'مثلاً 2'"
@change="() => clearLineError(line.transaction_id)"
/>
</a-form-item>
</div>
</div>
<div v-else-if="info && !formLines.length" class="py-8 text-center text-sm text-gray-500">
خطی برای ویرایش یافت نشد.
</div>
</BasicModal>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import {
adjustPhysicalWithdraw,
getPhysicalWithdrawAdjustInfo,
type PhysicalWithdrawAdjustInfo,
type PhysicalWithdrawAdjustLine
} from '@/api/physicalWithdraw'
import { physicalWithdrawAdjustValueHelp } from '@/utils/adminHelpCopy'
type FormLine = PhysicalWithdrawAdjustLine & { valueInput: string }
const props = defineProps<{
visible: boolean
withdrawId?: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const visibleProxy = computed({
get: () => props.visible,
set: (value: boolean) => emit('update:visible', value),
})
const loading = ref(false)
const saving = ref(false)
const info = ref<PhysicalWithdrawAdjustInfo | null>(null)
const formLines = ref<FormLine[]>([])
const lineErrors = ref<Record<number, string>>({})
watch(
() => [props.visible, props.withdrawId] as const,
([open, withdrawId]) => {
if (open && withdrawId) {
void loadInfo(withdrawId)
}
if (!open) {
resetState()
}
}
)
function resetState() {
info.value = null
formLines.value = []
lineErrors.value = {}
}
function formatQty(value: number, line: Pick<PhysicalWithdrawAdjustLine, 'unit' | 'allows_decimal'>) {
const decimals = line.allows_decimal ? 3 : 0
const formatted = Number(value).toLocaleString('fa-IR', {
minimumFractionDigits: 0,
maximumFractionDigits: decimals
})
return `${formatted} ${line.unit}`
}
function lineError(transactionId: number) {
return lineErrors.value[transactionId] ?? ''
}
function clearLineError(transactionId: number) {
if (lineErrors.value[transactionId]) {
const next = { ...lineErrors.value }
delete next[transactionId]
lineErrors.value = next
}
}
function parseValue(raw: string, line: PhysicalWithdrawAdjustLine): number | null {
const normalized = String(raw ?? '').trim().replace(',', '.')
if (!normalized) return null
const value = Number(normalized)
if (!Number.isFinite(value)) return null
if (!line.allows_decimal && Math.abs(value - Math.round(value)) > 0.000001) {
return null
}
return value
}
function validateLine(line: FormLine): string | null {
const value = parseValue(line.valueInput, line)
if (value == null) {
return line.allows_decimal ? 'مقدار معتبر وارد کنید' : 'مقدار باید عدد صحیح باشد'
}
if (value < line.min_value - 0.000001) {
return `حداقل مقدار ${formatQty(line.min_value, line)} است`
}
if (value > line.max_value + 0.000001) {
return `حداکثر مقدار ${formatQty(line.max_value, line)} است`
}
return null
}
async function loadInfo(withdrawId: number) {
loading.value = true
lineErrors.value = {}
try {
const data = await getPhysicalWithdrawAdjustInfo(withdrawId)
info.value = data
formLines.value = (data.lines ?? []).map((line) => ({
...line,
valueInput: String(line.current_value ?? '')
}))
} catch (e) {
console.error(e)
visibleProxy.value = false
} finally {
loading.value = false
}
}
function close() {
visibleProxy.value = false
}
async function submit() {
if (!props.withdrawId || !formLines.value.length) return
const errors: Record<number, string> = {}
const items: Array<{ transaction_id: number; value: number }> = []
for (const line of formLines.value) {
const error = validateLine(line)
if (error) {
errors[line.transaction_id] = error
continue
}
const value = parseValue(line.valueInput, line)
if (value == null) {
errors[line.transaction_id] = 'مقدار معتبر وارد کنید'
continue
}
if (Math.abs(value - line.current_value) < 0.000001) {
continue
}
items.push({
transaction_id: line.transaction_id,
value
})
}
lineErrors.value = errors
if (Object.keys(errors).length) {
message.error('لطفاً مقادیر را اصلاح کنید')
return
}
if (!items.length) {
message.info('تغییری اعمال نشده است')
return
}
saving.value = true
try {
await adjustPhysicalWithdraw(props.withdrawId, { items })
message.success('مقادیر با موفقیت به‌روزرسانی شد')
visibleProxy.value = false
emit('success')
} catch (e) {
console.error(e)
} finally {
saving.value = false
}
}
</script>
<style>
.physical-withdraw-adjust-modal {
z-index: 1100 !important;
}
</style>
@@ -1,26 +1,40 @@
<template>
<a-form ref="formRef" :model="form" layout="vertical" autocomplete="off">
<a-form-item label="مشتری" name="customer_id" :rules="inputRules()">
<a-form-item name="customer_id" :rules="inputRules()">
<template #label>
<AdminLabelWithHelp label="مشتری" :help="physicalWithdrawCustomerHelp" />
</template>
<BasicSelect
v-model:value="form.customer_id"
show-search
:options="customerOptions"
:items="customerItems"
key-name="id"
value-name="displayName"
placeholder="انتخاب مشتری"
:has-tooltip="false"
@change="onCustomerChange"
/>
</a-form-item>
<a-form-item v-if="form.customer_id" label="آدرس تحویل" name="address_id" :rules="inputRules()">
<a-form-item v-if="form.customer_id" name="address_id" :rules="inputRules()">
<template #label>
<AdminLabelWithHelp label="آدرس تحویل" :help="physicalWithdrawAddressHelp" />
</template>
<BasicSelect
v-model:value="form.address_id"
:options="addressOptions"
:items="addressItems"
key-name="id"
value-name="displayName"
placeholder="انتخاب آدرس"
:loading="optionsLoading"
:has-tooltip="false"
/>
</a-form-item>
<div v-if="form.customer_id" class="space-y-3">
<div class="text-sm font-semibold">محصولات</div>
<div class="flex items-center gap-2 text-sm font-semibold">
<span>محصولات</span>
<AdminFieldHelp :text="physicalWithdrawProductHelp" />
</div>
<div
v-for="(line, index) in form.lines"
:key="`${line.wallet_id}-${index}`"
@@ -30,13 +44,16 @@
class="!mb-0"
:name="['lines', index, 'wallet_id']"
:rules="inputRules()"
label="محصول"
>
<template #label>محصول</template>
<BasicSelect
v-model:value="line.wallet_id"
:options="productOptionsForLine(index)"
:items="productItemsForLine(index)"
key-name="wallet_id"
value-name="displayName"
placeholder="انتخاب محصول"
:loading="optionsLoading"
:has-tooltip="false"
@change="() => onProductChange(index)"
/>
</a-form-item>
@@ -44,8 +61,10 @@
class="!mb-0"
:name="['lines', index, 'value']"
:rules="inputRules()"
label="مقدار"
>
<template #label>
<AdminLabelWithHelp label="مقدار" :help="physicalWithdrawAmountHelp" />
</template>
<BasicInput v-model:value="line.value" type="number" min="0" />
</a-form-item>
<div class="flex items-end">
@@ -73,6 +92,14 @@
import { inputRules } from '@/utils/inputRules'
import type { Customer } from '@/model/customer'
import type { FormInstance } from 'ant-design-vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import {
physicalWithdrawAddressHelp,
physicalWithdrawAmountHelp,
physicalWithdrawCustomerHelp,
physicalWithdrawProductHelp
} from '@/utils/adminHelpCopy'
import {
getPhysicalWithdrawOptions,
type PhysicalWithdrawAddressOption,
@@ -100,21 +127,21 @@ const optionsLoading = ref(false)
const products = ref<PhysicalWithdrawProductOption[]>([])
const addresses = ref<PhysicalWithdrawAddressOption[]>([])
const customerOptions = computed(() =>
const customerItems = computed(() =>
(props.customers ?? []).map((customer) => ({
label: `${customer.name ?? '-'} (${customer.mobile ?? '-'})`,
value: customer.id
...customer,
displayName: `${customer.name ?? '-'} (${customer.mobile ?? '-'})`
}))
)
const addressOptions = computed(() =>
const addressItems = computed(() =>
addresses.value.map((addr) => ({
label: [addr.province, addr.city, addr.address].filter(Boolean).join(' / '),
value: addr.id
...addr,
displayName: [addr.province, addr.city, addr.address].filter(Boolean).join(' / ')
}))
)
function productOptionsForLine(index: number) {
function productItemsForLine(index: number) {
const usedWalletIds = props.form.lines
.map((line, i) => (i === index ? undefined : line.wallet_id))
.filter(Boolean)
@@ -122,8 +149,8 @@ function productOptionsForLine(index: number) {
return products.value
.filter((product) => !usedWalletIds.includes(product.wallet_id))
.map((product) => ({
label: `${product.title} (موجودی: ${product.available} ${product.unit})`,
value: product.wallet_id
...product,
displayName: `${product.title} (موجودی: ${product.available} ${product.unit})`
}))
}
@@ -1,83 +1,81 @@
<template>
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
<BasicShowContext title="لیست برداشت فیزیکی">
<template #extra>
<AdminFieldHelp :text="physicalWithdrawPageIntro" />
</template>
<div class="flex h-full min-h-0 flex-col">
<BaseTable class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader>
<BaseButton type="primary" size="small" icon="vuesax-linear:add-square" @click="emits('add')">
ثبت برداشت فیزیکی
</BaseButton>
<div class="flex items-center gap-2">
<BaseButton type="primary" size="small" icon="vuesax-linear:add-square" @click="emits('add')">
ثبت برداشت فیزیکی
</BaseButton>
</div>
</template>
<template #action="{ record }">
<div class="flex flex-col items-center gap-2 py-1">
<div
v-for="tx in record.transaction ?? []"
:key="tx.id"
class="flex w-full max-w-[220px] items-center justify-between gap-2 rounded border border-gray-200 px-2 py-1"
>
<span class="truncate text-xs">{{ tx.title?.trim() || 'محصول' }}</span>
<span class="shrink-0 text-xs font-semibold">{{ Math.abs(Number(tx.gold ?? 0)) }}</span>
<div v-if="canAdjust(record)" class="flex shrink-0 gap-1">
<BaseButton
size="small"
class="!min-w-0 !px-2"
:loading="isAdjusting(record.id, tx.id, 'decrease')"
@click="handleAdjust(record, tx, 'decrease')"
>
کم
</BaseButton>
<BaseButton
size="small"
type="primary"
class="!min-w-0 !px-2"
:loading="isAdjusting(record.id, tx.id, 'increase')"
@click="handleAdjust(record, tx, 'increase')"
>
زیاد
</BaseButton>
</div>
</div>
<div class="flex items-center justify-center gap-2">
<a-tooltip v-if="canAdjust(record)">
<template #title>ویرایش مقدار</template>
<Icon
icon="vuesax-linear:edit-2"
color="#2563EB"
class="cursor-pointer"
@click="openAdjustModal(record)"
/>
</a-tooltip>
<div class="flex flex-wrap items-center justify-center gap-2">
<BaseButton
v-if="canApprove(record)"
size="small"
type="primary"
:loading="isApproving(record.id)"
<a-tooltip v-if="canApprove(record)">
<template #title>{{ physicalWithdrawApproveHelp }}</template>
<Icon
icon="vuesax-linear:tick-circle"
color="#16A34A"
class="cursor-pointer"
:class="{ 'opacity-40 pointer-events-none': isApproving(record.id) }"
@click="handleApprove(record)"
>
تایید
</BaseButton>
<ADropdown v-if="canChangeStatus(record)" :trigger="['click']">
<BaseButton size="small">وضعیت</BaseButton>
<template #overlay>
<a-menu class="flex flex-col">
<a-menu-item
v-for="(item, key) in statusOptions(record.sts)"
:key="key"
class="border-none px-2 py-1"
@click="handleStatusChange(record.id, Number(key))"
>
{{ item.title }}
</a-menu-item>
</a-menu>
</template>
</ADropdown>
<a-tooltip v-if="record.sts >= 50 && record.sts <= 70">
<template #title>دریافت فاکتور</template>
<Icon
icon="vuesax-linear:document-download"
color="#1D4ED8"
class="cursor-pointer"
@click="$router.push({ name: 'factor', params: { id: record.id } })"
/>
/>
</a-tooltip>
<ADropdown v-if="canChangeStatus(record)" :trigger="['click']">
<a-tooltip>
<template #title>{{ physicalWithdrawStatusHelp }}</template>
<Icon icon="vuesax-linear:status-up" color="#CA8A04" class="cursor-pointer" />
</a-tooltip>
</div>
<template #overlay>
<a-menu class="flex flex-col min-w-[10rem]">
<a-menu-item
v-for="(item, key) in statusOptions(record.sts)"
:key="key"
class="border-none px-3 py-2"
:style="{ color: item.color }"
@click="handleStatusChange(record.id, Number(key))"
>
{{ item.title }}
</a-menu-item>
</a-menu>
</template>
</ADropdown>
<a-tooltip v-if="record.sts >= 50 && record.sts <= 70">
<template #title>دریافت فاکتور</template>
<Icon
icon="vuesax-linear:document-download"
color="#1D4ED8"
class="cursor-pointer"
@click="$router.push({ name: 'factor', params: { id: record.id } })"
/>
</a-tooltip>
</div>
</template>
</BaseTable>
</div>
</BasicShowContext>
<PhysicalWithdrawAdjustModal
v-model:visible="adjustVisible"
:withdraw-id="selectedWithdrawId"
@success="reload"
/>
</div>
</template>
@@ -86,21 +84,25 @@ import type { TableInput } from '@core/type/baseTable'
import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { getPhysicalWithdrawColumns } from '../hooks/physicalWithdrawColumns'
import {
adjustPhysicalWithdraw,
approvePhysicalWithdraw,
getPhysicalWithdrawTable
} from '@/api/physicalWithdraw'
import { approvePhysicalWithdraw, getPhysicalWithdrawTable } from '@/api/physicalWithdraw'
import { updateSts } from '@/api/wallet'
import { message } from 'ant-design-vue'
import type { Withdraw } from '@/model/wallet'
import { status as physicalStatusMap } from '@/pages/menuPages/withdraw/information/hooks/status'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import PhysicalWithdrawAdjustModal from './PhysicalWithdrawAdjustModal.vue'
import {
physicalWithdrawApproveHelp,
physicalWithdrawPageIntro,
physicalWithdrawStatusHelp
} from '@/utils/adminHelpCopy'
const emits = defineEmits(['add'])
const [submit, reload] = useBaseTable()
const adjustingKey = ref('')
const approvingId = ref<number | null>(null)
const adjustVisible = ref(false)
const selectedWithdrawId = ref<number>()
const tblProps = computed(() => ({
columns: getPhysicalWithdrawColumns(),
@@ -108,7 +110,7 @@ const tblProps = computed(() => ({
}))
function canAdjust(record: Withdraw) {
return Number(record.sts) === 50
return Number(record.sts) === 50 && (record.transaction?.length ?? 0) > 0
}
function canApprove(record: Withdraw) {
@@ -126,39 +128,18 @@ function statusOptions(currentSts: number) {
)
}
function adjustKey(withdrawId?: number, transactionId?: number, direction?: string) {
return `${withdrawId ?? 0}-${transactionId ?? 0}-${direction ?? ''}`
}
function isAdjusting(withdrawId?: number, transactionId?: number, direction?: string) {
return adjustingKey.value === adjustKey(withdrawId, transactionId, direction)
}
function isApproving(withdrawId?: number) {
return approvingId.value === withdrawId
}
async function handleAdjust(
record: Withdraw,
tx: { id?: number },
direction: 'increase' | 'decrease'
) {
if (!record.id || !tx.id) return
const key = adjustKey(record.id, tx.id, direction)
adjustingKey.value = key
try {
await adjustPhysicalWithdraw(record.id, { transaction_id: tx.id, direction })
message.success('مقدار بروزرسانی شد')
await reload()
} catch (e) {
console.error(e)
} finally {
if (adjustingKey.value === key) adjustingKey.value = ''
}
function openAdjustModal(record: Withdraw) {
if (!record.id) return
selectedWithdrawId.value = record.id
adjustVisible.value = true
}
async function handleApprove(record: Withdraw) {
if (!record.id) return
if (!record.id || isApproving(record.id)) return
approvingId.value = record.id
try {
await approvePhysicalWithdraw(record.id, 51)
@@ -3,15 +3,25 @@ import { toJalali } from '@core/utils/useDataTime'
import CustomTag from '@/components/CustomTag.vue'
import type { Withdraw } from '@/model/wallet'
function formatLineQty(tx: { gold?: number; product?: { unit?: string; trade_by_quantity?: boolean } }, title: string): string {
const qty = Math.abs(Number(tx.gold ?? 0))
const unit = tx.product?.unit || 'گرم'
const decimals = tx.product?.trade_by_quantity ? 0 : 3
const formatted = qty.toLocaleString('fa-IR', {
minimumFractionDigits: 0,
maximumFractionDigits: decimals
})
return `${title}: ${formatted} ${unit}`
}
function formatProductsSummary(record: Withdraw): string {
const lines = record.transaction ?? []
if (!lines.length) return '-'
return lines
.map((tx) => {
const qty = Math.abs(Number(tx.gold ?? 0))
const title = String(tx.title ?? '').trim() || 'محصول'
return `${title}: ${qty}`
const title = String(tx.title ?? '').trim() || tx.product?.title || 'محصول'
return formatLineQty(tx, title)
})
.join(' | ')
}
@@ -87,7 +97,7 @@ export function getPhysicalWithdrawColumns(): BaseTableColumn[] {
title: 'عملیات',
dataIndex: 'action',
key: 'action',
width: 260,
width: 140,
slot: 'action',
filterable: false,
fixed: 'right'
@@ -61,6 +61,7 @@ async function loadCustomers() {
customers.value = (await getCustomersList()) ?? []
} catch (e) {
console.error(e)
message.error('خطا در بارگذاری لیست مشتریان')
}
}
@@ -75,6 +76,7 @@ function resetForm() {
function openCreate() {
resetForm()
void loadCustomers()
createVisible.value = true
}
@@ -21,7 +21,6 @@
<BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader>
<div class="flex items-center gap-2">
<AdminFieldHelp :text="pricingPageIntro" />
<BaseButton
type="primary"
size="small"
+36
View File
@@ -1,5 +1,7 @@
/** توضیحات ساده فارسی برای فرم‌های ادمین — قابل فهم بدون دانش فنی */
import { adminHelp } from '@/utils/adminHelpFormat'
export const bannersPageIntro =
'بنرها همان تصاویر اسلایدر بالای صفحهٔ اصلی اپ مشتری هستند. هر کارت = یک اسلاید. بعد از هر تغییر حتماً «ذخیره تغییرات» را بزنید؛ تا آن لحظه در اپ دیده نمی‌شود.'
@@ -65,3 +67,37 @@ export const discountExpiryHelp =
export const priceTriggersPageIntro =
'لیست هشدارهای قیمت مشتریان. می‌توانید هشدار جدید بسازید، ویرایش کنید، فعال/غیرفعال کنید یا حذف کنید. وقتی قیمت به هدف برسد، به مشتری پیامک یا ایمیل می‌رود و هشدار خودکار غیرفعال می‌شود.'
export const physicalWithdrawPageIntro =
'درخواست تحویل فیزیکی طلا یا محصول از کیف مشتری. درخواست‌های ثبت‌شده از اپ مشتری و همین‌جا توسط ادمین در این لیست دیده می‌شوند. با ثبت درخواست، مقدار از کیف مشتری کسر می‌شود. در وضعیت «در انتظار ارسال» با آیکون ویرایش می‌توانید مقدار را (حتی اعشاری برای آبشده) تنظیم کنید؛ با «تایید» به «ارسال شده» می‌رود. برای برداشت ریالی از منوی «برداشت و واریز» استفاده کنید.'
export const physicalWithdrawCustomerHelp =
'مشتری‌ای که می‌خواهید برایش تحویل فیزیکی ثبت کنید. بعد از انتخاب، لیست محصولات و آدرس‌های همان مشتری بارگذاری می‌شود.'
export const physicalWithdrawAddressHelp =
'آدرسی که محصول به آن ارسال می‌شود. مشتری آدرس‌ها را در اپ خود ثبت می‌کند؛ اگر آدرسی ندارد، ابتدا از پنل مشتریان یا از خود مشتری بخواهید آدرس اضافه کند.'
export const physicalWithdrawProductHelp =
'محصولی که مشتری می‌خواهد فیزیکی تحویل بگیرد. فقط محصولاتی با موجودی بیشتر از صفر نشان داده می‌شوند.'
export const physicalWithdrawAmountHelp = adminHelp(
'تعداد یا مقدار درخواستی همان واحدی است که در اپ مشتری نمایش داده می‌شود (مثلاً تعداد سکه یا گرم).',
'مقدار درخواستی ≤ موجودی کیف مشتری'
)
export const physicalWithdrawDecreaseHelp =
'یک واحد از مقدار این خط کم می‌کند و همان مقدار به کیف مشتری برمی‌گردد. فقط وقتی وضعیت «در انتظار ارسال» است.'
export const physicalWithdrawIncreaseHelp =
'یک واحد به مقدار این خط اضافه می‌کند و از کیف مشتری کسر می‌شود. فقط وقتی وضعیت «در انتظار ارسال» است و موجودی کافی باشد.'
export const physicalWithdrawAdjustValueHelp = adminHelp(
'مقدار نهایی برداشت را وارد کنید. برای آبشده و محصولات وزنی می‌توانید اعشار بزنید (مثلاً ۱.۲۵۰ گرم). برای سکه و محصولات تعدادی فقط عدد صحیح مجاز است.',
'مقدار نهایی ≤ موجودی کیف مشتری'
)
export const physicalWithdrawApproveHelp =
'درخواست را تایید می‌کند و وضعیت به «ارسال شده» تغییر می‌کند؛ یعنی آمادهٔ ارسال یا تحویل است.'
export const physicalWithdrawStatusHelp =
'پیگیری مراحل ارسال: در انتظار ارسال، ارسال شده، لغو شده، یا در انتظار تعیین مجدد قیمت (برای محصولاتی که وزن نهایی مشخص نیست).'
+40
View File
@@ -0,0 +1,40 @@
/** ساختار متن راهنما — توضیح + فرمول اختیاری در کادر جدا */
export type AdminHelpContent = {
description: string
formula?: string
}
const FORMULA_MARKER = '\n\n__FORMULA__\n'
export function adminHelp(description: string, formula?: string): string {
const body = description.trim()
if (!formula?.trim()) return body
return `${body}${FORMULA_MARKER}${formula.trim()}`
}
export function parseAdminHelp(input: string | AdminHelpContent | undefined | null): AdminHelpContent {
if (!input) return { description: '' }
if (typeof input !== 'string') {
return {
description: input.description?.trim() ?? '',
formula: input.formula?.trim() || undefined,
}
}
const marked = input.split(FORMULA_MARKER)
if (marked.length === 2) {
return {
description: marked[0].trim(),
formula: marked[1].trim() || undefined,
}
}
return { description: input.trim() }
}
export function adminHelpPlainText(content: AdminHelpContent): string {
const parts = [content.description].filter(Boolean)
if (content.formula) parts.push(content.formula)
return parts.join('\n\n')
}
+66 -11
View File
@@ -1,20 +1,69 @@
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'
function toAsciiDigits(value: string): string {
return value
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)))
}
/** پارس مبلغ محدودیت سفارش — ارقام فارسی/عربی و جداکننده هزارگان */
export function parseOrderLimitCashValue(value: unknown): number | undefined {
if (value === undefined || value === null || value === '') {
return undefined
}
const raw = toAsciiDigits(String(value).trim().replace(/[,\s٬]/g, ''))
if (raw === '') {
return undefined
}
const num = Number(raw)
if (!Number.isFinite(num) || num < 0) {
return undefined
}
return Math.trunc(num)
}
function orderLimitInputsPresent(form: {
min_order_cash?: number | string | null
max_order_cash?: number | string | null
}): boolean {
return (
(form.min_order_cash != null && form.min_order_cash !== '') ||
(form.max_order_cash != null && form.max_order_cash !== '')
)
}
export function validateProductOrderLimitForm(form: {
order_limit_enabled?: boolean
min_order_cash?: number | string | null
max_order_cash?: number | string | null
}): string | null {
if (!form.order_limit_enabled) return null
const enabled = !!form.order_limit_enabled || orderLimitInputsPresent(form)
if (!enabled) return null
const min = form.min_order_cash
const max = form.max_order_cash
const hasMin = min != null && min !== ''
const hasMax = max != null && max !== ''
const rawMin = form.min_order_cash
const rawMax = form.max_order_cash
const hasMinInput = rawMin != null && rawMin !== ''
const hasMaxInput = rawMax != null && rawMax !== ''
const min = parseOrderLimitCashValue(rawMin)
const max = parseOrderLimitCashValue(rawMax)
if (!hasMin && !hasMax) {
if (hasMinInput && min == null) {
return 'حداقل مبلغ سفارش نامعتبر است'
}
if (hasMaxInput && max == null) {
return 'حداکثر مبلغ سفارش نامعتبر است'
}
if (min == null && max == null) {
return 'با فعال بودن محدودیت مبلغ سفارش، حداقل یکی از حداقل یا حداکثر مبلغ باید وارد شود'
}
if (hasMin && hasMax && Number(min) > Number(max)) {
if (min != null && max != null && min > max) {
return 'حداقل مبلغ نمی‌تواند بیشتر از حداکثر باشد'
}
@@ -22,13 +71,19 @@ export function validateProductOrderLimitForm(form: {
}
export function normalizeProductOrderLimitForm<T extends Record<string, any>>(data: T): T {
data.order_limit_enabled = !!data.order_limit_enabled
if (!data.order_limit_enabled) {
const min = parseOrderLimitCashValue(data.min_order_cash)
const max = parseOrderLimitCashValue(data.max_order_cash)
const enabled = !!data.order_limit_enabled || min != null || max != null
data.order_limit_enabled = enabled
if (!enabled) {
data.min_order_cash = undefined
data.max_order_cash = undefined
} else {
data.min_order_cash = data.min_order_cash != null && data.min_order_cash !== '' ? data.min_order_cash : undefined
data.max_order_cash = data.max_order_cash != null && data.max_order_cash !== '' ? data.max_order_cash : undefined
data.min_order_cash = min
data.max_order_cash = max
}
return data
}