Files
web/src/pages/SmsTenantPage.vue
T

1058 lines
30 KiB
Vue
Raw Normal View History

2026-07-23 19:42:07 +03:30
<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'
2026-07-23 19:42:07 +03:30
import { ArrowRightOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType } from 'ant-design-vue'
2026-07-23 19:42:07 +03:30
import api from '@/api/http'
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
import MoneyInput from '@/components/MoneyInput.vue'
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
import { formatMoney, formatNumber } from '@/utils/money'
2026-07-23 19:42:07 +03:30
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)
2026-07-23 19:42:07 +03:30
const tenant = ref<{
id: string
product: 'hamkar' | 'khanegi'
slug: string
nameFa: string
domain: string
} | null>(null)
const summary = ref<SmsSummary | null>(null)
2026-07-23 19:42:07 +03:30
const form = reactive({
pricePerPartRials: 2500,
isBlocked: false,
autoBlockBelowRials: null as number | null,
2026-07-23 19:42:07 +03:30
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
2026-07-23 19:42:07 +03:30
const tenantId = computed(() => String(route.params.tenantId || ''))
function toneClass(level?: BalanceLevel) {
return level || 'unknown'
2026-07-23 19:42:07 +03:30
}
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,
autoBlockBelowRials:
data.account.autoBlockBelowRials == null ? null : data.account.autoBlockBelowRials,
2026-07-23 19:42:07 +03:30
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 || '',
2026-07-23 19:42:07 +03:30
})
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت حساب پیامک')
} finally {
loading.value = false
}
}
async function savePrice() {
2026-07-23 19:42:07 +03:30
saving.value = true
try {
await api.patch(`/sms/tenants/${tenantId.value}`, {
pricePerPartRials: form.pricePerPartRials,
})
message.success('قیمت ذخیره شد')
2026-07-23 19:42:07 +03:30
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,
autoBlockBelowRials: form.autoBlockBelowRials,
})
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(),
})
}
2026-07-23 19:42:07 +03:30
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()
2026-07-23 19:42:07 +03:30
} 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
2026-07-23 19:42:07 +03:30
try {
await api.post(`/sms/tenants/${tenantId.value}/${next ? 'block' : 'unblock'}`)
form.isBlocked = next
message.success(next ? 'پیامک مسدود شد' : 'پیامک آزاد شد')
2026-07-23 19:42:07 +03:30
await loadDetail()
} catch (e: any) {
form.isBlocked = !next
2026-07-23 19:42:07 +03:30
message.error(e?.response?.data?.message || 'عملیات ناموفق')
} finally {
blockLoading.value = false
2026-07-23 19:42:07 +03:30
}
}
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()])
})
2026-07-23 19:42:07 +03:30
</script>
<template>
<div class="page-shell sms-page">
<div class="page-head">
2026-07-23 19:42:07 +03:30
<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 v-if="tenant" class="page-head-meta">
<a-tag :color="tenant.product === 'hamkar' ? 'cyan' : 'orange'">
{{ tenant.product === 'hamkar' ? 'همکار' : 'خانگی' }}
</a-tag>
<span class="domain">{{ tenant.domain }}</span>
</div>
2026-07-23 19:42:07 +03:30
</div>
</div>
<TenantPanelShortcuts :tenant-id="tenantId" current="sms" />
2026-07-23 19:42:07 +03:30
<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>{{ formatMoney(summary.balanceRials) }}</strong>
</div>
<div class="stat-card sky">
<span>قیمت هر پارت</span>
<strong>{{ formatMoney(summary.perPartRials) }}</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">
<MoneyInput v-model:value="form.pricePerPartRials" placeholder="قیمت هر پارت" />
<a-button type="primary" size="large" :loading="saving" @click="savePrice">ذخیره قیمت</a-button>
</div>
</div>
<div class="subcard emerald">
<h3>شارژ کیف پول</h3>
<div class="charge-grid">
<MoneyInput
v-model:value="chargeForm.amountRials"
allow-negative
placeholder="مبلغ (تومان)"
/>
<a-input v-model:value="chargeForm.note" size="large" placeholder="توضیح (اختیاری)" />
<a-button type="primary" size="large" :loading="charging" @click="openChargeConfirm">
شارژ کیف پول
</a-button>
</div>
2026-07-23 19:42:07 +03:30
</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"
/>
2026-07-23 19:42:07 +03:30
</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="راهنمای واریز"
2026-07-23 19:42:07 +03:30
/>
</div>
<a-button type="primary" :loading="chargeInfoSaving" @click="saveChargeInfo">ذخیره اطلاعات واریز</a-button>
2026-07-23 19:42:07 +03:30
</div>
<div class="subcard thresholds">
<h3>آستانه‌های رنگ موجودی</h3>
<div class="threshold-grid">
<label>
<span class="tone-label"><i class="dot critical" /> آستانه قرمز</span>
<MoneyInput v-model:value="form.alertCriticalRials" />
</label>
<label>
<span class="tone-label"><i class="dot warning" /> آستانه زرد</span>
<MoneyInput v-model:value="form.alertWarningRials" />
</label>
<a-button type="primary" size="large" :loading="saving" @click="saveThresholds">ذخیره آستانه‌ها</a-button>
2026-07-23 19:42:07 +03:30
</div>
</div>
<div class="subcard auto-block">
<h3>مسدودسازی خودکار ارسال</h3>
<p class="hint">
اگر مبلغی بگذارید و موجودی از آن کمتر شود، ارسال پیامک خودکار قطع می‌شود (کلید مسدودسازی
روشن نمی‌شود). معمولاً منفی است؛ مثلاً
<strong dir="ltr">-۵۰٬۰۰۰</strong>
یعنی تا ۵۰ هزار تومان بدهی اجازه ارسال هست و پایین‌تر مسدود می‌شود. خالی بگذارید تا فقط با
کلید دستی مسدود شود.
</p>
<div class="threshold-grid">
<label>
<span class="tone-label">آستانه مسدودسازی خودکار (تومان)</span>
<MoneyInput
v-model:value="form.autoBlockBelowRials"
allow-negative
nullable
placeholder="مثلاً -50,000 — خالی = غیرفعال"
/>
</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"
:scroll="{ x: true }"
: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"
:scroll="{ x: true }"
: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"
:scroll="{ x: true }"
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"
:scroll="{ x: true }"
row-key="id"
:pagination="{
current: ledgerPage,
pageSize,
total: ledgerTotal,
showSizeChanger: false,
onChange: (p: number) => { ledgerPage = p; loadLedger() },
}"
/>
</a-tab-pane>
</a-tabs>
</section>
</template>
2026-07-23 19:42:07 +03:30
<a-empty v-else-if="!loading" description="نسخه پیدا نشد" />
</a-spin>
</div>
</template>
<style scoped>
.sms-page {
display: grid;
gap: 14px;
width: 100%;
max-width: 100%;
min-width: 0;
}
.sms-page :deep(.ant-spin-nested-loading),
.sms-page :deep(.ant-spin-container) {
width: 100%;
max-width: 100%;
min-width: 0;
}
.sms-page :deep(.ant-table-wrapper) {
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.sms-page :deep(.ant-tabs) {
max-width: 100%;
min-width: 0;
}
.sms-page :deep(.ant-tabs-nav) {
margin-bottom: 12px;
2026-07-23 19:42:07 +03:30
}
.page-head {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
width: 100%;
min-width: 0;
}
.page-head-text {
flex: 1 1 160px;
min-width: 0;
}
.page-head-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
min-width: 0;
}
.domain {
color: #8b95a8;
font-size: 0.85rem;
direction: ltr;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-all;
}
.stat-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
width: 100%;
min-width: 0;
}
.stat-card {
background: #fff;
border: 1px solid #e8eef5;
border-radius: 16px;
padding: 14px;
display: grid;
gap: 8px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.stat-card span {
color: #64748b;
font-size: 0.85rem;
}
.stat-card strong {
font-size: 1.25rem;
color: #0f172a;
overflow-wrap: anywhere;
}
.stat-card small,
.stat-card em {
font-style: normal;
font-size: 0.82rem;
font-weight: 600;
color: #64748b;
overflow-wrap: anywhere;
}
.stat-label-row {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
flex-wrap: wrap;
min-width: 0;
}
.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: 14px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
width: 100%;
max-width: 100%;
min-width: 0;
overflow-x: clip;
}
.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;
gap: 12px;
margin-bottom: 12px;
}
.subcard {
border-radius: 14px;
border: 1px solid #e2e8f0;
padding: 14px;
display: grid;
gap: 12px;
min-width: 0;
max-width: 100%;
}
.subcard h3 {
margin: 0;
font-size: 0.92rem;
}
.subcard .hint {
margin: 8px 0 12px;
color: #64748b;
font-size: 0.85rem;
line-height: 1.7;
}
.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;
width: 100%;
min-width: 0;
}
.filter-row {
margin-bottom: 12px;
}
.inline-actions,
.charge-grid,
.filter-row,
.threshold-grid,
.fields-2,
.mgmt-row {
grid-template-columns: 1fr;
}
.block-row {
2026-07-23 19:42:07 +03:30
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
2026-07-23 19:42:07 +03:30
border-radius: 14px;
border: 1px solid #bbf7d0;
background: #f0fdf4;
padding: 14px;
margin-bottom: 12px;
min-width: 0;
}
.block-row > div {
flex: 1 1 200px;
min-width: 0;
2026-07-23 19:42:07 +03:30
}
.block-row.blocked {
border-color: #fecaca;
background: #fef2f2;
2026-07-23 19:42:07 +03:30
}
.block-row p {
margin: 4px 0 0;
color: #64748b;
2026-07-23 19:42:07 +03:30
font-size: 0.82rem;
}
.charge-info {
margin-bottom: 12px;
}
.tone-label {
display: inline-flex;
2026-07-23 19:42:07 +03:30
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 0.85rem;
color: #334155;
2026-07-23 19:42:07 +03:30
}
.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: 1fr;
gap: 10px;
margin-bottom: 12px;
}
.report-summary > div {
background: #f8fafc;
border-radius: 12px;
padding: 12px;
display: grid;
gap: 4px;
min-width: 0;
}
.report-summary span {
color: #94a3b8;
font-size: 0.78rem;
}
.money-input,
.filter-row :deep(.jalali-date-field),
.filter-row :deep(.ant-input),
.filter-row :deep(.ant-btn),
.inline-actions :deep(.ant-input),
.charge-grid :deep(.ant-input),
.charge-grid :deep(.ant-btn) {
width: 100%;
max-width: 100%;
min-width: 0;
}
@media (min-width: 640px) {
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.report-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.fields-2 {
grid-template-columns: 1fr 1fr;
}
.stat-card strong {
font-size: 1.35rem;
}
}
@media (min-width: 900px) {
.mgmt-row {
grid-template-columns: 1fr 1fr;
}
.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;
}
.threshold-grid {
grid-template-columns: 1fr 1fr auto;
align-items: end;
}
}
@media (min-width: 1100px) {
.stat-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.stat-card {
padding: 16px;
}
.stat-card strong {
font-size: 1.45rem;
}
.panel-card {
padding: 16px;
}
2026-07-23 19:42:07 +03:30
}
</style>