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
+111
View File
@@ -0,0 +1,111 @@
import useAxios from '@core/utils/useAxios'
import type { TableInput } from '@core/type/baseTable'
export interface GoldCreditForm {
customer_id?: number
gold_amount?: number
months?: number
interest_type?: 'none' | 'percent' | 'fixed_gram'
interest_value?: number
delivery_method?: 'auto' | 'manual' | 'in_person'
notes?: string
has_check?: boolean
has_contract?: boolean
check_note?: string
contract_note?: string
penalty_type?: 'rial' | 'gold'
penalty_amount?: number
grace_hours?: number
}
export interface GoldCreditInstallment {
id: number
installment_number: number
due_date: string
amount_soot: number
principal_gold: number
interest_gold: number
total_gold: number
paid_gold: number
paid_rial: number
status: 'pending' | 'paid' | 'partial' | 'overdue' | 'prepaid'
paid_at?: string
penalty_days?: number
penalty_rial?: number
penalty_gold?: number
}
export interface GoldCredit {
id: number
customer_id: number
gold_amount: number
months: number
interest_type: 'none' | 'percent' | 'fixed_gram'
interest_value: number
delivery_method: 'auto' | 'manual' | 'in_person'
status: string
source: string
total_interest_gold: number
total_payable_gold: number
paid_gold: number
paid_rial: number
notes?: string
has_check?: boolean
has_contract?: boolean
check_note?: string
contract_note?: string
penalty_type?: 'rial' | 'gold'
penalty_amount?: number
grace_hours?: number
customer?: { id: number; name: string; mobile: string }
installments?: GoldCreditInstallment[]
}
export interface GoldCreditRequest {
id: number
customer_id: number
requested_gold: number
requested_months?: number
note?: string
status: 'pending' | 'approved' | 'rejected'
admin_note?: string
customer?: { id: number; name: string; mobile: string }
}
export const createGoldCredit = async (data: GoldCreditForm) =>
await useAxios.post<GoldCredit>('/admin/gold_credit', data)
export const updateGoldCredit = async (id: number, data: Partial<GoldCreditForm> & { status?: string }) =>
await useAxios.put<GoldCredit>(`/admin/gold_credit/${id}`, data)
export const previewGoldCredit = async (data: GoldCreditForm) =>
await useAxios.post<{ installments: GoldCreditInstallment[]; total_payable_gold: number }>(
'/admin/gold_credit/preview',
data
)
export const getGoldCreditTable = async (params: TableInput) =>
await useAxios.post('/admin/gold_credit/table', params)
export const getGoldCreditRequestsTable = async (params: TableInput) =>
await useAxios.post('/admin/gold_credit/requests/table', params)
export const getCustomerGoldCreditInstallments = async (customerId: number) =>
await useAxios.post<{ rows: GoldCreditInstallment[]; credit: GoldCredit | null }>(
`/admin/customers/${customerId}/gold_credit/installments`,
{}
)
export const approveGoldCreditRequest = async (id: number, data: GoldCreditForm) =>
await useAxios.post<GoldCredit>(`/admin/gold_credit/requests/${id}/approve`, data)
export const rejectGoldCreditRequest = async (id: number, adminNote?: string) =>
await useAxios.post(`/admin/gold_credit/requests/${id}/reject`, { admin_note: adminNote })
export const payGoldCreditInstallments = async (data: {
gold_credit_id: number
installment_ids: number[]
pay_method: 'gold' | 'rial' | 'mixed'
gold_amount?: number
rial_amount?: number
}) => await useAxios.post('/admin/gold_credit/installments/pay', data)
+45 -10
View File
@@ -209,7 +209,7 @@ import SettingValueField from './SettingValueField.vue'
import SettingCard from './SettingCard.vue'
import { refreshAppModeMenu } from '@/router'
import { useUserStore } from '@/store/userStore'
import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyAutomationEnabledFromRows, applyProductFundsEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, applyPriceChannelAccessFromRows, applySupportChatMenuEnabledFromRows, applyGroupSmsFromRows, applyCustomerSpecialPricingFromRows, applyCustomerDirectSmsFromRows, applyCustomerLevelProfitEnabledFromRows, applySuperAdminAccessFlagsFromItems, invalidateSettingsBootstrap, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setAutomationEnabledFromSettings, setProductFundsEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setPriceChannelAccessFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings, setGroupSmsEnabledFromSettings, setCustomerSpecialPricingEnabledFromSettings } from '@/utils/config'
import { applyBannerEnabledFromRows, applyTrackingSystemsEnabledFromRows, applyConditionalOrderEnabledFromRows, applyAutomationEnabledFromRows, applyProductFundsEnabledFromRows, applyPriceTriggerEnabledFromRows, applyAppNotificationEnabledFromRows, applyPriceProviderFromRows, applyPriceChannelAccessFromRows, applySupportChatMenuEnabledFromRows, applyGroupSmsFromRows, applyCustomerSpecialPricingFromRows, applyGoldCreditEnabledFromRows, applyCustomerDirectSmsFromRows, applyCustomerLevelProfitEnabledFromRows, applySuperAdminAccessFlagsFromItems, invalidateSettingsBootstrap, setBannerEnabledFromSettings, setConditionalOrderEnabledFromSettings, setAutomationEnabledFromSettings, setProductFundsEnabledFromSettings, setPriceTriggerEnabledFromSettings, setAppNotificationEnabledFromSettings, setPriceProviderFromSettings, setPriceChannelAccessFromSettings, setStoreModeFromSettings, setTrackingSystemsEnabledFromSettings, setGroupSmsEnabledFromSettings, setCustomerSpecialPricingEnabledFromSettings, setGoldCreditEnabledFromSettings } from '@/utils/config'
import {
EDITABLE_SETTING_GROUPS,
SYSTEM_SETTING_GROUPS,
@@ -498,13 +498,12 @@ async function pullSuperAdminAndApplyFlags(): Promise<{
ok: boolean
message?: string
count?: number
meta?: { channel?: string; origin?: string; domain?: string }
items?: Record<string, string>
}> {
invalidateSettingsBootstrap()
const res = await refreshSuperAdminSettings()
if (res?.items && typeof res.items === 'object') {
applySuperAdminAccessFlagsFromItems(res.items)
}
// get_settings کامل — فلگ منو و کارت‌ها با مقدار زنده هم‌تراز شوند
// get_settings کامل — فلگ‌های غیرسوپرادمین و قیمت
try {
const { getConfiguration } = await import('@/api/configuration')
const all = await getConfiguration()
@@ -513,23 +512,50 @@ async function pullSuperAdminAndApplyFlags(): Promise<{
applyPriceProviderFromRows(rows)
applyPriceChannelAccessFromRows(rows)
} catch {
/* فلگ از items می‌ماند */
/* در ادامه از items همگام استفاده می‌شود */
}
// items پاسخ refresh منبع حقیقت است — آخر اعمال شود تا get_settings کهنه/دامنهٔ اشتباه فلگ را خاموش نکند
if (res?.items && typeof res.items === 'object') {
applySuperAdminAccessFlagsFromItems(res.items)
}
return {
ok: res?.ok !== false,
message: res?.message,
count: res?.count
count: res?.count,
meta: res?.meta,
items: res?.items
}
}
function syncSuccessMessage(res: {
message?: string
count?: number
meta?: { channel?: string; origin?: string; domain?: string }
items?: Record<string, string>
}): string {
const base = res?.message || `همگام شد (${res?.count ?? 0} کلید دسترسی از سوپرادمین)`
const domain = String(res?.meta?.domain || '').trim()
const csp = res?.items && typeof res.items === 'object' && !Array.isArray(res.items)
? String((res.items as Record<string, string>).customer_special_pricing ?? '')
: ''
const bits = [base]
if (domain) bits.push(`دامنه: ${domain}`)
if (csp !== '') bits.push(`قیمت ویژه: ${csp}`)
return bits.join(' — ')
}
async function refreshFromSuperAdmin() {
if (refreshingSuperAdmin.value) return
refreshingSuperAdmin.value = true
try {
const res = await pullSuperAdminAndApplyFlags()
await initConfig()
// دوباره items را بعد از initConfig (که ممکن است get_settings ناقص بخواند) اعمال کن
if (res?.items && typeof res.items === 'object') {
applySuperAdminAccessFlagsFromItems(res.items)
}
refreshMenuAfterStoreChange()
message.success(res?.message || `همگام شد (${res?.count ?? 0} کلید دسترسی از سوپرادمین)`)
message.success(syncSuccessMessage(res))
} catch (e: any) {
console.error(e)
const msg = e?.response?.data?.message || e?.message || 'همگام‌سازی از سوپرادمین ناموفق بود'
@@ -546,8 +572,11 @@ async function onRefreshClick() {
try {
const res = await pullSuperAdminAndApplyFlags()
await initConfig()
if (res?.items && typeof res.items === 'object') {
applySuperAdminAccessFlagsFromItems(res.items)
}
refreshMenuAfterStoreChange()
message.success(res?.message || 'بروزرسانی شد')
message.success(syncSuccessMessage(res))
} catch (e: any) {
console.error(e)
const msg = e?.response?.data?.message || e?.message || 'همگام‌سازی از سوپرادمین ناموفق بود'
@@ -580,6 +609,7 @@ async function initConfig() {
applySupportChatMenuEnabledFromRows(baseData.value)
applyGroupSmsFromRows(baseData.value)
applyCustomerSpecialPricingFromRows(baseData.value)
applyGoldCreditEnabledFromRows(baseData.value)
// صفحهٔ عادی price_provider ندارد (can_edit=0) — همیشه از get_settings کامل بخوان
if (
!isSystemMode.value ||
@@ -587,7 +617,8 @@ async function initConfig() {
!baseData.value.some((row) => row.key === 'show_to_users_chat') ||
!baseData.value.some((row) => row.key === 'price_channel_access') ||
!baseData.value.some((row) => row.key === 'group_sms') ||
!baseData.value.some((row) => row.key === 'customer_special_pricing')
!baseData.value.some((row) => row.key === 'customer_special_pricing') ||
!baseData.value.some((row) => row.key === 'enable_gold_credit')
) {
try {
const { getConfiguration } = await import('@/api/configuration')
@@ -598,6 +629,7 @@ async function initConfig() {
applyPriceChannelAccessFromRows(rows)
applyGroupSmsFromRows(rows)
applyCustomerSpecialPricingFromRows(rows)
applyGoldCreditEnabledFromRows(rows)
} catch {
/* کش قبلی می‌ماند */
}
@@ -677,6 +709,9 @@ function syncMenuFlagsFromSetting(key: string | undefined, value: string) {
if (key === 'customer_special_pricing') {
setCustomerSpecialPricingEnabledFromSettings(isSwitchOn(value))
}
if (key === 'enable_gold_credit') {
setGoldCreditEnabledFromSettings(isSwitchOn(value))
}
}
function onValueChange(row: Const, value: string) {
@@ -45,6 +45,12 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [
icon: 'vuesax-linear:lock',
match: (k) => /^(min_|minimum_|balance_report_exclude)/.test(k)
},
{
id: 'gold_credit',
title: 'اعتبار طلایی',
icon: 'vuesax-linear:medal-star',
match: (k) => k.startsWith('gold_credit_')
},
{
id: 'price_source',
title: 'منبع قیمت و تابلو',
@@ -152,7 +158,7 @@ export const SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [
icon: 'vuesax-linear:people',
// فقط فلگ‌های مالک سوپرادمین — banner/tracking/نمایش محصول در «تنظیمات» عادی‌اند
match: (k) =>
/^(enable_automation|enable_conditional_order|enable_product_funds|has_page_price_trigger|has_page_notification|has_page_gift_card|enable_customer_level_profit|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|customer_special_pricing|permision__enabled|show_to_users_chat)$/.test(
/^(enable_automation|enable_conditional_order|enable_product_funds|has_page_price_trigger|has_page_notification|has_page_gift_card|enable_customer_level_profit|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|customer_special_pricing|enable_gold_credit|permision__enabled|show_to_users_chat)$/.test(
k
)
},
@@ -194,6 +194,7 @@ export function isCodeManagedSettingKey(key?: string | null): boolean {
export const SUPERADMIN_OWNED_SETTING_KEYS = new Set([
'customer_direct_sms',
'customer_special_pricing',
'enable_gold_credit',
'enable_automation',
'enable_conditional_order',
'enable_customer_level_profit',
@@ -366,6 +367,22 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
sms_notif_support_admin: 'switch',
group_sms: 'switch',
customer_special_pricing: 'switch',
enable_gold_credit: 'switch',
gold_credit_overdue_sms_enabled: 'switch',
gold_credit_admin_sms_enabled: 'switch',
gold_credit_upcoming_sms_enabled: 'switch',
gold_credit_pay_gold_enabled: 'switch',
gold_credit_pay_rial_enabled: 'switch',
gold_credit_pay_mixed_enabled: 'switch',
gold_credit_penalty_amount: 'number',
gold_credit_grace_hours: 'number',
gold_credit_default_months: 'number',
gold_credit_min_balance_gram: 'number',
gold_credit_overdue_sms_text: 'longtext',
gold_credit_info_text: 'longtext',
gold_credit_request_success_text: 'longtext',
gold_credit_tiers: 'longtext',
gold_credit_penalty_type: 'select',
montzar_closed: 'switch',
montzar_open_buy: 'switch',
montzar_open_sell: 'switch',
@@ -443,6 +460,10 @@ const SELECT_LABELS_BY_KEY: Record<string, Record<string, string>> = {
tahesab: 'ته‌حساب',
zarin: 'زرین'
},
gold_credit_penalty_type: {
rial: 'ریالی',
gold: 'طلایی',
},
support_messenger: {
bale: 'بله',
telegram: 'تلگرام',
@@ -462,6 +483,7 @@ const SELECT_LABELS_BY_KEY: Record<string, Record<string, string>> = {
const SELECT_OPTIONS_BY_KEY: Record<string, string> = {
accounting_provider: 'none,pargar,tahesab,zarin',
gold_credit_penalty_type: 'rial,gold',
price_provider: 'montzar,zaarbox,main_product',
payping_gateway_name: 'sepal,',
external_payment_gateway: 'zarinpall,zibal,payping,sepal',
@@ -883,6 +905,8 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
'روشن: وقتی مشتری اولین پیام خوانده‌نشده را در چت پشتیبانی می‌فرستد و ادمین همان گفتگو را باز و visible ندارد، پیامک به ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: اعلان پیامک چت نمی‌رود.',
group_sms:
'روشن: دکمه ارسال پیامک گروهی در پنل ادمین فعال است (ارسال ۱۰تایی). خاموش: ارسال پیامک گروهی غیرفعال است. فقط از تنظیمات پیشرفته.',
enable_gold_credit:
'روشن: زیرمنوی اعتبار در مشتریان و صفحه اعتبار در اپ مشتری فعال می‌شود. خاموش: غیرفعال. فقط از سوپرادمین قابل تغییر است.',
customer_special_pricing:
'روشن: زیرمنوی قیمت ویژه در مشتریان و اعمال سود خرید/فروش پکیج روی مظنه و سفارش برای کاربران انتخاب‌شده. خاموش: غیرفعال. فقط از دیپلوی قابل تغییر است.',
profit_gold_api_enabled:
@@ -1049,6 +1073,22 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
open_register: 'ثبت‌نام باز',
sms_panel: 'پنل پیامک (کیف پول)',
customer_direct_sms: 'پیامک تکی به مشتری از لیست',
enable_gold_credit: 'اعتبار طلایی',
gold_credit_penalty_type: 'نوع جریمه اعتبار طلایی',
gold_credit_penalty_amount: 'مبلغ جریمه روزانه اعتبار طلایی',
gold_credit_grace_hours: 'مهلت پرداخت بعد از سررسید (ساعت)',
gold_credit_default_months: 'تعداد ماه پیش‌فرض اعتبار طلایی',
gold_credit_min_balance_gram: 'حداقل موجودی طلا برای درخواست (گرم)',
gold_credit_overdue_sms_enabled: 'SMS هشدار سررسید اعتبار طلایی',
gold_credit_admin_sms_enabled: 'پیامک ادمین اعتبار طلایی',
gold_credit_upcoming_sms_enabled: 'یادآوری سررسید قسط اعتبار',
gold_credit_pay_gold_enabled: 'پرداخت اقساط با طلا',
gold_credit_pay_rial_enabled: 'پرداخت اقساط با ریال',
gold_credit_pay_mixed_enabled: 'پرداخت اقساط ترکیبی',
gold_credit_overdue_sms_text: 'متن SMS سررسید اعتبار طلایی',
gold_credit_info_text: 'متن راهنمای اعتبار طلایی',
gold_credit_request_success_text: 'متن پس از ثبت درخواست اعتبار',
gold_credit_tiers: 'سطوح اعتبار طلایی (JSON)',
customer_special_pricing: 'قیمت ویژه مشتری',
charge_notif: 'پیامک شارژ ادمین',
sms_notif_order_admin: 'پیامک خرید/فروش به ادمین',
@@ -1157,6 +1197,7 @@ export const SETTING_LOCKED_KEYS = new Set([
'referral_bonus_amount',
'group_sms',
'customer_special_pricing',
'enable_gold_credit',
'customer_direct_sms',
'enable_automation',
'enable_conditional_order',
@@ -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>
+10
View File
@@ -80,6 +80,16 @@ const Customers: RouteRecordRaw = {
title: 'قیمت ویژه',
order: 4
}
},
{
name: 'customerMain_goldCredit',
path: 'gold-credit',
component: () => import('@/pages/menuPages/customers/goldCredit/index.vue'),
meta: {
icon: 'vuesax-linear:medal-star',
title: 'اعتبار',
order: 5
}
}
]
}
+22 -3
View File
@@ -64,6 +64,9 @@ export const groupSmsEnabledSetting = ref(false)
/** reactive — زیرمنوی قیمت ویژه مشتری */
export const customerSpecialPricingEnabledSetting = ref(false)
/** reactive — زیرمنوی اعتبار طلایی در مشتریان */
export const goldCreditEnabledSetting = ref(false)
/** reactive — منو و زنگوله اعلان داخل اپ */
export const appNotificationEnabledSetting = ref(true)
@@ -87,8 +90,11 @@ let pargarEnabledFromSettings: boolean | null = null
let customerDirectSmsFromSettings: boolean | null = null
let accountingProviderFromSettings: AccountingProvider | null = null
function isSwitchOn(value: string | undefined | null): boolean {
const v = (value ?? '').trim().toLowerCase()
function isSwitchOn(value: string | number | boolean | undefined | null): boolean {
if (value === true || value === 1) return true
const v = String(value ?? '')
.trim()
.toLowerCase()
return v === 'true' || v === '1' || v === 'yes' || v === 'activate'
}
@@ -166,6 +172,10 @@ export function setCustomerSpecialPricingEnabledFromSettings(value: boolean): vo
customerSpecialPricingEnabledSetting.value = value
}
export function setGoldCreditEnabledFromSettings(value: boolean): void {
goldCreditEnabledSetting.value = value
}
export function setConditionalOrderEnabledFromSettings(value: boolean): void {
conditionalOrderEnabledSetting.value = value
}
@@ -283,13 +293,20 @@ export function applyCustomerSpecialPricingFromRows(
if (row) setCustomerSpecialPricingEnabledFromSettings(isSwitchOn(row.value))
}
export function applyGoldCreditEnabledFromRows(
rows: Array<{ key?: string; value?: string | null }>
) {
const row = rows.find((item) => item.key === 'enable_gold_credit')
if (row) setGoldCreditEnabledFromSettings(isSwitchOn(row.value))
}
/** بعد از همگام‌سازی سوپرادمین — bootstrap بعدی دوباره از API می‌خواند */
export function invalidateSettingsBootstrap(): void {
settingsBootstrapped = false
}
/**
* اعمال مستقیم ۱۴ دسترسی سوپرادمین روی فلگ‌های منو/UI
* اعمال مستقیم ۱۵ دسترسی سوپرادمین روی فلگ‌های منو/UI
* (پاسخ refresh-superadmin.items یا ردیف‌های get_settings).
*/
export function applySuperAdminAccessFlagsFromItems(
@@ -301,6 +318,7 @@ export function applySuperAdminAccessFlagsFromItems(
applyCustomerDirectSmsFromRows(rows)
applyGroupSmsFromRows(rows)
applyCustomerSpecialPricingFromRows(rows)
applyGoldCreditEnabledFromRows(rows)
applyConditionalOrderEnabledFromRows(rows)
applyAutomationEnabledFromRows(rows)
applyProductFundsEnabledFromRows(rows)
@@ -386,6 +404,7 @@ async function bootstrapSettingsFromApi(): Promise<void> {
applyCustomerDirectSmsFromRows(list)
applyGroupSmsFromRows(list)
applyCustomerSpecialPricingFromRows(list)
applyGoldCreditEnabledFromRows(list)
applyConditionalOrderEnabledFromRows(list)
applyAutomationEnabledFromRows(list)
applyProductFundsEnabledFromRows(list)