feat: add per-org SMS wallet with adm panel and header credit
Bill all platform SMS (including OTP), allow negative balance with manual/auto cutoffs, and support own-panel bypass without wallet debit.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { OutboundSmsHistoryResponse, OutboundSmsLogDto } from '@mento/shared';
|
||||
import { MESSAGE_AUTOMATION_FIELDS, MESSAGE_AUTOMATION_SMS_SEGMENT } from '@mento/shared';
|
||||
import { MESSAGE_AUTOMATION_FIELDS, countSmsParts } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliDay, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
@@ -46,7 +46,7 @@ const historyLoading = ref(false);
|
||||
|
||||
const preview = computed(() => fill(text.value));
|
||||
const count = computed(() => text.value.length);
|
||||
const parts = computed(() => (count.value === 0 ? 0 : Math.ceil(count.value / MESSAGE_AUTOMATION_SMS_SEGMENT)));
|
||||
const parts = computed(() => countSmsParts(text.value));
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import type { SmsWalletSummaryDto } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const summary = ref<SmsWalletSummaryDto | null>(null);
|
||||
const loading = ref(false);
|
||||
const chargeOpen = ref(false);
|
||||
const warnOpen = ref(false);
|
||||
const NEGATIVE_KEY = 'mento_sms_negative_warning_shown';
|
||||
|
||||
const visible = computed(() => Boolean(summary.value?.billingEnabled));
|
||||
|
||||
const balanceLabel = computed(() => {
|
||||
const n = summary.value?.balanceRials ?? 0;
|
||||
return toFaDigits(new Intl.NumberFormat('fa-IR').format(n));
|
||||
});
|
||||
|
||||
const toneClass = computed(() => {
|
||||
const level = summary.value?.balanceLevel;
|
||||
if (level === 'critical') return 'critical';
|
||||
if (level === 'warning') return 'warning';
|
||||
if ((summary.value?.balanceRials ?? 0) < 0) return 'negative';
|
||||
return 'ok';
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!auth.isAuthenticated || !auth.user?.organizationId) {
|
||||
summary.value = null;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get<SmsWalletSummaryDto>('/sms-wallet/summary');
|
||||
summary.value = data;
|
||||
syncWarn(data);
|
||||
} catch {
|
||||
summary.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncWarn(data: SmsWalletSummaryDto) {
|
||||
if (!data.billingEnabled || data.balanceRials >= 0) {
|
||||
warnOpen.value = false;
|
||||
try {
|
||||
sessionStorage.removeItem(NEGATIVE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (sessionStorage.getItem(NEGATIVE_KEY) === '1') return;
|
||||
sessionStorage.setItem(NEGATIVE_KEY, '1');
|
||||
warnOpen.value = true;
|
||||
} catch {
|
||||
warnOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => auth.user?.organizationId,
|
||||
() => {
|
||||
void load();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
defineExpose({ refresh: load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="sms-wallet">
|
||||
<div class="chip" :class="toneClass" :title="loading ? '…' : undefined">
|
||||
<span>اعتبار پیامک: {{ balanceLabel }} ریال</span>
|
||||
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
|
||||
</div>
|
||||
|
||||
<div v-if="chargeOpen" class="layer">
|
||||
<div class="backdrop" @click="chargeOpen = false" />
|
||||
<div class="modal" role="dialog" aria-label="شارژ پیامک">
|
||||
<header>
|
||||
<strong>شارژ اعتبار پیامک</strong>
|
||||
<button type="button" class="x" @click="chargeOpen = false">×</button>
|
||||
</header>
|
||||
<p class="muted">
|
||||
برای شارژ با پشتیبانی هماهنگ کنید یا فیش واریز را ارسال کنید.
|
||||
</p>
|
||||
<template v-if="summary?.chargeInfo">
|
||||
<dl>
|
||||
<div v-if="summary.chargeInfo.card">
|
||||
<dt>کارت</dt>
|
||||
<dd dir="ltr">{{ summary.chargeInfo.card }}</dd>
|
||||
</div>
|
||||
<div v-if="summary.chargeInfo.iban">
|
||||
<dt>شبا</dt>
|
||||
<dd dir="ltr">{{ summary.chargeInfo.iban }}</dd>
|
||||
</div>
|
||||
<div v-if="summary.chargeInfo.accountHolder">
|
||||
<dt>صاحب حساب</dt>
|
||||
<dd>{{ summary.chargeInfo.accountHolder }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="instruction">{{ summary.chargeInfo.instruction }}</p>
|
||||
</template>
|
||||
<button type="button" class="ok" @click="chargeOpen = false">متوجه شدم</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="warnOpen" class="layer">
|
||||
<div class="backdrop" @click="warnOpen = false" />
|
||||
<div class="modal warn" role="dialog" aria-label="هشدار موجودی منفی">
|
||||
<header>
|
||||
<strong>موجودی پیامک منفی است</strong>
|
||||
<button type="button" class="x" @click="warnOpen = false">×</button>
|
||||
</header>
|
||||
<p>
|
||||
اعتبار پیامک محیط شما منفی شده
|
||||
({{ balanceLabel }} ریال). ارسال تا رسیدن به آستانه قطع یا مسدودسازی دستی ادامه دارد؛ لطفاً
|
||||
شارژ کنید.
|
||||
</p>
|
||||
<button type="button" class="ok" @click="warnOpen = false">متوجه شدم</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #a7f3d0;
|
||||
background: rgba(236, 253, 245, 0.7);
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #065f46;
|
||||
}
|
||||
.chip.warning {
|
||||
border-color: #fde68a;
|
||||
background: rgba(255, 251, 235, 0.85);
|
||||
color: #92400e;
|
||||
}
|
||||
.chip.critical,
|
||||
.chip.negative {
|
||||
border-color: #fecaca;
|
||||
background: rgba(254, 242, 242, 0.9);
|
||||
color: #991b1b;
|
||||
}
|
||||
.charge {
|
||||
border: 0;
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.critical .charge,
|
||||
.chip.negative .charge,
|
||||
.chip.warning .charge {
|
||||
background: #b45309;
|
||||
}
|
||||
.layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
.modal {
|
||||
position: relative;
|
||||
width: min(420px, calc(100vw - 2rem));
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 1rem 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
.modal header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.muted,
|
||||
.instruction {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
dl > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ok {
|
||||
border: 0;
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,8 @@ const pageTitle = computed(() => {
|
||||
return 'کاربران';
|
||||
case 'adm-admins':
|
||||
return 'ادمینها';
|
||||
case 'adm-sms':
|
||||
return 'پیامک';
|
||||
default:
|
||||
return 'سوپرادمین';
|
||||
}
|
||||
@@ -31,6 +33,7 @@ const tabs = [
|
||||
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
|
||||
{ name: 'adm-organizations', label: 'محیطها', icon: 'building' as const },
|
||||
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
|
||||
{ name: 'adm-sms', label: 'پیامک', icon: 'mail' as const },
|
||||
{ name: 'adm-admins', label: 'ادمینها', icon: 'lock' as const },
|
||||
];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import QuickCreate from '@/components/QuickCreate.vue';
|
||||
import OrgSwitcher from '@/components/OrgSwitcher.vue';
|
||||
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
|
||||
import GamificationToaster from '@/components/gamification/GamificationToaster.vue';
|
||||
import SmsHeaderWallet from '@/components/SmsHeaderWallet.vue';
|
||||
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
||||
import type {
|
||||
ActivitySummaryDto,
|
||||
@@ -769,6 +770,7 @@ onUnmounted(() => {
|
||||
<BrandName lang="en" />
|
||||
</div>
|
||||
<div class="top-tools">
|
||||
<SmsHeaderWallet />
|
||||
<button class="round-btn" type="button" aria-label="اعلانها" @click="toggleNotif">
|
||||
<UiIcon name="bell" :size="18" />
|
||||
<span v-if="hasUnread" class="badge">{{ toFaDigits(unreadCount) }}</span>
|
||||
@@ -805,6 +807,7 @@ onUnmounted(() => {
|
||||
</form>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<SmsHeaderWallet />
|
||||
<RouterLink class="round-btn accent" :to="{ name: 'notes' }" title="هوش مصنوعی">
|
||||
<UiIcon name="sparkle" :size="18" />
|
||||
</RouterLink>
|
||||
|
||||
@@ -61,6 +61,11 @@ export const router = createRouter({
|
||||
name: 'adm-admins',
|
||||
component: () => import('@/views/adm/AdmAdminsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'adm-sms',
|
||||
component: () => import('@/views/adm/AdmSmsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { PlatformSmsOverviewDto, PlatformSmsTenantRowDto } from '@mento/shared';
|
||||
import { platformApi } from '@/lib/platform-api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const q = ref('');
|
||||
const tenants = ref<PlatformSmsTenantRowDto[]>([]);
|
||||
const overview = ref<PlatformSmsOverviewDto | null>(null);
|
||||
|
||||
const selectedId = computed(() => String(route.query.org || ''));
|
||||
|
||||
const chargeAmount = ref('');
|
||||
const chargeNote = ref('');
|
||||
const priceInput = ref('');
|
||||
const autoBlockInput = ref('');
|
||||
const ownUser = ref('');
|
||||
const ownPass = ref('');
|
||||
const ownFrom = ref('');
|
||||
const ownType = ref('0098');
|
||||
|
||||
function formatRials(n: number) {
|
||||
return toFaDigits(new Intl.NumberFormat('fa-IR').format(n)) + ' ریال';
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
const { data } = await platformApi.get<PlatformSmsTenantRowDto[]>('/platform/sms/tenants', {
|
||||
params: q.value.trim() ? { q: q.value.trim() } : undefined,
|
||||
});
|
||||
tenants.value = data;
|
||||
}
|
||||
|
||||
async function loadOverview(orgId: string) {
|
||||
const { data } = await platformApi.get<PlatformSmsOverviewDto>(
|
||||
`/platform/sms/organizations/${orgId}`,
|
||||
);
|
||||
overview.value = data;
|
||||
priceInput.value = String(data.account.pricePerPartRials);
|
||||
autoBlockInput.value =
|
||||
data.account.autoBlockBelowRials == null
|
||||
? ''
|
||||
: String(data.account.autoBlockBelowRials);
|
||||
ownUser.value = data.account.ownPanelUsername || '';
|
||||
ownPass.value = '';
|
||||
ownFrom.value = data.account.ownPanelFrom || '';
|
||||
ownType.value = data.account.ownPanelType || '0098';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadTenants();
|
||||
if (selectedId.value) {
|
||||
await loadOverview(selectedId.value);
|
||||
} else {
|
||||
overview.value = null;
|
||||
}
|
||||
} catch {
|
||||
error.value = 'بارگذاری پنل پیامک ناموفق بود';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openOrg(id: string) {
|
||||
void router.replace({ name: 'adm-sms', query: { org: id } });
|
||||
}
|
||||
|
||||
async function runSave(fn: () => Promise<void>) {
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await fn();
|
||||
if (selectedId.value) await loadOverview(selectedId.value);
|
||||
await loadTenants();
|
||||
} catch {
|
||||
error.value = 'ذخیره ناموفق بود';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function charge() {
|
||||
const amount = Number(String(chargeAmount.value).replace(/[^\d-]/g, ''));
|
||||
if (!selectedId.value || !amount) return;
|
||||
void runSave(async () => {
|
||||
await platformApi.post(`/platform/sms/organizations/${selectedId.value}/charge`, {
|
||||
amountRials: amount,
|
||||
note: chargeNote.value || undefined,
|
||||
});
|
||||
chargeAmount.value = '';
|
||||
chargeNote.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
function savePrice() {
|
||||
const price = Number(priceInput.value);
|
||||
if (!selectedId.value || !Number.isFinite(price) || price < 0) return;
|
||||
void runSave(async () => {
|
||||
await platformApi.put(`/platform/sms/organizations/${selectedId.value}/price`, {
|
||||
pricePerPartRials: Math.trunc(price),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleBlock() {
|
||||
if (!overview.value || !selectedId.value) return;
|
||||
const next = !overview.value.account.isBlocked;
|
||||
void runSave(async () => {
|
||||
await platformApi.put(`/platform/sms/organizations/${selectedId.value}/block`, {
|
||||
isBlocked: next,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveAutoBlock() {
|
||||
if (!selectedId.value) return;
|
||||
const raw = autoBlockInput.value.trim();
|
||||
const value = raw === '' ? null : Number(raw.replace(/[^\d-]/g, ''));
|
||||
if (raw !== '' && !Number.isFinite(value)) return;
|
||||
void runSave(async () => {
|
||||
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
|
||||
autoBlockBelowRials: value,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setPanelMode(mode: 'platform' | 'own') {
|
||||
if (!selectedId.value) return;
|
||||
void runSave(async () => {
|
||||
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
|
||||
panelMode: mode,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveOwnPanel() {
|
||||
if (!selectedId.value) return;
|
||||
void runSave(async () => {
|
||||
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
|
||||
panelMode: 'own',
|
||||
ownPanelType: ownType.value,
|
||||
ownPanelUsername: ownUser.value || null,
|
||||
ownPanelFrom: ownFrom.value || null,
|
||||
...(ownPass.value ? { ownPanelPassword: ownPass.value } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.org,
|
||||
() => {
|
||||
void load();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else class="grid">
|
||||
<aside class="list-card">
|
||||
<div class="list-head">
|
||||
<strong>محیطها</strong>
|
||||
<input v-model="q" type="search" placeholder="جستجو…" @keyup.enter="load" />
|
||||
</div>
|
||||
<button
|
||||
v-for="t in tenants"
|
||||
:key="t.id"
|
||||
type="button"
|
||||
class="tenant"
|
||||
:class="{ active: t.id === selectedId }"
|
||||
@click="openOrg(t.id)"
|
||||
>
|
||||
<span class="name">{{ t.name }}</span>
|
||||
<small>
|
||||
{{ t.sms.billingEnabled ? formatRials(t.sms.balanceRials) : 'پنل اختصاصی' }}
|
||||
<span v-if="t.isBlocked" class="badge bad">قطع</span>
|
||||
</small>
|
||||
</button>
|
||||
<p v-if="!tenants.length" class="muted">محیطی یافت نشد.</p>
|
||||
</aside>
|
||||
|
||||
<section v-if="overview" class="detail-card">
|
||||
<header class="detail-head">
|
||||
<div>
|
||||
<h2>پنل پیامک</h2>
|
||||
<p class="muted">
|
||||
{{ overview.organization.name }} · مدیریت کیف پول، تعرفه و گزارش ارسال
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="overview.account.isBlocked ? 'btn-ok' : 'btn-danger'"
|
||||
:disabled="saving"
|
||||
@click="toggleBlock"
|
||||
>
|
||||
{{ overview.account.isBlocked ? 'وصل کردن ارسال' : 'قطع ارسال' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="kpis">
|
||||
<article>
|
||||
<span>موجودی کیف پول</span>
|
||||
<strong>{{ formatRials(overview.account.balanceRials) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>قیمت هر پارت</span>
|
||||
<strong>{{ formatRials(overview.account.pricePerPartRials) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>امروز</span>
|
||||
<strong>{{ toFaDigits(overview.today.messages) }} پیامک</strong>
|
||||
<small>
|
||||
{{ toFaDigits(overview.today.parts) }} پارت ·
|
||||
{{ formatRials(overview.today.amountRials) }}
|
||||
</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>ماه جاری</span>
|
||||
<strong>{{ toFaDigits(overview.month.messages) }} پیامک</strong>
|
||||
<small>
|
||||
{{ toFaDigits(overview.month.parts) }} پارت ·
|
||||
{{ formatRials(overview.month.amountRials) }}
|
||||
</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="panels">
|
||||
<div class="panel">
|
||||
<h3>شارژ</h3>
|
||||
<input v-model="chargeAmount" type="text" placeholder="مبلغ ریال" />
|
||||
<input v-model="chargeNote" type="text" placeholder="یادداشت (اختیاری)" />
|
||||
<button type="button" class="btn btn-primary" :disabled="saving" @click="charge">
|
||||
ثبت شارژ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>تعرفه</h3>
|
||||
<input v-model="priceInput" type="text" placeholder="قیمت هر پارت (ریال)" />
|
||||
<button type="button" class="btn" :disabled="saving" @click="savePrice">
|
||||
ذخیره تعرفه
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>آستانه قطع خودکار</h3>
|
||||
<p class="hint">
|
||||
معمولاً منفی؛ مثلاً -500000 یعنی تا ۵۰۰ هزار ریال بدهی ارسال مجاز است. خالی =
|
||||
فقط کلید دستی.
|
||||
</p>
|
||||
<input v-model="autoBlockInput" type="text" placeholder="مثلاً -500000" />
|
||||
<button type="button" class="btn" :disabled="saving" @click="saveAutoBlock">
|
||||
ذخیره آستانه
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>سامانه پیامک</h3>
|
||||
<div class="mode-row">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="{ 'btn-primary': overview.account.panelMode === 'platform' }"
|
||||
:disabled="saving"
|
||||
@click="setPanelMode('platform')"
|
||||
>
|
||||
سامانه ما
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="{ 'btn-primary': overview.account.panelMode === 'own' }"
|
||||
:disabled="saving"
|
||||
@click="setPanelMode('own')"
|
||||
>
|
||||
پنل اختصاصی مشتری
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="overview.account.panelMode === 'own'">
|
||||
<p class="hint">با پنل اختصاصی، از کیف ما کسر و لاگ billing ثبت نمیشود.</p>
|
||||
<select v-model="ownType">
|
||||
<option value="0098">۰۰۹۸</option>
|
||||
<option value="trez">ترز</option>
|
||||
</select>
|
||||
<input v-model="ownUser" type="text" placeholder="نام کاربری" />
|
||||
<input v-model="ownPass" type="password" placeholder="رمز (خالی = بدون تغییر)" />
|
||||
<input v-model="ownFrom" type="text" placeholder="شماره فرستنده" />
|
||||
<button type="button" class="btn" :disabled="saving" @click="saveOwnPanel">
|
||||
ذخیره پنل اختصاصی
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tables">
|
||||
<div>
|
||||
<h3>آخرین ارسالها</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>گیرنده</th>
|
||||
<th>پارت</th>
|
||||
<th>مبلغ</th>
|
||||
<th>منبع</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in overview.sendLogs" :key="row.id">
|
||||
<td dir="ltr">{{ row.receiver }}</td>
|
||||
<td>{{ toFaDigits(row.parts) }}</td>
|
||||
<td>{{ formatRials(row.totalAmountRials) }}</td>
|
||||
<td>{{ row.source || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!overview.sendLogs.length" class="muted">هنوز لاگی نیست.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>گردش کیف</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نوع</th>
|
||||
<th>مبلغ</th>
|
||||
<th>مانده</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in overview.ledgers" :key="row.id">
|
||||
<td>{{ row.type }}</td>
|
||||
<td>{{ formatRials(row.amountRials) }}</td>
|
||||
<td>{{ formatRials(row.balanceAfterRials) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!overview.ledgers.length" class="muted">گردشی ثبت نشده.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else class="detail-card empty">
|
||||
<p class="muted">یک محیط را از فهرست انتخاب کنید.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.list-card,
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.list-head {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.list-head input,
|
||||
.panel input,
|
||||
.panel select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
}
|
||||
.tenant {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
border: 1px solid transparent;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.4rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tenant.active {
|
||||
border-color: #14b8a6;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
.tenant .name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.tenant small {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.badge.bad {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.detail-head h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.kpis article {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.kpis span,
|
||||
.kpis small {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.kpis strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.panel h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.mode-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #0f766e;
|
||||
border-color: #0f766e;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-ok {
|
||||
background: #15803d;
|
||||
border-color: #15803d;
|
||||
color: #fff;
|
||||
}
|
||||
.tables {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0.4rem 0.25rem;
|
||||
text-align: start;
|
||||
}
|
||||
.empty {
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.grid,
|
||||
.kpis,
|
||||
.panels,
|
||||
.tables {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user