Files
CRM_Montzar/apps/web/src/views/adm/AdmSmsView.vue
T
Mahdi bbbedc6059 fix: polish SMS header chip and add deposit info in adm panel
Tighten the wallet chip layout and let super-admins set charge deposit details shown in the header charge modal.
2026-10-19 21:47:17 +03:30

615 lines
17 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, 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');
const depositCard = ref('');
const depositIban = ref('');
const depositHolder = ref('');
const depositInstruction = ref('');
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';
depositCard.value = data.chargeInfo.card || '';
depositIban.value = data.chargeInfo.iban || '';
depositHolder.value = data.chargeInfo.accountHolder || '';
depositInstruction.value = data.chargeInfo.instruction || '';
}
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 } : {}),
});
});
}
function saveDepositInfo() {
if (!selectedId.value) return;
void runSave(async () => {
await platformApi.patch(`/platform/sms/organizations/${selectedId.value}`, {
chargeCard: depositCard.value.trim() || null,
chargeIban: depositIban.value.trim() || null,
chargeHolder: depositHolder.value.trim() || null,
chargeInstruction: depositInstruction.value.trim() || null,
});
});
}
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 class="panel deposit-panel">
<h3>اطلاعات واریز شارژ</h3>
<p class="hint">نمایش در مودال «شارژ» هدر برای همه مدیران این محیط</p>
<label class="field">
<span>شماره کارت</span>
<input v-model="depositCard" type="text" dir="ltr" placeholder="مثلاً 6219…" />
</label>
<label class="field">
<span>شماره شبا</span>
<input v-model="depositIban" type="text" dir="ltr" placeholder="IR…" />
</label>
<label class="field">
<span>به نام</span>
<input v-model="depositHolder" type="text" placeholder="صاحب حساب" />
</label>
<label class="field">
<span>متن راهنما</span>
<textarea
v-model="depositInstruction"
rows="3"
placeholder="متن راهنمای واریز برای مدیران"
/>
</label>
<button type="button" class="btn btn-primary" :disabled="saving" @click="saveDepositInfo">
ذخیره
</button>
</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,
.panel textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.55rem 0.7rem;
font: inherit;
}
.panel textarea {
resize: vertical;
min-height: 4.5rem;
}
.deposit-panel {
grid-column: 1 / -1;
background: color-mix(in srgb, #ede9fe 35%, #fff);
border-color: #ddd6fe;
}
.field {
display: grid;
gap: 0.3rem;
}
.field > span {
font-size: 0.82rem;
font-weight: 600;
color: #475569;
}
.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>