970 lines
28 KiB
Vue
970 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { Modal, message } from 'ant-design-vue'
|
||
import { ArrowRightOutlined } from '@ant-design/icons-vue'
|
||
import type { TableColumnsType } from 'ant-design-vue'
|
||
import api from '@/api/http'
|
||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
|
||
|
||
type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'
|
||
|
||
type SmsSummary = {
|
||
balanceRials: number
|
||
balanceLabel: string
|
||
balanceLevel: BalanceLevel
|
||
perPartRials: number
|
||
todayMessages: number
|
||
todayParts: number
|
||
todayCostRials: number
|
||
monthMessages: number
|
||
monthParts: number
|
||
monthCostRials: number
|
||
}
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const charging = ref(false)
|
||
const blockLoading = ref(false)
|
||
const chargeInfoSaving = ref(false)
|
||
const reportLoading = ref(false)
|
||
const chargeReportLoading = ref(false)
|
||
const logsLoading = ref(false)
|
||
const ledgerLoading = ref(false)
|
||
|
||
const tenant = ref<{
|
||
id: string
|
||
product: 'hamkar' | 'khanegi'
|
||
slug: string
|
||
nameFa: string
|
||
domain: string
|
||
} | null>(null)
|
||
const summary = ref<SmsSummary | null>(null)
|
||
|
||
const form = reactive({
|
||
pricePerPartRials: 2500,
|
||
isBlocked: false,
|
||
alertCriticalRials: 10000,
|
||
alertWarningRials: 100000,
|
||
})
|
||
|
||
const chargeForm = reactive({
|
||
amountRials: 100000,
|
||
note: '',
|
||
})
|
||
|
||
const chargeInfo = reactive({
|
||
card: '',
|
||
iban: '',
|
||
accountHolder: '',
|
||
instruction: '',
|
||
})
|
||
|
||
const reportTab = ref('sms')
|
||
const tablesTab = ref('logs')
|
||
const reportFrom = ref<string | null>(null)
|
||
const reportTo = ref<string | null>(null)
|
||
const chargeFrom = ref<string | null>(null)
|
||
const chargeTo = ref<string | null>(null)
|
||
const logsFrom = ref<string | null>(null)
|
||
const logsTo = ref<string | null>(null)
|
||
const ledgerFrom = ref<string | null>(null)
|
||
const ledgerTo = ref<string | null>(null)
|
||
const logsSearch = ref('')
|
||
const ledgerSearch = ref('')
|
||
|
||
const periodReport = ref<{
|
||
summary: { messages: number; parts: number; amount: number }
|
||
byProvider: { provider: string; messages: number; parts: number; amount: number }[]
|
||
} | null>(null)
|
||
|
||
const chargeReport = ref<{
|
||
summary: { charges: number; amount: number }
|
||
items: { id: string; amount: number; balanceAfter: number | null; note: string | null; createdAt: string }[]
|
||
} | null>(null)
|
||
|
||
const logsPage = ref(1)
|
||
const logsTotal = ref(0)
|
||
const logsRows = ref<any[]>([])
|
||
const ledgerPage = ref(1)
|
||
const ledgerTotal = ref(0)
|
||
const ledgerRows = ref<any[]>([])
|
||
const pageSize = 20
|
||
|
||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||
|
||
function toGroupedDisplay(value: number | null | undefined) {
|
||
if (value == null || Number.isNaN(Number(value))) return ''
|
||
const n = Math.trunc(Number(value))
|
||
const neg = n < 0
|
||
const digits = String(Math.abs(n))
|
||
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||
return neg ? `-${grouped}` : grouped
|
||
}
|
||
|
||
function fromGroupedInput(raw: string, allowNegative = false) {
|
||
const text = String(raw ?? '')
|
||
const neg = allowNegative && text.trim().startsWith('-')
|
||
const digits = text.replace(/[^\d]/g, '')
|
||
if (!digits) return 0
|
||
const n = Number(digits)
|
||
return neg ? -n : n
|
||
}
|
||
|
||
function onGroupedMoneyInput(
|
||
apply: (n: number) => void,
|
||
value: string | number,
|
||
allowNegative = false,
|
||
) {
|
||
apply(fromGroupedInput(String(value ?? ''), allowNegative))
|
||
}
|
||
|
||
function formatNumber(value: number | null | undefined) {
|
||
if (value == null) return '—'
|
||
return new Intl.NumberFormat('fa-IR').format(value)
|
||
}
|
||
|
||
function formatMoney(value: number | null | undefined) {
|
||
if (value == null) return '—'
|
||
return `${formatNumber(value)} ریال`
|
||
}
|
||
|
||
function toneClass(level?: BalanceLevel) {
|
||
return level || 'unknown'
|
||
}
|
||
|
||
async function loadDetail() {
|
||
if (!tenantId.value) return
|
||
loading.value = true
|
||
try {
|
||
const { data } = await api.get(`/sms/tenants/${tenantId.value}`)
|
||
tenant.value = data.tenant
|
||
summary.value = data.summary
|
||
Object.assign(form, {
|
||
pricePerPartRials: data.account.pricePerPartRials,
|
||
isBlocked: data.account.isBlocked,
|
||
alertCriticalRials: data.account.alertCriticalRials,
|
||
alertWarningRials: data.account.alertWarningRials,
|
||
})
|
||
Object.assign(chargeInfo, {
|
||
card: data.chargeInfo?.card || '',
|
||
iban: data.chargeInfo?.iban || '',
|
||
accountHolder: data.chargeInfo?.accountHolder || '',
|
||
instruction: data.chargeInfo?.instruction || '',
|
||
})
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'خطا در دریافت حساب پیامک')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function savePrice() {
|
||
saving.value = true
|
||
try {
|
||
await api.patch(`/sms/tenants/${tenantId.value}`, {
|
||
pricePerPartRials: form.pricePerPartRials,
|
||
})
|
||
message.success('قیمت ذخیره شد')
|
||
await loadDetail()
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'ذخیره ناموفق')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function saveThresholds() {
|
||
saving.value = true
|
||
try {
|
||
await api.patch(`/sms/tenants/${tenantId.value}`, {
|
||
alertCriticalRials: form.alertCriticalRials,
|
||
alertWarningRials: form.alertWarningRials,
|
||
})
|
||
message.success('آستانهها ذخیره شد')
|
||
await loadDetail()
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'ذخیره ناموفق')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
function openChargeConfirm() {
|
||
if (!chargeForm.amountRials) {
|
||
message.warning('مبلغ را وارد کنید')
|
||
return
|
||
}
|
||
Modal.confirm({
|
||
title: 'تأیید شارژ کیف پول',
|
||
content: `مبلغ ${formatMoney(chargeForm.amountRials)} روی کیف پول ثبت شود؟`,
|
||
okText: 'ثبت',
|
||
cancelText: 'انصراف',
|
||
onOk: () => chargeWallet(),
|
||
})
|
||
}
|
||
|
||
async function chargeWallet() {
|
||
charging.value = true
|
||
try {
|
||
await api.post(`/sms/tenants/${tenantId.value}/charge`, {
|
||
amountRials: Number(chargeForm.amountRials),
|
||
note: chargeForm.note || undefined,
|
||
})
|
||
message.success('موجودی بهروز شد')
|
||
chargeForm.note = ''
|
||
await loadDetail()
|
||
if (tablesTab.value === 'ledger') await loadLedger()
|
||
if (reportTab.value === 'charges') await loadChargeReport()
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'شارژ ناموفق')
|
||
} finally {
|
||
charging.value = false
|
||
}
|
||
}
|
||
|
||
async function onBlockChange(checked: boolean | string | number) {
|
||
const next = Boolean(checked)
|
||
blockLoading.value = true
|
||
try {
|
||
await api.post(`/sms/tenants/${tenantId.value}/${next ? 'block' : 'unblock'}`)
|
||
form.isBlocked = next
|
||
message.success(next ? 'پیامک مسدود شد' : 'پیامک آزاد شد')
|
||
await loadDetail()
|
||
} catch (e: any) {
|
||
form.isBlocked = !next
|
||
message.error(e?.response?.data?.message || 'عملیات ناموفق')
|
||
} finally {
|
||
blockLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function saveChargeInfo() {
|
||
chargeInfoSaving.value = true
|
||
try {
|
||
await api.put(`/sms/tenants/${tenantId.value}/charge-info`, {
|
||
chargeCard: chargeInfo.card,
|
||
chargeIban: chargeInfo.iban,
|
||
chargeHolder: chargeInfo.accountHolder,
|
||
chargeInstruction: chargeInfo.instruction,
|
||
})
|
||
message.success('اطلاعات واریز ذخیره شد')
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'ذخیره ناموفق')
|
||
} finally {
|
||
chargeInfoSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function loadPeriodReport() {
|
||
reportLoading.value = true
|
||
try {
|
||
const { data } = await api.get(`/sms/tenants/${tenantId.value}/report`, {
|
||
params: {
|
||
fromDate: reportFrom.value || undefined,
|
||
toDate: reportTo.value || undefined,
|
||
},
|
||
})
|
||
periodReport.value = data
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'خطا در گزارش')
|
||
} finally {
|
||
reportLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadChargeReport() {
|
||
chargeReportLoading.value = true
|
||
try {
|
||
const { data } = await api.get(`/sms/tenants/${tenantId.value}/report/charges`, {
|
||
params: {
|
||
fromDate: chargeFrom.value || undefined,
|
||
toDate: chargeTo.value || undefined,
|
||
},
|
||
})
|
||
chargeReport.value = data
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'خطا در گزارش شارژ')
|
||
} finally {
|
||
chargeReportLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadLogs() {
|
||
logsLoading.value = true
|
||
try {
|
||
const { data } = await api.post(`/sms/tenants/${tenantId.value}/logs/table`, {
|
||
page: logsPage.value,
|
||
pageSize,
|
||
fromDate: logsFrom.value || undefined,
|
||
toDate: logsTo.value || undefined,
|
||
search: logsSearch.value || undefined,
|
||
})
|
||
logsRows.value = data.rows || []
|
||
logsTotal.value = data.total || 0
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'خطا در لاگ ارسال')
|
||
} finally {
|
||
logsLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadLedger() {
|
||
ledgerLoading.value = true
|
||
try {
|
||
const { data } = await api.post(`/sms/tenants/${tenantId.value}/ledger/table`, {
|
||
page: ledgerPage.value,
|
||
pageSize,
|
||
fromDate: ledgerFrom.value || undefined,
|
||
toDate: ledgerTo.value || undefined,
|
||
search: ledgerSearch.value || undefined,
|
||
})
|
||
ledgerRows.value = data.rows || []
|
||
ledgerTotal.value = data.total || 0
|
||
} catch (e: any) {
|
||
message.error(e?.response?.data?.message || 'خطا در دفترچه')
|
||
} finally {
|
||
ledgerLoading.value = false
|
||
}
|
||
}
|
||
|
||
const logColumns: TableColumnsType = [
|
||
{ title: 'گیرنده', dataIndex: 'receiver', key: 'receiver', width: 130 },
|
||
{ title: 'متن', dataIndex: 'body', key: 'body', ellipsis: true },
|
||
{ title: 'پارت', dataIndex: 'parts', key: 'parts', width: 70 },
|
||
{
|
||
title: 'مبلغ',
|
||
key: 'totalAmount',
|
||
width: 120,
|
||
customRender: ({ record }: { record: any }) => formatMoney(record.totalAmount),
|
||
},
|
||
{ title: 'ارائهدهنده', dataIndex: 'provider', key: 'provider', width: 110 },
|
||
{
|
||
title: 'زمان',
|
||
key: 'createdAt',
|
||
width: 150,
|
||
customRender: ({ record }: { record: any }) => formatIranDateTime(record.createdAt),
|
||
},
|
||
]
|
||
|
||
const ledgerColumns: TableColumnsType = [
|
||
{ title: 'نوع', dataIndex: 'type', key: 'type', width: 90 },
|
||
{
|
||
title: 'مبلغ',
|
||
key: 'amount',
|
||
width: 130,
|
||
customRender: ({ record }: { record: any }) => formatMoney(record.amount),
|
||
},
|
||
{
|
||
title: 'مانده',
|
||
key: 'balanceAfter',
|
||
width: 130,
|
||
customRender: ({ record }: { record: any }) => formatMoney(record.balanceAfter),
|
||
},
|
||
{ title: 'توضیح', dataIndex: 'note', key: 'note', ellipsis: true },
|
||
{
|
||
title: 'زمان',
|
||
key: 'createdAt',
|
||
width: 150,
|
||
customRender: ({ record }: { record: any }) => formatIranDateTime(record.createdAt),
|
||
},
|
||
]
|
||
|
||
const providerReportColumns: TableColumnsType = [
|
||
{ title: 'ارائهدهنده', dataIndex: 'provider', key: 'provider' },
|
||
{ title: 'پیامک', dataIndex: 'messages', key: 'messages' },
|
||
{ title: 'پارت', dataIndex: 'parts', key: 'parts' },
|
||
{
|
||
title: 'مبلغ',
|
||
key: 'amount',
|
||
customRender: ({ record }: { record: any }) => formatMoney(record.amount),
|
||
},
|
||
]
|
||
|
||
const chargeReportColumns: TableColumnsType = [
|
||
{
|
||
title: 'مبلغ',
|
||
key: 'amount',
|
||
customRender: ({ record }: { record: any }) => formatMoney(record.amount),
|
||
},
|
||
{
|
||
title: 'مانده',
|
||
key: 'balanceAfter',
|
||
customRender: ({ record }: { record: any }) => formatMoney(record.balanceAfter),
|
||
},
|
||
{ title: 'توضیح', dataIndex: 'note', key: 'note' },
|
||
{
|
||
title: 'زمان',
|
||
key: 'createdAt',
|
||
customRender: ({ record }: { record: any }) => formatIranDateTime(record.createdAt),
|
||
},
|
||
]
|
||
|
||
watch(tenantId, async () => {
|
||
await loadDetail()
|
||
await Promise.all([loadPeriodReport(), loadChargeReport(), loadLogs(), loadLedger()])
|
||
})
|
||
|
||
watch(reportTab, (tab) => {
|
||
if (tab === 'sms' && !periodReport.value) loadPeriodReport()
|
||
if (tab === 'charges' && !chargeReport.value) loadChargeReport()
|
||
})
|
||
|
||
watch(tablesTab, (tab) => {
|
||
if (tab === 'logs') loadLogs()
|
||
if (tab === 'ledger') loadLedger()
|
||
})
|
||
|
||
onMounted(async () => {
|
||
const today = todayIranDateInput()
|
||
reportFrom.value = `${today.slice(0, 8)}01`
|
||
reportTo.value = today
|
||
await loadDetail()
|
||
await Promise.all([loadPeriodReport(), loadLogs(), loadLedger()])
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-shell sms-page">
|
||
<div class="page-head">
|
||
<a-button type="text" @click="router.push({ name: 'sms-panel' })">
|
||
<ArrowRightOutlined />
|
||
</a-button>
|
||
<div class="page-head-text">
|
||
<h1 class="page-title">{{ tenant?.nameFa || 'پنل پیامک' }}</h1>
|
||
<p class="page-sub">مدیریت کیف پول، تعرفه و گزارش ارسال پیامک</p>
|
||
</div>
|
||
<a-tag v-if="tenant" :color="tenant.product === 'hamkar' ? 'cyan' : 'orange'">
|
||
{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}
|
||
</a-tag>
|
||
<span v-if="tenant" class="domain">{{ tenant.domain }}</span>
|
||
</div>
|
||
|
||
<a-spin :spinning="loading">
|
||
<template v-if="tenant && summary">
|
||
<section class="stat-grid">
|
||
<div class="stat-card" :class="toneClass(summary.balanceLevel)">
|
||
<div class="stat-label-row">
|
||
<span>موجودی کیف پول</span>
|
||
<a-tag v-if="form.isBlocked" color="error">ارسال مسدود</a-tag>
|
||
</div>
|
||
<strong>{{ formatNumber(summary.balanceRials) }} <small>ریال</small></strong>
|
||
</div>
|
||
<div class="stat-card sky">
|
||
<span>قیمت هر پارت</span>
|
||
<strong>{{ formatNumber(summary.perPartRials) }} <small>ریال</small></strong>
|
||
</div>
|
||
<div class="stat-card emerald">
|
||
<span>امروز</span>
|
||
<strong>{{ formatNumber(summary.todayMessages) }} <small>پیامک</small></strong>
|
||
<em>{{ formatNumber(summary.todayParts) }} پارت · {{ formatMoney(summary.todayCostRials) }}</em>
|
||
</div>
|
||
<div class="stat-card violet">
|
||
<span>ماه جاری</span>
|
||
<strong>{{ formatNumber(summary.monthMessages) }} <small>پیامک</small></strong>
|
||
<em>{{ formatNumber(summary.monthParts) }} پارت · {{ formatMoney(summary.monthCostRials) }}</em>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel-card mgmt">
|
||
<h2>مدیریت کیف پول</h2>
|
||
|
||
<div class="mgmt-row">
|
||
<div class="subcard sky">
|
||
<h3>تعرفه پیامک</h3>
|
||
<div class="inline-actions">
|
||
<a-input
|
||
size="large"
|
||
dir="ltr"
|
||
class="money-input"
|
||
:value="toGroupedDisplay(form.pricePerPartRials)"
|
||
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.pricePerPartRials = n), v)"
|
||
/>
|
||
<a-button type="primary" size="large" :loading="saving" @click="savePrice">ذخیره قیمت</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="subcard emerald">
|
||
<h3>شارژ کیف پول</h3>
|
||
<div class="charge-grid">
|
||
<a-input
|
||
size="large"
|
||
dir="ltr"
|
||
class="money-input"
|
||
placeholder="مبلغ (ریال)"
|
||
:value="toGroupedDisplay(chargeForm.amountRials)"
|
||
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (chargeForm.amountRials = n), v, true)"
|
||
/>
|
||
<a-input v-model:value="chargeForm.note" size="large" placeholder="توضیح (اختیاری)" />
|
||
<a-button type="primary" size="large" :loading="charging" @click="openChargeConfirm">
|
||
شارژ کیف پول
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="block-row" :class="{ blocked: form.isBlocked }">
|
||
<div>
|
||
<strong>مسدودسازی ارسال پیامک</strong>
|
||
<p>در حالت مسدود، ارسال از این نسخه متوقف میشود (پس از اتصال محصولات).</p>
|
||
</div>
|
||
<a-switch
|
||
:checked="form.isBlocked"
|
||
:loading="blockLoading"
|
||
checked-children="مسدود"
|
||
un-checked-children="فعال"
|
||
@change="onBlockChange"
|
||
/>
|
||
</div>
|
||
|
||
<div class="subcard violet charge-info">
|
||
<h3>اطلاعات واریز شارژ</h3>
|
||
<div class="fields-2">
|
||
<a-input v-model:value="chargeInfo.card" size="large" dir="ltr" placeholder="شماره کارت" />
|
||
<a-input v-model:value="chargeInfo.iban" size="large" dir="ltr" placeholder="شبا" />
|
||
<a-input v-model:value="chargeInfo.accountHolder" size="large" placeholder="نام صاحب حساب" />
|
||
<a-textarea
|
||
v-model:value="chargeInfo.instruction"
|
||
:rows="2"
|
||
placeholder="راهنمای واریز"
|
||
/>
|
||
</div>
|
||
<a-button type="primary" :loading="chargeInfoSaving" @click="saveChargeInfo">ذخیره اطلاعات واریز</a-button>
|
||
</div>
|
||
|
||
<div class="subcard thresholds">
|
||
<h3>آستانههای رنگ موجودی</h3>
|
||
<div class="threshold-grid">
|
||
<label>
|
||
<span class="tone-label"><i class="dot critical" /> آستانه قرمز</span>
|
||
<a-input
|
||
size="large"
|
||
dir="ltr"
|
||
class="money-input"
|
||
:value="toGroupedDisplay(form.alertCriticalRials)"
|
||
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.alertCriticalRials = n), v)"
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span class="tone-label"><i class="dot warning" /> آستانه زرد</span>
|
||
<a-input
|
||
size="large"
|
||
dir="ltr"
|
||
class="money-input"
|
||
:value="toGroupedDisplay(form.alertWarningRials)"
|
||
@update:value="(v: string | number) => onGroupedMoneyInput((n) => (form.alertWarningRials = n), v)"
|
||
/>
|
||
</label>
|
||
<a-button type="primary" size="large" :loading="saving" @click="saveThresholds">ذخیره آستانهها</a-button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel-card">
|
||
<h2>گزارش</h2>
|
||
<a-tabs v-model:activeKey="reportTab">
|
||
<a-tab-pane key="sms" tab="گزارش بازهای پیامک">
|
||
<div class="filter-row">
|
||
<JalaliDatePicker v-model="reportFrom" value-type="ymd" placeholder="از تاریخ" />
|
||
<JalaliDatePicker v-model="reportTo" value-type="ymd" placeholder="تا تاریخ" />
|
||
<a-button type="primary" :loading="reportLoading" @click="loadPeriodReport">دریافت گزارش</a-button>
|
||
</div>
|
||
<div v-if="periodReport" class="report-summary">
|
||
<div>
|
||
<span>پیامک</span>
|
||
<strong>{{ formatNumber(periodReport.summary.messages) }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>پارت</span>
|
||
<strong>{{ formatNumber(periodReport.summary.parts) }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>مبلغ</span>
|
||
<strong>{{ formatMoney(periodReport.summary.amount) }}</strong>
|
||
</div>
|
||
</div>
|
||
<a-empty
|
||
v-if="periodReport && !periodReport.byProvider.length"
|
||
description="هنوز لاگ ارسالی ثبت نشده (پس از اتصال consume پر میشود)"
|
||
/>
|
||
<a-table
|
||
v-else-if="periodReport?.byProvider?.length"
|
||
size="small"
|
||
:pagination="false"
|
||
:data-source="periodReport.byProvider"
|
||
:columns="providerReportColumns"
|
||
row-key="provider"
|
||
/>
|
||
</a-tab-pane>
|
||
|
||
<a-tab-pane key="charges" tab="گزارش شارژ کیف پول">
|
||
<div class="filter-row">
|
||
<JalaliDatePicker v-model="chargeFrom" value-type="ymd" placeholder="از تاریخ" />
|
||
<JalaliDatePicker v-model="chargeTo" value-type="ymd" placeholder="تا تاریخ" />
|
||
<a-button type="primary" :loading="chargeReportLoading" @click="loadChargeReport">
|
||
دریافت گزارش
|
||
</a-button>
|
||
</div>
|
||
<div v-if="chargeReport" class="report-summary">
|
||
<div>
|
||
<span>تعداد شارژ</span>
|
||
<strong>{{ formatNumber(chargeReport.summary.charges) }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>جمع مبلغ</span>
|
||
<strong>{{ formatMoney(chargeReport.summary.amount) }}</strong>
|
||
</div>
|
||
</div>
|
||
<a-table
|
||
v-if="chargeReport"
|
||
size="small"
|
||
:loading="chargeReportLoading"
|
||
:pagination="false"
|
||
:data-source="chargeReport.items"
|
||
:columns="chargeReportColumns"
|
||
row-key="id"
|
||
/>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</section>
|
||
|
||
<section class="panel-card">
|
||
<h2>جداول</h2>
|
||
<a-tabs v-model:activeKey="tablesTab" destroy-inactive-tab-pane>
|
||
<a-tab-pane key="logs" tab="لاگ ارسال">
|
||
<div class="filter-row">
|
||
<JalaliDatePicker v-model="logsFrom" value-type="ymd" placeholder="از تاریخ" />
|
||
<JalaliDatePicker v-model="logsTo" value-type="ymd" placeholder="تا تاریخ" />
|
||
<a-input
|
||
v-model:value="logsSearch"
|
||
allow-clear
|
||
placeholder="جستجو"
|
||
style="max-width: 200px"
|
||
@pressEnter="() => { logsPage = 1; loadLogs() }"
|
||
/>
|
||
<a-button type="primary" :loading="logsLoading" @click="() => { logsPage = 1; loadLogs() }">
|
||
اعمال
|
||
</a-button>
|
||
</div>
|
||
<a-table
|
||
size="middle"
|
||
:loading="logsLoading"
|
||
:columns="logColumns"
|
||
:data-source="logsRows"
|
||
row-key="id"
|
||
:pagination="{
|
||
current: logsPage,
|
||
pageSize,
|
||
total: logsTotal,
|
||
showSizeChanger: false,
|
||
onChange: (p: number) => { logsPage = p; loadLogs() },
|
||
}"
|
||
/>
|
||
</a-tab-pane>
|
||
|
||
<a-tab-pane key="ledger" tab="دفترچه کیف پول">
|
||
<div class="filter-row">
|
||
<JalaliDatePicker v-model="ledgerFrom" value-type="ymd" placeholder="از تاریخ" />
|
||
<JalaliDatePicker v-model="ledgerTo" value-type="ymd" placeholder="تا تاریخ" />
|
||
<a-input
|
||
v-model:value="ledgerSearch"
|
||
allow-clear
|
||
placeholder="جستجو در توضیح"
|
||
style="max-width: 200px"
|
||
@pressEnter="() => { ledgerPage = 1; loadLedger() }"
|
||
/>
|
||
<a-button type="primary" :loading="ledgerLoading" @click="() => { ledgerPage = 1; loadLedger() }">
|
||
اعمال
|
||
</a-button>
|
||
</div>
|
||
<a-table
|
||
size="middle"
|
||
:loading="ledgerLoading"
|
||
:columns="ledgerColumns"
|
||
:data-source="ledgerRows"
|
||
row-key="id"
|
||
:pagination="{
|
||
current: ledgerPage,
|
||
pageSize,
|
||
total: ledgerTotal,
|
||
showSizeChanger: false,
|
||
onChange: (p: number) => { ledgerPage = p; loadLedger() },
|
||
}"
|
||
/>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</section>
|
||
</template>
|
||
<a-empty v-else-if="!loading" description="نسخه پیدا نشد" />
|
||
</a-spin>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.sms-page {
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.page-head-text {
|
||
flex: 1;
|
||
min-width: 180px;
|
||
}
|
||
|
||
.domain {
|
||
color: #8b95a8;
|
||
font-size: 0.85rem;
|
||
direction: ltr;
|
||
}
|
||
|
||
.stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: #fff;
|
||
border: 1px solid #e8eef5;
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
display: grid;
|
||
gap: 8px;
|
||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.stat-card span {
|
||
color: #64748b;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.stat-card strong {
|
||
font-size: 1.45rem;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.stat-card small,
|
||
.stat-card em {
|
||
font-style: normal;
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
color: #64748b;
|
||
}
|
||
|
||
.stat-label-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
.stat-card.healthy {
|
||
background: linear-gradient(160deg, #ecfdf5, #fff);
|
||
border-color: #bbf7d0;
|
||
color: #166534;
|
||
}
|
||
.stat-card.healthy strong { color: #166534; }
|
||
|
||
.stat-card.warning {
|
||
background: linear-gradient(160deg, #fffbeb, #fff);
|
||
border-color: #fde68a;
|
||
}
|
||
.stat-card.warning strong { color: #92400e; }
|
||
|
||
.stat-card.critical {
|
||
background: linear-gradient(160deg, #fef2f2, #fff);
|
||
border-color: #fecaca;
|
||
}
|
||
.stat-card.critical strong { color: #991b1b; }
|
||
|
||
.stat-card.sky {
|
||
background: linear-gradient(160deg, #f0f9ff, #fff);
|
||
border-color: #bae6fd;
|
||
}
|
||
.stat-card.emerald {
|
||
background: linear-gradient(160deg, #ecfdf5, #fff);
|
||
border-color: #a7f3d0;
|
||
}
|
||
.stat-card.violet {
|
||
background: linear-gradient(160deg, #f5f3ff, #fff);
|
||
border-color: #ddd6fe;
|
||
}
|
||
|
||
.panel-card {
|
||
background: #fff;
|
||
border: 1px solid #e8eef5;
|
||
border-radius: 18px;
|
||
padding: 16px;
|
||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||
}
|
||
|
||
.panel-card h2 {
|
||
margin: 0 0 14px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.mgmt-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.subcard {
|
||
border-radius: 14px;
|
||
border: 1px solid #e2e8f0;
|
||
padding: 14px;
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.subcard h3 {
|
||
margin: 0;
|
||
font-size: 0.92rem;
|
||
}
|
||
|
||
.subcard.sky {
|
||
background: linear-gradient(160deg, #f0f9ff, #fff);
|
||
border-color: #bae6fd;
|
||
}
|
||
.subcard.emerald {
|
||
background: linear-gradient(160deg, #ecfdf5, #fff);
|
||
border-color: #a7f3d0;
|
||
}
|
||
.subcard.violet {
|
||
background: linear-gradient(160deg, #f5f3ff, #fff);
|
||
border-color: #ddd6fe;
|
||
}
|
||
|
||
.inline-actions,
|
||
.charge-grid,
|
||
.filter-row,
|
||
.threshold-grid,
|
||
.fields-2 {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.inline-actions {
|
||
grid-template-columns: 1fr auto;
|
||
align-items: center;
|
||
}
|
||
|
||
.charge-grid {
|
||
grid-template-columns: 1fr 1.2fr auto;
|
||
align-items: center;
|
||
}
|
||
|
||
.filter-row {
|
||
grid-template-columns: 1fr 1fr auto auto;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.fields-2 {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
|
||
.block-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
border-radius: 14px;
|
||
border: 1px solid #bbf7d0;
|
||
background: #f0fdf4;
|
||
padding: 14px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.block-row.blocked {
|
||
border-color: #fecaca;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.block-row p {
|
||
margin: 4px 0 0;
|
||
color: #64748b;
|
||
font-size: 0.82rem;
|
||
}
|
||
|
||
.charge-info {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.threshold-grid {
|
||
grid-template-columns: 1fr 1fr auto;
|
||
align-items: end;
|
||
}
|
||
|
||
.tone-label {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
font-size: 0.85rem;
|
||
color: #334155;
|
||
}
|
||
|
||
.dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
.dot.critical { background: #ef4444; }
|
||
.dot.warning { background: #eab308; }
|
||
|
||
.report-summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.report-summary > div {
|
||
background: #f8fafc;
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
|
||
.report-summary span {
|
||
color: #94a3b8;
|
||
font-size: 0.78rem;
|
||
}
|
||
|
||
.money-input :deep(input) {
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
@media (max-width: 1100px) {
|
||
.stat-grid,
|
||
.mgmt-row,
|
||
.charge-grid,
|
||
.filter-row,
|
||
.threshold-grid,
|
||
.fields-2,
|
||
.report-summary {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.inline-actions {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|