feat: complete application updates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-09-24 22:29:28 +03:30
co-authored by Copilot
parent 981f4fdaf9
commit 148f00a75d
94 changed files with 1351 additions and 458 deletions
+16 -1
View File
@@ -16,6 +16,7 @@ const password = ref(isMockAuthEnabled() ? MOCK_ADMIN.password : '')
const remember = ref(true)
const showPassword = ref(false)
const mockAuth = isMockAuthEnabled()
const brandLogoUrl = `${import.meta.env.BASE_URL}brand-logo.png`
const isFormValid = computed(() => phone.value.trim().length > 0 && password.value.length > 0)
@@ -123,6 +124,7 @@ async function submit() {
<div class="login-page__brand">
<div class="login-page__brand-inner">
<img :src="brandLogoUrl" alt="نادمی تکس" class="login-page__brand-logo" />
<p class="login-page__brand-badge">پارچه</p>
<h1 class="login-page__brand-title">پنل مدیریت سفارش‌های عمده</h1>
<p class="login-page__brand-text">
@@ -146,7 +148,12 @@ async function submit() {
align-items: center;
justify-content: center;
padding: 48px 40px;
background: linear-gradient(160deg, #062428 0%, #0a353c 55%, #0e454e 100%);
background: linear-gradient(
160deg,
var(--color-primary-dark) 0%,
var(--color-primary) 55%,
var(--color-sidebar-active) 100%
);
color: #fff;
}
@@ -154,6 +161,14 @@ async function submit() {
max-width: 420px;
}
.login-page__brand-logo {
display: block;
width: min(220px, 70%);
height: 150px;
object-fit: contain;
margin-bottom: 28px;
}
.login-page__brand-badge {
display: inline-flex;
margin: 0 0 16px;
+445
View File
@@ -0,0 +1,445 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import { useSmsWalletSummary } from '@/composables/useSmsWalletSummary'
import { useToast } from '@/composables/useToast'
import { smsWalletApi, type SmsUsageStats, type SmsWalletManage } from '@/services/api'
import { formatNumber } from '@/utils/format'
import { formatCardNumber, formatSheba } from '@/utils/smsFormat'
const toast = useToast()
const { refresh: refreshSummary } = useSmsWalletSummary()
const loading = ref(true)
const savingPrice = ref(false)
const charging = ref(false)
const savingDeposit = ref(false)
const savingThresholds = ref(false)
const toggling = ref(false)
const wallet = ref<SmsWalletManage | null>(null)
const pricePerPart = ref<number | null>(250)
const chargeAmount = ref<number | null>(null)
const chargeNote = ref('')
const cardNumber = ref('')
const sheba = ref('')
const accountName = ref('')
const yellowThreshold = ref<number | null>(10000)
const redThreshold = ref<number | null>(1000)
function apply(data: SmsWalletManage) {
wallet.value = data
pricePerPart.value = data.pricePerPart
cardNumber.value = formatCardNumber(data.cardNumber)
sheba.value = formatSheba(data.sheba)
accountName.value = data.accountName
yellowThreshold.value = data.yellowThreshold
redThreshold.value = data.redThreshold
}
onMounted(async () => {
try {
apply(await smsWalletApi.manage())
await refreshSummary().catch(() => {})
} catch (e) {
toast.error(e instanceof Error ? e.message : 'دریافت تعرفه پیامک ناموفق بود.')
} finally {
loading.value = false
}
})
async function savePrice() {
if (!pricePerPart.value || pricePerPart.value < 1 || savingPrice.value) return
savingPrice.value = true
try {
apply(await smsWalletApi.update({ pricePerPart: pricePerPart.value }))
toast.success('تعرفه پیامک ذخیره شد.')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'ذخیره تعرفه ناموفق بود.')
} finally {
savingPrice.value = false
}
}
async function charge() {
if (!chargeAmount.value || chargeAmount.value < 1 || charging.value) return
charging.value = true
try {
apply(
await smsWalletApi.charge({
amountToman: chargeAmount.value,
note: chargeNote.value.trim() || undefined,
}),
)
chargeAmount.value = null
chargeNote.value = ''
await refreshSummary()
toast.success('کیف پول پیامک شارژ شد.')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'شارژ کیف پول ناموفق بود.')
} finally {
charging.value = false
}
}
async function setBlocked(next: boolean) {
if (!wallet.value || toggling.value) return
const previous = wallet.value.blocked
wallet.value.blocked = next
toggling.value = true
try {
apply(await smsWalletApi.update({ blocked: next }))
await refreshSummary()
toast.success(next ? 'ارسال پیامک مسدود شد.' : 'ارسال پیامک فعال شد.')
} catch (e) {
wallet.value.blocked = previous
toast.error(e instanceof Error ? e.message : 'تغییر وضعیت مسدودسازی ناموفق بود.')
} finally {
toggling.value = false
}
}
async function saveDeposit() {
if (savingDeposit.value) return
savingDeposit.value = true
try {
apply(
await smsWalletApi.update({
cardNumber: cardNumber.value,
sheba: sheba.value,
accountName: accountName.value,
}),
)
await refreshSummary()
toast.success('اطلاعات واریز ذخیره شد.')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'ذخیره اطلاعات واریز ناموفق بود.')
} finally {
savingDeposit.value = false
}
}
async function saveThresholds() {
if (yellowThreshold.value == null || redThreshold.value == null || savingThresholds.value) return
savingThresholds.value = true
try {
apply(
await smsWalletApi.update({
yellowThreshold: yellowThreshold.value,
redThreshold: redThreshold.value,
}),
)
await refreshSummary()
toast.success('آستانه‌ها ذخیره شد.')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'ذخیره آستانه‌ها ناموفق بود.')
} finally {
savingThresholds.value = false
}
}
function stat(row: SmsUsageStats | undefined) {
return row ?? { messages: 0, parts: 0, costToman: 0 }
}
</script>
<template>
<div class="sms-page">
<div class="admin-page-header">
<h1 class="admin-page-header__title">تعرفه پیامک‌ها</h1>
</div>
<AdminPageSkeleton v-if="loading" variant="form" :rows="8" />
<template v-else-if="wallet">
<section class="sms-stats">
<article class="sms-stat sms-stat--balance">
<h2>مانده جاری</h2>
<p><span>{{ formatNumber(stat(wallet.total).messages) }}</span> پیامک</p>
<p><span>{{ formatNumber(stat(wallet.total).parts) }}</span> پارت</p>
<p><span>{{ formatNumber(stat(wallet.total).costToman) }}</span> تومان</p>
</article>
<article class="sms-stat sms-stat--today">
<h2>امروز</h2>
<p><span>{{ formatNumber(stat(wallet.today).messages) }}</span> پیامک</p>
<p><span>{{ formatNumber(stat(wallet.today).parts) }}</span> پارت</p>
<p><span>{{ formatNumber(stat(wallet.today).costToman) }}</span> تومان</p>
</article>
<article class="sms-stat sms-stat--price">
<h2>قیمت هر پارت</h2>
<strong>{{ formatNumber(wallet.pricePerPart) }} تومان</strong>
</article>
<article class="sms-stat sms-stat--wallet">
<h2>موجودی کیف پول</h2>
<strong>{{ formatNumber(wallet.balanceToman) }} تومان</strong>
</article>
</section>
<section class="admin-card sms-manage">
<h2>مدیریت کیف پول</h2>
<div class="sms-manage__row">
<div class="sms-manage__tariff">
<span>تعرفه پیامک</span>
<strong>{{ formatNumber(wallet.pricePerPart) }}</strong>
</div>
<div class="sms-manage__price">
<AdminCurrencyInput v-model="pricePerPart" :allow-empty="false" />
<button
type="button"
class="admin-btn admin-btn--success"
:disabled="savingPrice || !pricePerPart"
@click="savePrice"
>
{{ savingPrice ? 'در حال ذخیره…' : 'ذخیره قیمت' }}
</button>
</div>
<div class="sms-manage__charge">
<AdminCurrencyInput v-model="chargeAmount" placeholder="مبلغ (تومان)" />
<input
v-model="chargeNote"
class="admin-input"
type="text"
placeholder="توضیح (اختیاری)"
/>
<button
type="button"
class="admin-btn admin-btn--success"
:disabled="charging || !chargeAmount"
@click="charge"
>
{{ charging ? 'در حال شارژ…' : 'شارژ کیف پول' }}
</button>
</div>
</div>
<p class="sms-manage__hint">هر پیامک ۶۷ کاراکتر است. پیام ۱۴۰ کاراکتری ۳ پارت محاسبه می‌شود.</p>
<div class="sms-block">
<div>
<strong>مسدودسازی ارسال پیامک</strong>
<p>در حالت مسدود، ارسال پیامک متوقف می‌شود.</p>
</div>
<div class="sms-block__switch">
<span>{{ wallet.blocked ? 'فعال' : 'خاموش' }}</span>
<BaseSwitch
:model-value="wallet.blocked"
:loading="toggling"
active-text="فعال"
inactive-text="خاموش"
@update:model-value="setBlocked"
/>
</div>
</div>
</section>
<section class="admin-card sms-deposit">
<h2>اطلاعات واریز شارژ</h2>
<p class="sms-deposit__hint">
لطفا مبلغ مورد نظر خود جهت شارژ سامانه پیامکی را به شماره کارت یا حساب زیر واریز نمایید و تصویر فیش پرداختی را برای پشتیبانی ارسال فرمایید.
</p>
<label class="admin-form__field">
<span>شماره کارت</span>
<input v-model="cardNumber" class="admin-input" dir="ltr" inputmode="numeric" />
</label>
<label class="admin-form__field">
<span>شماره شبا</span>
<input v-model="sheba" class="admin-input" dir="ltr" />
</label>
<label class="admin-form__field">
<span>به نام</span>
<input v-model="accountName" class="admin-input" />
</label>
<button
type="button"
class="admin-btn admin-btn--success sms-deposit__save"
:disabled="savingDeposit"
@click="saveDeposit"
>
{{ savingDeposit ? 'در حال ذخیره…' : 'ذخیره اطلاعات واریز' }}
</button>
</section>
<section class="admin-card sms-thresholds">
<h2>آستانه‌های رنگ موجودی</h2>
<div class="sms-thresholds__row">
<label class="admin-form__field">
<span>آستانه زرد</span>
<AdminCurrencyInput v-model="yellowThreshold" :allow-empty="false" />
</label>
<label class="admin-form__field">
<span>آستانه قرمز</span>
<AdminCurrencyInput v-model="redThreshold" :allow-empty="false" />
</label>
<button
type="button"
class="admin-btn admin-btn--success"
:disabled="savingThresholds"
@click="saveThresholds"
>
{{ savingThresholds ? 'در حال ذخیره…' : 'ذخیره آستانه‌ها' }}
</button>
</div>
</section>
</template>
</div>
</template>
<style scoped>
.sms-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.sms-stat {
border-radius: 16px;
padding: 16px 18px;
min-height: 112px;
}
.sms-stat h2 {
margin: 0 0 10px;
font-size: 0.95rem;
}
.sms-stat p {
margin: 0;
color: var(--color-text-muted, #64748b);
font-size: 0.8125rem;
}
.sms-stat p span,
.sms-stat strong {
font-weight: 800;
}
.sms-stat--balance {
background: #f3e8ff;
color: #6d28d9;
}
.sms-stat--today {
background: #fff7ed;
color: #c2410c;
}
.sms-stat--price {
background: #eff6ff;
color: #1d4ed8;
}
.sms-stat--price strong,
.sms-stat--wallet strong {
display: block;
margin-top: 18px;
font-size: 1.35rem;
}
.sms-stat--wallet {
background: #fffbeb;
color: #b45309;
}
.sms-manage,
.sms-deposit,
.sms-thresholds {
margin-bottom: 16px;
}
.sms-manage h2,
.sms-deposit h2,
.sms-thresholds h2 {
margin: 0 0 14px;
font-size: 1rem;
}
.sms-manage__row,
.sms-thresholds__row {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
}
.sms-manage__tariff,
.sms-manage__price,
.sms-manage__charge {
display: flex;
align-items: center;
gap: 8px;
}
.sms-manage__price :deep(input),
.sms-manage__charge :deep(input),
.sms-manage__charge .admin-input,
.sms-thresholds__row :deep(input) {
width: 140px;
}
.sms-manage__tariff strong {
min-width: 72px;
text-align: center;
font-size: 1.25rem;
}
.sms-manage__charge {
margin-right: auto;
}
.sms-manage__hint,
.sms-deposit__hint {
margin: 12px 0 0;
color: var(--color-text-muted, #64748b);
font-size: 0.8125rem;
}
.sms-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding: 12px 14px;
border-radius: 12px;
background: #ecfdf3;
}
.sms-block p {
margin: 4px 0 0;
color: #067647;
font-size: 0.75rem;
}
.sms-block__switch {
display: flex;
align-items: center;
gap: 8px;
color: #067647;
font-weight: 700;
}
.sms-deposit__save {
width: 100%;
margin-top: 8px;
}
.sms-thresholds__row .admin-btn {
margin-bottom: 2px;
}
.admin-form__field > span {
font-size: 0.8125rem;
font-weight: 600;
}
@media (max-width: 960px) {
.sms-stats {
grid-template-columns: 1fr 1fr;
}
.sms-manage__charge {
margin-right: 0;
}
}
</style>