Files
CRM_Montzar/apps/web/src/views/adm/AdmSmsView.vue
T

548 lines
15 KiB
Vue
Raw Normal View History

<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>