Add customers gold credit menu with request and active tabs.

Gate the submenu on enable_gold_credit and wire settings UI for SuperAdmin and tenant credit options.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 00:17:03 +03:30
co-authored by Cursor
parent 14d2bb603e
commit 084b449177
15 changed files with 1536 additions and 19 deletions
@@ -0,0 +1,199 @@
<template>
<BasicModal
v-model:visible="modalVisible"
title="جزئیات اعتبار طلایی"
width="52rem"
ok-text="بستن"
@ok="modalVisible = false"
>
<a-spin :spinning="loading">
<template v-if="credit">
<div class="flex flex-wrap gap-2 mb-4 text-sm">
<span v-if="displayName" class="bg-amber-50 text-amber-900 px-3 py-1.5 rounded-full font-medium">
{{ displayName }}
</span>
<span class="bg-amber-50 text-amber-800 px-3 py-1.5 rounded-full font-medium">
اعتبار: {{ credit.gold_amount }} گرم
</span>
<span class="bg-blue-50 text-blue-800 px-3 py-1.5 rounded-full font-medium">
{{ credit.months }} ماه
</span>
<span class="px-3 py-1.5 rounded-full font-medium" :class="statusClass(credit.status)">
{{ statusLabel(credit.status) }}
</span>
<span class="bg-slate-100 text-slate-700 px-3 py-1.5 rounded-full font-medium">
پرداخت‌شده: {{ Number(credit.paid_gold ?? 0).toFixed(4) }} گرم
</span>
<span
v-if="Number(credit.paid_rial) > 0"
class="bg-slate-100 text-slate-700 px-3 py-1.5 rounded-full font-medium"
>
{{ Number(credit.paid_rial).toLocaleString('fa-IR') }} ریال
</span>
<span
v-if="credit.has_check"
class="bg-emerald-50 text-emerald-800 px-3 py-1.5 rounded-full font-medium"
>
چک دریافت شد
</span>
<span
v-if="credit.has_contract"
class="bg-indigo-50 text-indigo-800 px-3 py-1.5 rounded-full font-medium"
>
قرارداد بسته شد
</span>
</div>
<div
v-if="credit.penalty_type"
class="mb-4 text-sm text-amber-900 bg-amber-50/80 border border-amber-100 rounded-xl px-3 py-2"
>
جریمه دیرکرد:
{{
credit.penalty_type === 'gold'
? `${credit.penalty_amount} گرم در روز`
: `${Number(credit.penalty_amount || 0).toLocaleString('fa-IR')} ریال در روز`
}}
· مهلت {{ credit.grace_hours ?? 24 }} ساعت
</div>
<div v-if="credit.notes" class="mb-4 text-sm text-gray-600 bg-gray-50 rounded-xl px-3 py-2">
<span class="font-medium text-gray-800">یادداشت: </span>{{ credit.notes }}
</div>
<div class="font-bold text-sm mb-2 text-gray-700">اقساط</div>
<a-table
:columns="detailColumns"
:data-source="installments"
:pagination="false"
size="small"
bordered
row-key="id"
>
<template #bodyCell="{ column, record: row }">
<template v-if="column.key === 'amount_soot'">
{{ row.amount_soot?.toLocaleString('fa-IR') }} سوت
</template>
<template v-else-if="column.key === 'due_date'">
{{ toJalaliNoTime(row.due_date) }}
</template>
<template v-else-if="column.key === 'status'">
<CustomTag
:textColor="instStatusStyle(row.status).textColor"
:bgColor="instStatusStyle(row.status).bgColor"
class="px-2"
>
{{ instStatusStyle(row.status).text }}
</CustomTag>
</template>
<template v-else-if="column.key === 'paid_at'">
{{ row.paid_at ? toJalaliNoTime(row.paid_at) : '—' }}
</template>
<template v-else-if="column.key === 'penalty'">
<span v-if="!row.penalty_days && !row.penalty_rial && !row.penalty_gold">—</span>
<span v-else class="text-xs text-red-700">
<span v-if="row.penalty_days">{{ row.penalty_days }} روز</span>
<span v-if="row.penalty_rial">
{{ row.penalty_days ? ' · ' : '' }}{{ Number(row.penalty_rial).toLocaleString('fa-IR') }} ریال
</span>
</span>
</template>
</template>
</a-table>
</template>
<div v-else-if="!loading" class="text-center text-gray-500 py-8">
اطلاعات اعتبار یافت نشد
</div>
</a-spin>
</BasicModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getCustomerGoldCreditInstallments, type GoldCredit, type GoldCreditInstallment } from '@/api/goldCredit'
import { toJalaliNoTime } from '@core/utils/useDataTime'
import CustomTag from '@/components/CustomTag.vue'
const props = defineProps<{
visible?: boolean
customerId?: number
customerName?: string
}>()
const emit = defineEmits<{ (e: 'update:visible', value: boolean): void }>()
const modalVisible = computed({
get: () => props.visible ?? false,
set: (value: boolean) => emit('update:visible', value),
})
const loading = ref(false)
const credit = ref<GoldCredit | null>(null)
const installments = ref<GoldCreditInstallment[]>([])
const displayName = computed(() => props.customerName ?? credit.value?.customer?.name ?? '')
const detailColumns = [
{ title: 'قسط', dataIndex: 'installment_number', key: 'installment_number', align: 'center' as const },
{ title: 'مبلغ (سوت)', dataIndex: 'amount_soot', key: 'amount_soot', align: 'center' as const },
{ title: 'سررسید', dataIndex: 'due_date', key: 'due_date', align: 'center' as const },
{ title: 'وضعیت', dataIndex: 'status', key: 'status', align: 'center' as const },
{ title: 'پرداخت', dataIndex: 'paid_at', key: 'paid_at', align: 'center' as const },
{ title: 'جریمه', dataIndex: 'penalty', key: 'penalty', align: 'center' as const },
]
const instStatusStyles: Record<string, { text: string; textColor: string; bgColor: string }> = {
pending: { text: 'در انتظار', textColor: '#c2410c', bgColor: '#ffedd5' },
paid: { text: 'پرداخت شده', textColor: '#15803d', bgColor: '#dcfce7' },
prepaid: { text: 'پیش‌پرداخت', textColor: '#1d4ed8', bgColor: '#dbeafe' },
overdue: { text: 'معوق', textColor: '#b91c1c', bgColor: '#fee2e2' },
partial: { text: 'ناقص', textColor: '#a16207', bgColor: '#fef9c3' },
}
function instStatusStyle(s: string) {
return (
instStatusStyles[s] ?? {
text: s,
textColor: '#374151',
bgColor: '#f3f4f6',
}
)
}
function statusLabel(s: string) {
const map: Record<string, string> = {
active: 'فعال',
completed: 'تسویه شده',
cancelled: 'لغو شده',
pending: 'در انتظار',
}
return map[s] ?? s
}
function statusClass(s: string) {
const map: Record<string, string> = {
active: 'bg-green-50 text-green-800',
completed: 'bg-blue-50 text-blue-800',
cancelled: 'bg-red-50 text-red-800',
pending: 'bg-orange-50 text-orange-800',
}
return map[s] ?? 'bg-gray-100 text-gray-600'
}
watch(
() => props.visible,
async (open) => {
if (!open || !props.customerId) return
loading.value = true
credit.value = null
installments.value = []
try {
const res = await getCustomerGoldCreditInstallments(props.customerId)
credit.value = res.credit
installments.value = res.rows ?? []
} finally {
loading.value = false
}
}
)
</script>
@@ -0,0 +1,99 @@
<template>
<div>
<div class="flex justify-between items-center mb-4 flex-wrap gap-2">
<div v-if="creditSummary" class="flex gap-2 flex-wrap text-sm">
<span class="bg-amber-50 text-amber-800 px-3 py-1 rounded-full font-medium">
اعتبار: {{ creditSummary.gold_amount }} گرم
</span>
<span class="bg-blue-50 text-blue-800 px-3 py-1 rounded-full font-medium">
{{ creditSummary.months }} ماه
</span>
<span
class="px-3 py-1 rounded-full font-medium"
:class="
creditSummary.status === 'active'
? 'bg-green-50 text-green-800'
: 'bg-gray-100 text-gray-600'
"
>
{{ statusLabel(creditSummary.status) }}
</span>
<span
v-if="creditSummary.has_check"
class="bg-emerald-50 text-emerald-800 px-3 py-1 rounded-full font-medium"
>
چک گرفته شد
</span>
<span
v-if="creditSummary.has_contract"
class="bg-indigo-50 text-indigo-800 px-3 py-1 rounded-full font-medium"
>
قرارداد بسته شد
</span>
<span
v-if="creditSummary.penalty_type"
class="bg-red-50 text-red-800 px-3 py-1 rounded-full font-medium"
>
جریمه:
{{
creditSummary.penalty_type === 'gold'
? `${creditSummary.penalty_amount} گرم/روز`
: `${Number(creditSummary.penalty_amount || 0).toLocaleString('fa-IR')} ریال/روز`
}}
· مهلت {{ creditSummary.grace_hours ?? 24 }}س
</span>
</div>
<BaseButton type="primary" size="small" class="flex mr-auto" @click="emits('assign')">
<div>تعیین اعتبار طلایی</div>
<BaseIcon icon="vuesax-linear:medal-star" Size="16px" />
</BaseButton>
</div>
<BasicShowContext title="لیست اقساط">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl" />
</BasicShowContext>
</div>
</template>
<script lang="tsx" setup>
import type { TableInput } from '@core/type/baseTable'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import { cols } from '@/pages/menuPages/customers/base/hooks/goldCreditColumns'
import { getCustomerGoldCreditInstallments, type GoldCredit } from '@/api/goldCredit'
const [submit, reload] = useBaseTable()
interface Prop {
customerId?: number
}
const emits = defineEmits(['assign'])
const props = defineProps<Prop>()
const creditSummary = ref<GoldCredit | null>(null)
const table = ref(null)
const tblProps = ref<any>({
columns: cols,
pagination: false,
api: async () => {
if (!props.customerId) return { rows: [], total: 0 }
const res = await getCustomerGoldCreditInstallments(props.customerId)
creditSummary.value = res.credit
return { rows: res.rows ?? [], total: res.rows?.length ?? 0 }
},
})
const statusLabel = (s: string) => {
const map: Record<string, string> = {
active: 'فعال',
completed: 'تسویه شده',
cancelled: 'لغو شده',
pending: 'در انتظار',
}
return map[s] ?? s
}
const fetch = () => reload()
defineExpose({ fetch })
</script>
@@ -0,0 +1,335 @@
<template>
<BasicModal
v-model:visible="modalVisible"
:title="modalTitle"
width="40rem"
:loading="loading"
:confirm-loading="loading"
:ok-text="props.mode === 'approve' ? 'تأیید و ایجاد اعتبار' : 'ثبت'"
@ok="submit"
@close="close"
>
<a-form :model="form" ref="formRef" autocomplete="off" layout="vertical">
<div class="grid md:grid-cols-2 gap-3">
<a-form-item label="مقدار طلا (گرم)" name="gold_amount" :rules="inputRules('مقدار طلا')">
<BasicInput v-model:value="form.gold_amount" type="number" @change="loadPreview" />
</a-form-item>
<a-form-item label="تعداد ماه" name="months" :rules="inputRules('تعداد ماه')">
<BasicInput v-model:value="form.months" type="number" @change="loadPreview" />
</a-form-item>
<a-form-item label="نوع سود" name="interest_type">
<BasicSelect
v-model:value="form.interest_type"
:items="interestTypes"
valueName="value"
keyName="key"
@change="loadPreview"
/>
</a-form-item>
<a-form-item
v-if="form.interest_type !== 'none'"
label="مقدار سود"
name="interest_value"
:rules="inputRules('مقدار سود')"
>
<BasicInput
v-model:value="form.interest_value"
type="number"
:addon-before="form.interest_type === 'percent' ? '%' : 'گرم'"
@change="loadPreview"
/>
</a-form-item>
<a-form-item label="روش تحویل" name="delivery_method" class="md:col-span-2">
<BasicSelect
v-model:value="form.delivery_method"
:items="deliveryMethods"
valueName="value"
keyName="key"
/>
</a-form-item>
</div>
<div class="mt-2 mb-1 font-bold text-sm text-gray-700">ضمانت</div>
<div class="grid md:grid-cols-2 gap-3 rounded-xl bg-slate-50 p-3 border border-slate-100">
<a-form-item label="چک ضمانت دریافت شد" name="has_check">
<a-switch v-model:checked="form.has_check" checked-children="بله" un-checked-children="خیر" />
</a-form-item>
<a-form-item label="قرارداد بسته شد" name="has_contract">
<a-switch
v-model:checked="form.has_contract"
checked-children="بله"
un-checked-children="خیر"
/>
</a-form-item>
<a-form-item label="جزئیات چک" name="check_note">
<BasicInput v-model:value="form.check_note" place-holder="شماره / بانک / ..." />
</a-form-item>
<a-form-item label="جزئیات قرارداد" name="contract_note">
<BasicInput v-model:value="form.contract_note" place-holder="شماره قرارداد / تاریخ" />
</a-form-item>
</div>
<div class="mt-3 mb-1 font-bold text-sm text-gray-700">جریمه دیرکرد (قابل تنظیم برای این اعتبار)</div>
<div class="grid md:grid-cols-3 gap-3 rounded-xl bg-amber-50/60 p-3 border border-amber-100">
<a-form-item label="نوع جریمه" name="penalty_type">
<BasicSelect
v-model:value="form.penalty_type"
:items="penaltyTypes"
valueName="value"
keyName="key"
/>
</a-form-item>
<a-form-item label="مبلغ روزانه" name="penalty_amount">
<BasicInput
v-model:value="form.penalty_amount"
type="number"
:addon-before="form.penalty_type === 'gold' ? 'گرم' : 'ریال'"
/>
</a-form-item>
<a-form-item label="مهلت بعد از سررسید (ساعت)" name="grace_hours">
<BasicInput v-model:value="form.grace_hours" type="number" />
</a-form-item>
<div class="md:col-span-3 text-xs text-amber-800/80">
پرداخت خودکار نیست. بعد از سررسید SMS هشدار می‌رود؛ اگر تا پایان مهلت پرداخت نشود، هر روز این
مبلغ به بدهی قسط اضافه می‌شود. مقادیر پیش‌فرض از پیکربندی سیستم خوانده می‌شود.
</div>
</div>
<a-form-item label="یادداشت" name="notes" class="mt-3">
<BasicInput v-model:value="form.notes" />
</a-form-item>
</a-form>
<div
v-if="preview.length"
class="mt-4 rounded-xl border border-amber-100 bg-gradient-to-l from-amber-50 to-white p-4"
>
<div class="font-bold text-amber-900 mb-3 flex items-center gap-2">
<BaseIcon icon="vuesax-linear:calendar" Size="18px" />
پیش‌نمایش اقساط
</div>
<div class="grid grid-cols-3 gap-2 text-center text-sm mb-2 font-medium text-gray-600">
<div>ماه</div>
<div>مبلغ (سوت)</div>
<div>جمع (گرم)</div>
</div>
<div
v-for="item in preview.slice(0, 6)"
:key="item.installment_number"
class="grid grid-cols-3 gap-2 text-center py-1.5 border-b border-amber-50 last:border-0"
>
<div class="font-medium">{{ item.installment_number }}</div>
<div class="text-amber-800 font-bold">{{ item.amount_soot?.toLocaleString('fa-IR') }}</div>
<div>{{ item.total_gold?.toFixed(4) }}</div>
</div>
<div v-if="preview.length > 6" class="text-center text-xs text-gray-500 mt-2">
و {{ preview.length - 6 }} قسط دیگر...
</div>
<div class="mt-3 pt-3 border-t border-amber-100 flex justify-between font-bold text-amber-900">
<span>مجموع قابل پرداخت:</span>
<span>{{ totalPayable?.toFixed(4) }} گرم</span>
</div>
</div>
</BasicModal>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { inputRules } from '@/utils/inputRules'
import type { FormInstance } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import type { Item } from '@/model/Item'
import {
createGoldCredit,
updateGoldCredit,
previewGoldCredit,
approveGoldCreditRequest,
type GoldCreditForm,
type GoldCreditInstallment,
} from '@/api/goldCredit'
const props = withDefaults(
defineProps<{
visible?: boolean
customerId?: number
editId?: number
requestId?: number
mode?: 'create' | 'edit' | 'approve'
initial?: Partial<GoldCreditForm>
}>(),
{
visible: false,
mode: 'create',
initial: () => ({}),
}
)
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'success'): void
(e: 'close'): void
}>()
const modalVisible = computed({
get: () => props.visible,
set: (value: boolean) => emit('update:visible', value),
})
const modalTitle = computed(() => {
if (props.mode === 'approve') return 'تأیید درخواست اعتبار'
if (props.editId) return 'ویرایش اعتبار طلایی'
return 'تعیین اعتبار طلایی'
})
const loading = ref(false)
const formRef = ref<FormInstance>()
const form = ref<GoldCreditForm>(defaultForm())
const preview = ref<GoldCreditInstallment[]>([])
const totalPayable = ref(0)
const interestTypes: Item[] = [
{ key: 'none', value: 'بدون سود' },
{ key: 'percent', value: 'درصد ماهانه' },
{ key: 'fixed_gram', value: 'گرم ثابت در ماه' },
]
const deliveryMethods: Item[] = [
{ key: 'auto', value: 'خودکار — شارژ کیف پول' },
{ key: 'manual', value: 'دستی' },
{ key: 'in_person', value: 'تحویل حضوری' },
]
const penaltyTypes: Item[] = [
{ key: 'rial', value: 'ریالی (روزانه)' },
{ key: 'gold', value: 'طلایی (گرم روزانه)' },
]
function defaultForm(): GoldCreditForm {
return {
interest_type: 'none',
interest_value: 0,
delivery_method: 'manual',
months: 12,
has_check: false,
has_contract: false,
penalty_type: 'rial',
penalty_amount: 1000000,
grace_hours: 24,
}
}
function resetForm() {
form.value = {
...defaultForm(),
customer_id: props.customerId,
...props.initial,
}
if (!form.value.months) {
form.value.months = 12
}
preview.value = []
totalPayable.value = 0
}
watch(
() => props.visible,
async (open) => {
if (!open) {
formRef.value?.resetFields()
return
}
resetForm()
await nextTick()
formRef.value?.clearValidate()
await loadPreview()
}
)
async function loadPreview() {
const gold = Number(form.value.gold_amount)
const months = Number(form.value.months)
if (!gold || !months) {
preview.value = []
totalPayable.value = 0
return
}
try {
const res = await previewGoldCredit({
...form.value,
gold_amount: gold,
months,
})
preview.value = res.installments ?? []
totalPayable.value = res.total_payable_gold ?? 0
} catch {
preview.value = []
totalPayable.value = 0
}
}
async function submit() {
try {
await formRef.value?.validateFields()
} catch {
message.warning('لطفاً فیلدهای الزامی را تکمیل کنید')
return Promise.reject()
}
if (props.mode === 'approve' && !props.requestId) {
message.error('شناسه درخواست یافت نشد')
return Promise.reject()
}
if (props.mode !== 'approve' && props.mode !== 'edit' && !props.customerId) {
message.error('شناسه مشتری یافت نشد')
return Promise.reject()
}
const gold = Number(form.value.gold_amount)
const months = Number(form.value.months)
if (!gold || gold <= 0) {
message.error('مقدار طلا باید بیشتر از صفر باشد')
return Promise.reject()
}
if (!months || months < 1) {
message.error('تعداد ماه باید حداقل ۱ باشد')
return Promise.reject()
}
loading.value = true
try {
const payload: GoldCreditForm = {
...form.value,
customer_id: props.customerId,
gold_amount: gold,
months,
interest_value: Number(form.value.interest_value ?? 0),
penalty_amount: Number(form.value.penalty_amount ?? 0),
grace_hours: Number(form.value.grace_hours ?? 24),
}
if (props.mode === 'approve' && props.requestId) {
await approveGoldCreditRequest(props.requestId, payload)
message.success('درخواست تأیید و اعتبار ایجاد شد')
} else if (props.editId) {
await updateGoldCredit(props.editId, payload)
message.success('اعتبار با موفقیت ویرایش شد')
} else {
await createGoldCredit(payload)
message.success('اعتبار طلایی با موفقیت ثبت شد')
}
modalVisible.value = false
emit('success')
} catch {
return Promise.reject()
} finally {
loading.value = false
}
}
function close() {
modalVisible.value = false
emit('close')
}
</script>
@@ -0,0 +1,164 @@
<template>
<BasicModal
v-model:visible="modalVisible"
title="تنظیمات اعتبار طلایی"
width="48rem"
ok-text="بستن"
@ok="modalVisible = false"
>
<a-spin :spinning="loading">
<div class="space-y-4">
<div class="text-sm text-gray-500 mb-2">
این تنظیمات در اپ کاربر اعمال می‌شوند. روش‌های پرداخت غیرفعال در اپ نمایش داده نمی‌شوند؛
ادمین همچنان می‌تواند از همه روش‌ها استفاده کند.
</div>
<div
v-for="row in rows"
:key="row.id ?? row.key"
class="rounded-xl border border-slate-100 bg-slate-50/60 p-3"
>
<div class="font-medium text-sm text-gray-800 mb-1">{{ row.title_fa || row.key }}</div>
<div v-if="row.description" class="text-xs text-gray-500 mb-2 whitespace-pre-wrap">
{{ displayDescription(row) }}
</div>
<SettingValueField
:model-value="drafts[row.id!] ?? ''"
:setting-key="row.key"
:setting-type="row.type"
:description="row.description"
:rows="row.key === 'gold_credit_tiers' ? 6 : 4"
:disabled="savingIds.has(row.id!)"
@update:value="onDraftChange(row, $event)"
/>
<div v-if="savedFlashIds.has(row.id!)" class="text-xs text-green-600 mt-1">ذخیره شد</div>
</div>
</div>
</a-spin>
</BasicModal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { getConfiguration, setConfiguration } from '@/api/configuration'
import type { Const } from '@/model/configuration'
import SettingValueField from '@/pages/menuPages/configuration/SettingValueField.vue'
import { formatValueForSave, resolveSettingFieldType } from '@/pages/menuPages/configuration/settingTypes'
const GOLD_CREDIT_KEYS = [
'gold_credit_pay_gold_enabled',
'gold_credit_pay_rial_enabled',
'gold_credit_pay_mixed_enabled',
'gold_credit_min_balance_gram',
'gold_credit_default_months',
'gold_credit_info_text',
'gold_credit_request_success_text',
'gold_credit_tiers',
'gold_credit_penalty_type',
'gold_credit_penalty_amount',
'gold_credit_grace_hours',
]
const props = defineProps<{ visible?: boolean }>()
const emit = defineEmits<{ (e: 'update:visible', value: boolean): void }>()
const modalVisible = computed({
get: () => props.visible ?? false,
set: (v: boolean) => emit('update:visible', v),
})
const loading = ref(false)
const rows = ref<Const[]>([])
const drafts = ref<Record<number, string>>({})
const savingIds = ref(new Set<number>())
const savedFlashIds = ref(new Set<number>())
const saveTimers = new Map<number, ReturnType<typeof setTimeout>>()
const debounceTimers = new Map<number, ReturnType<typeof setTimeout>>()
function displayDescription(row: Const) {
if (row.type === 'select' && row.description?.includes('|')) {
return row.description.split('|')[0]
}
return row.description
}
watch(
() => props.visible,
async (open) => {
if (!open) return
loading.value = true
try {
const all = (await getConfiguration()) ?? []
rows.value = all
.filter((r) => r.key && GOLD_CREDIT_KEYS.includes(r.key))
.sort(
(a, b) =>
GOLD_CREDIT_KEYS.indexOf(a.key!) - GOLD_CREDIT_KEYS.indexOf(b.key!)
)
const next: Record<number, string> = {}
for (const row of rows.value) {
if (row.id != null) next[row.id] = String(row.value ?? '')
}
drafts.value = next
} finally {
loading.value = false
}
}
)
function onDraftChange(row: Const, value: string) {
if (row.id == null) return
drafts.value[row.id] = value
scheduleSave(row)
}
function scheduleSave(row: Const) {
if (row.id == null) return
if (debounceTimers.has(row.id)) clearTimeout(debounceTimers.get(row.id))
debounceTimers.set(
row.id,
setTimeout(() => {
void saveRow(row)
}, 600)
)
}
async function saveRow(row: Const) {
if (row.id == null) return
const type = resolveSettingFieldType(row)
const raw = drafts.value[row.id] ?? row.value ?? ''
const value = formatValueForSave(raw, type)
if (value === (row.value ?? '')) return
savingIds.value.add(row.id)
savedFlashIds.value.delete(row.id)
try {
await setConfiguration({
id: row.id,
key: row.key,
value,
description: row.description ?? '',
is_active: row.is_active,
title_fa: row.title_fa ?? '',
type,
})
row.value = value
savedFlashIds.value.add(row.id)
if (saveTimers.has(row.id)) clearTimeout(saveTimers.get(row.id))
saveTimers.set(
row.id,
setTimeout(() => {
savedFlashIds.value.delete(row.id!)
savedFlashIds.value = new Set(savedFlashIds.value)
}, 2000)
)
} catch {
drafts.value[row.id] = String(row.value ?? '')
message.error(`ذخیره «${row.title_fa || row.key}» ناموفق بود`)
} finally {
savingIds.value.delete(row.id)
savingIds.value = new Set(savingIds.value)
}
}
</script>
@@ -0,0 +1,185 @@
import type { BaseTableColumn } from '@core/type/baseTable'
import CustomTag from '@/components/CustomTag.vue'
import { toJalaliNoTime } from '@core/utils/useDataTime'
const statusStyles: Record<string, { text: string; textColor: string; bgColor: string }> = {
pending: { text: 'در انتظار', textColor: '#c2410c', bgColor: '#ffedd5' },
paid: { text: 'پرداخت شده', textColor: '#15803d', bgColor: '#dcfce7' },
prepaid: { text: 'پیش‌پرداخت', textColor: '#1d4ed8', bgColor: '#dbeafe' },
overdue: { text: 'معوق', textColor: '#b91c1c', bgColor: '#fee2e2' },
partial: { text: 'ناقص', textColor: '#a16207', bgColor: '#fef9c3' },
}
export const cols: BaseTableColumn[] = [
{
title: 'ردیف',
dataIndex: 'installment_number',
key: 'installment_number',
filterable: false,
sortable: false,
},
{
title: 'مبلغ (سوت)',
dataIndex: 'amount_soot',
key: 'amount_soot',
type: 'Number',
customRender: (record: any) => `${record.record.amount_soot?.toLocaleString('fa-IR')} سوت`,
},
{
title: 'تاریخ سررسید',
dataIndex: 'due_date',
key: 'due_date',
customRender: (record: any) => toJalaliNoTime(record.record.due_date),
},
{
title: 'وضعیت',
dataIndex: 'status',
key: 'status',
customRender: (record: any) => {
const s = statusStyles[record.record.status] ?? {
text: record.record.status,
textColor: '#374151',
bgColor: '#f3f4f6',
}
return (
<CustomTag textColor={s.textColor} bgColor={s.bgColor} class="px-2">
{s.text}
</CustomTag>
)
},
},
{
title: 'تاریخ پرداخت',
dataIndex: 'paid_at',
key: 'paid_at',
customRender: (record: any) =>
record.record.paid_at ? toJalaliNoTime(record.record.paid_at) : '-',
},
{
title: 'جریمه',
dataIndex: 'penalty_days',
key: 'penalty_days',
customRender: (record: any) => {
const r = record.record
const days = r.penalty_days || 0
if (!days && !(r.penalty_rial > 0) && !(r.penalty_gold > 0)) return '-'
const parts: string[] = []
if (days) parts.push(`${days} روز`)
if (r.penalty_rial > 0) parts.push(`${Number(r.penalty_rial).toLocaleString('fa-IR')} ریال`)
if (r.penalty_gold > 0) parts.push(`${Number(r.penalty_gold).toFixed(4)} گرم`)
return parts.join(' / ')
},
},
]
export const creditCols: BaseTableColumn[] = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (record: any) => record.index + 1,
},
{
title: 'نام مشتری',
dataIndex: 'customer.name',
key: 'customer.name',
type: 'Text',
},
{
title: 'موبایل',
dataIndex: 'customer.mobile',
key: 'customer.mobile',
type: 'Text',
},
{
title: 'مقدار (گرم)',
dataIndex: 'gold_amount',
key: 'gold_amount',
type: 'Number',
},
{
title: 'تعداد ماه',
dataIndex: 'months',
key: 'months',
type: 'Number',
},
{
title: 'وضعیت',
dataIndex: 'status',
key: 'status',
customRender: (record: any) => {
const map: Record<string, string> = {
active: 'فعال',
completed: 'تسویه شده',
cancelled: 'لغو شده',
pending: 'در انتظار',
}
const styles: Record<string, { textColor: string; bgColor: string }> = {
active: { textColor: '#15803d', bgColor: '#dcfce7' },
completed: { textColor: '#1d4ed8', bgColor: '#dbeafe' },
cancelled: { textColor: '#b91c1c', bgColor: '#fee2e2' },
pending: { textColor: '#c2410c', bgColor: '#ffedd5' },
}
const st = record.record.status
const style = styles[st] ?? { textColor: '#374151', bgColor: '#f3f4f6' }
return (
<CustomTag textColor={style.textColor} bgColor={style.bgColor} class="px-2">
{map[st] ?? st}
</CustomTag>
)
},
},
]
export const requestCols: BaseTableColumn[] = [
{
title: 'ردیف',
dataIndex: 'id',
key: 'id',
filterable: false,
sortable: false,
customRender: (record: any) => record.index + 1,
},
{
title: 'نام مشتری',
dataIndex: 'customer.name',
key: 'customer.name',
type: 'Text',
},
{
title: 'موبایل',
dataIndex: 'customer.mobile',
key: 'customer.mobile',
type: 'Text',
},
{
title: 'مقدار درخواست (گرم)',
dataIndex: 'requested_gold',
key: 'requested_gold',
type: 'Number',
},
{
title: 'وضعیت',
dataIndex: 'status',
key: 'status',
customRender: (record: any) => {
const map: Record<string, { text: string; textColor: string; bgColor: string }> = {
pending: { text: 'در انتظار', textColor: '#c2410c', bgColor: '#ffedd5' },
approved: { text: 'تأیید شده', textColor: '#15803d', bgColor: '#dcfce7' },
rejected: { text: 'رد شده', textColor: '#b91c1c', bgColor: '#fee2e2' },
}
const s = map[record.record.status] ?? {
text: record.record.status,
textColor: '#374151',
bgColor: '#f3f4f6',
}
return (
<CustomTag textColor={s.textColor} bgColor={s.bgColor} class="px-2">
{s.text}
</CustomTag>
)
},
},
]
@@ -0,0 +1,175 @@
<template>
<div class="w-full h-full">
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #extraHeader>
<div class="flex gap-2">
<BaseButton type="default" size="small" class="flex" @click="settingsVisible = true">
<div>تنظیمات</div>
<BaseIcon icon="vuesax-linear:setting-2" Size="16px" />
</BaseButton>
<BaseButton type="primary" size="small" class="flex" @click="openCreate">
<div>اعطای اعتبار</div>
<BaseIcon icon="vuesax-linear:medal-star" Size="16px" />
</BaseButton>
</div>
</template>
<template #action="{ record }">
<div class="flex gap-3 justify-center items-center">
<a-tooltip placement="top">
<template #title>جزئیات</template>
<Icon
icon="vuesax-linear:eye"
color="#6366f1"
class="cursor-pointer"
@click.stop="openDetails(record)"
/>
</a-tooltip>
<a-tooltip v-if="record.status === 'active'" placement="top">
<template #title>ویرایش</template>
<Icon
icon="vuesax-linear:edit"
color="#4572E9"
class="cursor-pointer"
@click.stop="openEdit(record)"
/>
</a-tooltip>
</div>
</template>
</BaseTable>
<GoldCreditDetailModal
v-model:visible="detailVisible"
:customer-id="detailCustomerId"
:customer-name="detailCustomerName"
/>
<GoldCreditSettingsModal v-model:visible="settingsVisible" />
<GoldCreditModal
v-model:visible="modalVisible"
:mode="modalMode"
:edit-id="editRecord?.id"
:customer-id="createCustomerId ?? editRecord?.customer_id"
:initial="editInitial"
@success="reload"
/>
<BasicModal
v-model:visible="createPickerVisible"
title="اعطای اعتبار طلایی"
width="28rem"
ok-text="ادامه"
@ok="confirmCreateCustomer"
>
<a-form-item label="انتخاب مشتری">
<BasicSelect
v-model:value="createCustomerId"
:items="customerOptions"
valueName="value"
keyName="key"
placeholder="جستجو نام یا موبایل"
/>
</a-form-item>
</BasicModal>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import { creditCols } from '@/pages/menuPages/customers/base/hooks/goldCreditColumns'
import { getGoldCreditTable, type GoldCredit } from '@/api/goldCredit'
import GoldCreditModal from '@/pages/menuPages/customers/base/components/goldCreditModal.vue'
import GoldCreditDetailModal from '@/pages/menuPages/customers/base/components/goldCreditDetailModal.vue'
import GoldCreditSettingsModal from '@/pages/menuPages/customers/base/components/goldCreditSettingsModal.vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { getCustomersList } from '@/api/customer'
import { message } from 'ant-design-vue'
import type { Item } from '@/model/Item'
const [submit, reload] = useBaseTable()
const modalVisible = ref(false)
const modalMode = ref<'create' | 'edit'>('edit')
const detailVisible = ref(false)
const settingsVisible = ref(false)
const createPickerVisible = ref(false)
const detailCustomerId = ref<number>()
const detailCustomerName = ref<string>()
const createCustomerId = ref<number>()
const customerOptions = ref<Item[]>([])
const editRecord = ref<GoldCredit | null>(null)
const editInitial = ref<Record<string, unknown>>({})
const tblProps = ref({
columns: [
...creditCols,
{
title: 'عملیات',
key: 'action',
dataIndex: 'action',
slot: 'action',
width: 120,
filterable: false,
sortable: false,
},
],
api: (params: unknown) => getGoldCreditTable(params as never),
})
onMounted(async () => {
try {
const list = await getCustomersList()
customerOptions.value = (list ?? []).map((c) => ({
key: c.id,
value: `${c.name ?? ''} — ${c.mobile ?? ''}`,
}))
} catch {
customerOptions.value = []
}
})
function openDetails(record: GoldCredit) {
detailCustomerId.value = record.customer_id
detailCustomerName.value = record.customer?.name
detailVisible.value = true
}
function openCreate() {
createCustomerId.value = undefined
createPickerVisible.value = true
}
function confirmCreateCustomer() {
if (!createCustomerId.value) {
message.warning('مشتری را انتخاب کنید')
return Promise.reject()
}
createPickerVisible.value = false
editRecord.value = null
editInitial.value = { months: 12 }
modalMode.value = 'create'
modalVisible.value = true
}
function openEdit(record: GoldCredit) {
createCustomerId.value = undefined
editRecord.value = record
modalMode.value = 'edit'
editInitial.value = {
gold_amount: record.gold_amount,
months: record.months,
interest_type: record.interest_type,
interest_value: record.interest_value,
delivery_method: record.delivery_method,
notes: record.notes,
has_check: record.has_check,
has_contract: record.has_contract,
check_note: record.check_note,
contract_note: record.contract_note,
penalty_type: record.penalty_type,
penalty_amount: record.penalty_amount,
grace_hours: record.grace_hours,
}
modalVisible.value = true
}
</script>
@@ -0,0 +1,29 @@
<template>
<div class="h-full min-h-0 w-full">
<BasicShowContext title="اعتبار">
<a-tabs v-model:activeKey="activeTab" class="gold-credit-tabs h-full" destroy-inactive-tab-pane>
<a-tab-pane key="requests" tab="درخواست‌ها">
<GoldCreditRequestsTab />
</a-tab-pane>
<a-tab-pane key="active" tab="اعتبارهای جاری">
<GoldCreditActiveTab />
</a-tab-pane>
</a-tabs>
</BasicShowContext>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import GoldCreditRequestsTab from './requests/index.vue'
import GoldCreditActiveTab from './active/index.vue'
const activeTab = ref('requests')
</script>
<style scoped>
.gold-credit-tabs :deep(.ant-tabs-content),
.gold-credit-tabs :deep(.ant-tabs-tabpane) {
height: 100%;
}
</style>
@@ -0,0 +1,108 @@
<template>
<div class="w-full h-full">
<BaseTable v-bind="tblProps" @register="registerTable" ref="tbl">
<template #action="{ record }">
<div v-if="record.status === 'pending'" class="flex gap-2 justify-center">
<BaseButton type="primary" size="small" @click.stop="openApprove(record)">
تأیید
</BaseButton>
<BaseButton size="small" danger @click.stop="openReject(record)">رد</BaseButton>
</div>
<span v-else class="text-gray-400 text-xs">—</span>
</template>
</BaseTable>
<GoldCreditModal
v-model:visible="modalVisible"
mode="approve"
:request-id="selectedRequestId"
:customer-id="selectedCustomerId"
:initial="modalInitial"
@success="reload"
/>
<BasicModal
v-model:visible="rejectVisible"
title="رد درخواست"
width="24rem"
:confirm-loading="loading"
@ok="confirmReject"
>
<a-form-item label="دلیل رد">
<BasicInput v-model:value="rejectNote" />
</a-form-item>
</BasicModal>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import { requestCols } from '@/pages/menuPages/customers/base/hooks/goldCreditColumns'
import {
getGoldCreditRequestsTable,
rejectGoldCreditRequest,
type GoldCreditRequest,
} from '@/api/goldCredit'
import GoldCreditModal from '@/pages/menuPages/customers/base/components/goldCreditModal.vue'
import { message } from 'ant-design-vue'
const [registerTable, reload] = useBaseTable()
const loading = ref(false)
const modalVisible = ref(false)
const rejectVisible = ref(false)
const selectedRequestId = ref<number>()
const selectedCustomerId = ref<number>()
const rejectNote = ref('')
const modalInitial = ref<Record<string, unknown>>({})
const tblProps = ref({
columns: [
...requestCols,
{
title: 'عملیات',
key: 'action',
dataIndex: 'action',
slot: 'action',
width: 180,
filterable: false,
sortable: false,
},
],
api: (params: unknown) => getGoldCreditRequestsTable(params as never),
})
async function openApprove(record: GoldCreditRequest) {
selectedRequestId.value = record.id
selectedCustomerId.value = record.customer_id
modalInitial.value = {
gold_amount: record.requested_gold,
months: record.requested_months ?? 12,
}
await nextTick()
modalVisible.value = true
}
function openReject(record: GoldCreditRequest) {
selectedRequestId.value = record.id
rejectNote.value = ''
rejectVisible.value = true
}
async function confirmReject() {
if (!selectedRequestId.value) {
return Promise.reject()
}
loading.value = true
try {
await rejectGoldCreditRequest(selectedRequestId.value, rejectNote.value)
message.success('درخواست رد شد')
rejectVisible.value = false
reload()
} catch {
return Promise.reject()
} finally {
loading.value = false
}
}
</script>
+6 -5
View File
@@ -21,7 +21,7 @@ import { computed } from 'vue'
import BasicMenu from '@/components/BasicMenu/index.vue'
import { useRouterStore } from '@core/stores/router'
import { get } from 'lodash'
import { customerSpecialPricingEnabledSetting, initWalletSettingsFromApi } from '@/utils/config'
import { customerSpecialPricingEnabledSetting, goldCreditEnabledSetting, initWalletSettingsFromApi } from '@/utils/config'
initWalletSettingsFromApi()
@@ -33,10 +33,11 @@ const menu = computed(() => {
meta?: { order?: number }
}>
return [...children]
.filter(
(child) =>
child.name !== 'customerMain_specialPrice' || customerSpecialPricingEnabledSetting.value
)
.filter((child) => {
if (child.name === 'customerMain_specialPrice') return customerSpecialPricingEnabledSetting.value
if (child.name === 'customerMain_goldCredit') return goldCreditEnabledSetting.value
return true
})
.sort((a, b) => (a.meta?.order ?? 99) - (b.meta?.order ?? 99))
})
</script>