Files
web/src/pages/SmsTenantPage.vue
T
2026-08-08 14:12:30 +03:30

1054 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 MoneyInput from '@/components/MoneyInput.vue'
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
import { formatIranDateTime, todayIranDateInput } from '@/utils/datetime'
import { formatMoney, formatNumber } from '@/utils/money'
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,
autoBlockBelowRials: null as number | null,
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 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,
autoBlockBelowRials:
data.account.autoBlockBelowRials == null ? null : data.account.autoBlockBelowRials,
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,
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(),
})
}
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 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>
</div>
</div>
<TenantPanelShortcuts :tenant-id="tenantId" current="sms" />
<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>
</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>
<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>
</div>
</div>
<div class="subcard auto-block">
<h3>مسدودسازی خودکار ارسال</h3>
<p class="hint">
اگر مبلغی بگذارید و موجودی از آن کمتر شود، ارسال پیامک خودکار قطع می‌شود (کلید مسدودسازی
روشن نمی‌شود). خالی بگذارید تا فقط با کلید دستی مسدود شود.
</p>
<div class="threshold-grid">
<label>
<span class="tone-label">آستانه مسدودسازی خودکار (تومان)</span>
<MoneyInput
v-model:value="form.autoBlockBelowRials"
nullable
placeholder="خالی = بدون مسدودسازی خودکار"
/>
</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>
<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;
}
.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 {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
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;
}
.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;
}
.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: 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;
}
}
</style>