Files
CRM_Montzar/apps/web/src/components/SmsHeaderWallet.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

435 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. 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 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 copyHint = ref('');
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';
});
const chargeInfo = computed(() => summary.value?.chargeInfo ?? null);
function normalizeCopyText(value: string) {
const persianDigits = '۰۱۲۳۴۵۶۷۸۹';
const arabicDigits = '٠١٢٣٤٥٦٧٨٩';
return value
.replace(/[\u06F0-\u06F9]/g, (digit) => String(persianDigits.indexOf(digit)))
.replace(/[\u0660-\u0669]/g, (digit) => String(arabicDigits.indexOf(digit)))
.replace(/\s+/g, '');
}
function formatCardDisplay(value: string) {
const digits = normalizeCopyText(value);
if (!digits) return '—';
return digits.replace(/(\d{4})(?=\d)/g, '$1 ').trim();
}
async function copyValue(label: string, value: string) {
const text = normalizeCopyText(value);
if (!text) {
copyHint.value = `${label} ثبت نشده است`;
return;
}
try {
await navigator.clipboard.writeText(text);
copyHint.value = `${label} کپی شد`;
} catch {
copyHint.value = 'کپی انجام نشد';
}
}
function closeCharge() {
chargeOpen.value = false;
copyHint.value = '';
}
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 ? '…' : `اعتبار پیامک: ${balanceLabel} ریال`"
>
<span class="label">پیامک</span>
<span class="amount">{{ balanceLabel }}</span>
<button type="button" class="charge" @click="chargeOpen = true">شارژ</button>
</div>
<div v-if="chargeOpen" class="layer">
<div class="backdrop" @click="closeCharge" />
<div class="modal charge-modal" role="dialog" aria-label="شارژ پیامک">
<header>
<strong>شارژ اعتبار پیامک</strong>
<button type="button" class="x" @click="closeCharge">×</button>
</header>
<div v-if="chargeInfo" class="charge-body">
<div class="instruction-box">
<p>{{ chargeInfo.instruction }}</p>
</div>
<div class="info-card">
<div class="info-head">
<span>شماره کارت</span>
<button type="button" class="copy violet" @click="copyValue('شماره کارت', chargeInfo.card)">
کپی
</button>
</div>
<p class="mono" dir="ltr">{{ formatCardDisplay(chargeInfo.card) }}</p>
</div>
<div class="info-card">
<div class="info-head">
<span>شماره شبا</span>
<button type="button" class="copy emerald" @click="copyValue('شماره شبا', chargeInfo.iban)">
کپی
</button>
</div>
<p class="mono" dir="ltr">{{ chargeInfo.iban || '—' }}</p>
</div>
<div class="info-card muted-card">
<span class="small">به نام</span>
<p class="holder">{{ chargeInfo.accountHolder || '—' }}</p>
</div>
<p v-if="copyHint" class="copy-hint">{{ copyHint }}</p>
</div>
<p v-else class="muted">اطلاعات واریز در دسترس نیست.</p>
</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>
.sms-wallet {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
/* چیپ جمع‌وجور؛ دکمه شارژ سمت چپ، برچسب سمت راست */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 31px;
box-sizing: border-box;
margin: 0;
padding: 0 4px 0 2px;
border: 1px solid #a7f3d0;
border-radius: 10px;
background: color-mix(in srgb, #ecfdf5 70%, #fff);
color: #065f46;
font-size: 0.75rem;
line-height: 1;
white-space: nowrap;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.chip.warning {
border-color: #fde68a;
background: color-mix(in srgb, #fffbeb 75%, #fff);
color: #92400e;
}
.chip.critical,
.chip.negative {
border-color: #fecaca;
background: color-mix(in srgb, #fef2f2 75%, #fff);
color: #991b1b;
}
.label {
font-weight: 600;
font-size: 0.7rem;
opacity: 0.72;
}
.amount {
font-weight: 800;
letter-spacing: -0.01em;
}
.charge {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
height: 23px;
margin: 0;
padding: 0 6px;
border: 1px solid #e2e8f0;
border-radius: 7px;
background: #fff;
color: #334155;
font: inherit;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
.charge:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #0f172a;
}
.charge:active {
background: #f1f5f9;
}
.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));
max-height: min(90vh, 640px);
overflow: auto;
background: #fff;
border-radius: 16px;
padding: 1rem 1.1rem 1.15rem;
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;
padding-bottom: 0.35rem;
border-bottom: 1px solid #f3f4f6;
}
.x {
border: 0;
background: transparent;
font-size: 1.3rem;
cursor: pointer;
line-height: 1;
}
.muted {
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.6;
text-align: center;
padding: 1rem 0;
}
.charge-body {
display: grid;
gap: 0.65rem;
}
.instruction-box {
border: 1px solid #e0f2fe;
border-radius: 14px;
background: linear-gradient(145deg, #f0f9ff, #fff);
padding: 0.85rem 0.95rem;
}
.instruction-box p {
margin: 0;
color: #475569;
font-size: 0.88rem;
line-height: 1.75;
}
.info-card {
border: 1px solid #f3f4f6;
border-radius: 12px;
background: #fff;
padding: 0.75rem 0.85rem;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.info-card.muted-card {
background: #f8fafc;
}
.info-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.info-head > span,
.small {
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
}
.mono {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 1rem;
font-weight: 800;
letter-spacing: 0.04em;
color: #0f172a;
word-break: break-all;
}
.holder {
margin: 0.15rem 0 0;
font-weight: 700;
color: #0f172a;
}
.copy {
border: 0;
border-radius: 8px;
padding: 0.25rem 0.55rem;
font: inherit;
font-size: 0.72rem;
font-weight: 700;
cursor: pointer;
}
.copy.violet {
background: #f5f3ff;
color: #6d28d9;
}
.copy.violet:hover {
background: #ede9fe;
}
.copy.emerald {
background: #ecfdf5;
color: #047857;
}
.copy.emerald:hover {
background: #d1fae5;
}
.copy-hint {
margin: 0;
text-align: center;
font-size: 0.8rem;
color: #0f766e;
font-weight: 600;
}
.ok {
border: 0;
background: #0f766e;
color: #fff;
border-radius: 10px;
padding: 0.45rem 0.9rem;
font: inherit;
cursor: pointer;
}
</style>