fix some extention bug
This commit is contained in:
@@ -3,7 +3,9 @@ import { computed } from 'vue'
|
||||
import Vue3PersianDatetimePicker from 'vue3-persian-datetime-picker'
|
||||
import {
|
||||
iranDateInputToIso,
|
||||
isoToIranDateInput,
|
||||
iranYmd,
|
||||
isoToJalaliInput,
|
||||
jalaliInputToIso,
|
||||
todayIranDateInput,
|
||||
} from '@/utils/datetime'
|
||||
|
||||
@@ -34,8 +36,10 @@ const emit = defineEmits<{
|
||||
const pickerValue = computed({
|
||||
get() {
|
||||
if (!props.modelValue) return ''
|
||||
if (props.valueType === 'ymd') return props.modelValue
|
||||
return isoToIranDateInput(props.modelValue)
|
||||
if (props.valueType === 'ymd') {
|
||||
return isoToJalaliInput(iranDateInputToIso(props.modelValue))
|
||||
}
|
||||
return isoToJalaliInput(props.modelValue)
|
||||
},
|
||||
set(val: string | null | undefined) {
|
||||
const raw = (val || '').trim()
|
||||
@@ -43,11 +47,13 @@ const pickerValue = computed({
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const iso = jalaliInputToIso(raw)
|
||||
if (!iso) return
|
||||
if (props.valueType === 'ymd') {
|
||||
emit('update:modelValue', raw)
|
||||
emit('update:modelValue', iranYmd(iso))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', iranDateInputToIso(raw))
|
||||
emit('update:modelValue', iso)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -65,11 +71,13 @@ function setToday() {
|
||||
<div class="jalali-date-field" :class="[`size-${size}`]">
|
||||
<Vue3PersianDatetimePicker
|
||||
v-model="pickerValue"
|
||||
simple
|
||||
auto-submit
|
||||
append-to="body"
|
||||
color="#20c997"
|
||||
format="jYYYY/jMM/jDD"
|
||||
display-format="jYYYY/jMM/jDD"
|
||||
:disabled="disabled"
|
||||
:clearable="clearable"
|
||||
format="YYYY-MM-DD"
|
||||
display-format="jYYYY/jMM/jDD"
|
||||
:placeholder="placeholder"
|
||||
input-class="jalali-date-input"
|
||||
class="jalali-picker"
|
||||
@@ -144,3 +152,10 @@ function setToday() {
|
||||
color: #0f766e;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Picker is appended to body so it can sit above Ant modal */
|
||||
.vpd-wrapper {
|
||||
z-index: 11000 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
+346
-65
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
@@ -7,7 +7,14 @@ import api from '@/api/http'
|
||||
import JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||
import MoneyInput from '@/components/MoneyInput.vue'
|
||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.vue'
|
||||
import { formatIranDate, todayIranDateInput, iranDateInputToIso } from '@/utils/datetime'
|
||||
import {
|
||||
addIranDaysToIso,
|
||||
addIranPlanExpiryIso,
|
||||
formatIranDate,
|
||||
iranDateInputToIso,
|
||||
iranDaysBetween,
|
||||
todayIranDateInput,
|
||||
} from '@/utils/datetime'
|
||||
import { formatMoney } from '@/utils/money'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@@ -48,6 +55,7 @@ const pluginStoreEnabled = computed(() => tenant.value?.pluginStoreEnabled === t
|
||||
const selectedId = ref<string | null>(null)
|
||||
const showCreate = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const detailTab = ref<'info' | 'credit' | 'renew'>('info')
|
||||
|
||||
const tenant = ref<{
|
||||
id: string
|
||||
@@ -68,6 +76,7 @@ const form = reactive({
|
||||
description: '',
|
||||
priceRials: 0 as number,
|
||||
chargedAt: null as string | null,
|
||||
expiresAt: null as string | null,
|
||||
planMonths: 1 as number,
|
||||
alertDaysBefore: 3,
|
||||
isActive: true,
|
||||
@@ -89,11 +98,14 @@ const createForm = reactive({
|
||||
|
||||
const renewForm = reactive({
|
||||
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
||||
expiresAt: addIranPlanExpiryIso(iranDateInputToIso(todayIranDateInput()) || '', 1),
|
||||
planMonths: 1,
|
||||
priceRials: 0 as number,
|
||||
note: '',
|
||||
})
|
||||
|
||||
const skipPeriodSync = ref(false)
|
||||
|
||||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||||
|
||||
const selected = computed(() => plugins.value.find((p) => p.id === selectedId.value) || null)
|
||||
@@ -133,12 +145,18 @@ function toneClass(level?: ExpiryLevel) {
|
||||
}
|
||||
|
||||
function fillForm(plugin: PluginItem) {
|
||||
skipPeriodSync.value = true
|
||||
Object.assign(form, {
|
||||
name: plugin.name || '',
|
||||
specs: plugin.specs || '',
|
||||
description: plugin.description || '',
|
||||
priceRials: plugin.priceRials ?? 0,
|
||||
chargedAt: plugin.chargedAt || null,
|
||||
expiresAt:
|
||||
plugin.expiresAt ||
|
||||
(plugin.chargedAt && plugin.planMonths
|
||||
? addIranPlanExpiryIso(plugin.chargedAt, plugin.planMonths)
|
||||
: null),
|
||||
planMonths: plugin.planMonths || 1,
|
||||
alertDaysBefore: plugin.alertDaysBefore ?? 3,
|
||||
isActive: plugin.isActive !== false,
|
||||
@@ -148,9 +166,118 @@ function fillForm(plugin: PluginItem) {
|
||||
renewForm.planMonths = plugin.planMonths || 1
|
||||
renewForm.priceRials = plugin.priceRials ?? 0
|
||||
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||
renewForm.expiresAt = addIranPlanExpiryIso(renewForm.chargedAt || '', renewForm.planMonths)
|
||||
renewForm.note = ''
|
||||
void nextTick(() => {
|
||||
skipPeriodSync.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function shiftExpiryWithCharge(
|
||||
target: { chargedAt: string | null; expiresAt: string | null; planMonths: number },
|
||||
next: string | null,
|
||||
prev: string | null | undefined,
|
||||
) {
|
||||
if (!next) return
|
||||
if (target.expiresAt && prev) {
|
||||
const delta = iranDaysBetween(prev, next) ?? 0
|
||||
target.expiresAt = addIranDaysToIso(target.expiresAt, delta)
|
||||
return
|
||||
}
|
||||
if (target.planMonths) {
|
||||
target.expiresAt = addIranPlanExpiryIso(next, target.planMonths)
|
||||
}
|
||||
}
|
||||
|
||||
const durationDays = computed({
|
||||
get() {
|
||||
if (!form.chargedAt || !form.expiresAt) return 0
|
||||
return iranDaysBetween(form.chargedAt, form.expiresAt) ?? 0
|
||||
},
|
||||
set(days: number | null) {
|
||||
if (!form.chargedAt) return
|
||||
form.expiresAt = addIranDaysToIso(form.chargedAt, Math.max(0, Number(days) || 0))
|
||||
},
|
||||
})
|
||||
|
||||
const renewDurationDays = computed({
|
||||
get() {
|
||||
if (!renewForm.chargedAt || !renewForm.expiresAt) return 0
|
||||
return iranDaysBetween(renewForm.chargedAt, renewForm.expiresAt) ?? 0
|
||||
},
|
||||
set(days: number | null) {
|
||||
if (!renewForm.chargedAt) return
|
||||
renewForm.expiresAt = addIranDaysToIso(renewForm.chargedAt, Math.max(0, Number(days) || 0))
|
||||
},
|
||||
})
|
||||
|
||||
const liveDaysLeft = computed(() =>
|
||||
form.expiresAt ? iranDaysBetween(new Date(), form.expiresAt) : null,
|
||||
)
|
||||
|
||||
function expiryCaption(daysLeft: number | null) {
|
||||
if (daysLeft == null) return 'بدون تاریخ اعتبار'
|
||||
if (daysLeft < 0) return `${Math.abs(daysLeft)} روز گذشته از انقضا`
|
||||
if (daysLeft === 0) return 'امروز منقضی میشود'
|
||||
return `${daysLeft} روز مانده`
|
||||
}
|
||||
|
||||
function pluginDaysLeft(plugin: PluginItem) {
|
||||
return plugin.expiresAt ? iranDaysBetween(new Date(), plugin.expiresAt) : null
|
||||
}
|
||||
|
||||
function pluginExpiryLabel(plugin: PluginItem) {
|
||||
return expiryCaption(pluginDaysLeft(plugin))
|
||||
}
|
||||
|
||||
function pluginExpiryTone(plugin: PluginItem): ExpiryLevel {
|
||||
const days = pluginDaysLeft(plugin)
|
||||
const threshold = plugin.alertDaysBefore ?? 3
|
||||
if (days == null) return 'unknown'
|
||||
if (days <= threshold) return 'critical'
|
||||
if (days <= threshold + 4) return 'warning'
|
||||
return 'healthy'
|
||||
}
|
||||
|
||||
const liveExpiryLabel = computed(() => expiryCaption(liveDaysLeft.value))
|
||||
const liveExpiryLevel = computed((): ExpiryLevel => {
|
||||
const days = liveDaysLeft.value
|
||||
const threshold = form.alertDaysBefore ?? 3
|
||||
if (days == null) return 'unknown'
|
||||
if (days <= threshold) return 'critical'
|
||||
if (days <= threshold + 4) return 'warning'
|
||||
return 'healthy'
|
||||
})
|
||||
|
||||
watch(
|
||||
() => form.chargedAt,
|
||||
(next, prev) => {
|
||||
if (skipPeriodSync.value) return
|
||||
shiftExpiryWithCharge(form, next, prev)
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => form.planMonths,
|
||||
(months) => {
|
||||
if (skipPeriodSync.value || !form.chargedAt || !months) return
|
||||
form.expiresAt = addIranPlanExpiryIso(form.chargedAt, months)
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => renewForm.chargedAt,
|
||||
(next, prev) => {
|
||||
if (skipPeriodSync.value) return
|
||||
shiftExpiryWithCharge(renewForm, next, prev)
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => renewForm.planMonths,
|
||||
(months) => {
|
||||
if (skipPeriodSync.value || !renewForm.chargedAt || !months) return
|
||||
renewForm.expiresAt = addIranPlanExpiryIso(renewForm.chargedAt, months)
|
||||
},
|
||||
)
|
||||
|
||||
function selectPlugin(id: string) {
|
||||
selectedId.value = id
|
||||
const plugin = plugins.value.find((p) => p.id === id)
|
||||
@@ -159,11 +286,13 @@ function selectPlugin(id: string) {
|
||||
|
||||
function openPlugin(id: string) {
|
||||
selectPlugin(id)
|
||||
detailTab.value = 'info'
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
showDetail.value = false
|
||||
detailTab.value = 'info'
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
@@ -246,6 +375,7 @@ async function savePlugin() {
|
||||
description: form.description || null,
|
||||
priceRials: Number(form.priceRials) || 0,
|
||||
chargedAt: form.chargedAt,
|
||||
expiresAt: form.expiresAt,
|
||||
planMonths: Number(form.planMonths),
|
||||
alertDaysBefore: Number(form.alertDaysBefore) || 3,
|
||||
isActive: form.isActive,
|
||||
@@ -274,6 +404,7 @@ async function renewPlugin() {
|
||||
try {
|
||||
await api.post(`/plugins/items/${selectedId.value}/renew`, {
|
||||
chargedAt: renewForm.chargedAt,
|
||||
expiresAt: renewForm.expiresAt,
|
||||
planMonths: Number(renewForm.planMonths),
|
||||
priceRials: Number(renewForm.priceRials),
|
||||
note: renewForm.note || undefined,
|
||||
@@ -394,11 +525,11 @@ onMounted(loadDetail)
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="plugin-card"
|
||||
:class="toneClass(p.summary?.expiryLevel)"
|
||||
:class="toneClass(pluginExpiryTone(p))"
|
||||
@click="openPlugin(p.id)"
|
||||
>
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span>{{ p.summary?.expiryLabel }}</span>
|
||||
<span>{{ pluginExpiryLabel(p) }}</span>
|
||||
</button>
|
||||
<div v-if="!filteredActivePlugins.length" class="empty-hint">
|
||||
{{ pluginQuery.trim() ? 'افزونهای با این جستجو نیست.' : 'افزونهٔ فعالی نیست — با «افزونه جدید» ثبت کنید.' }}
|
||||
@@ -416,11 +547,11 @@ onMounted(loadDetail)
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="plugin-card inactive"
|
||||
:class="toneClass(p.summary?.expiryLevel)"
|
||||
:class="toneClass(pluginExpiryTone(p))"
|
||||
@click="openPlugin(p.id)"
|
||||
>
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span>{{ p.summary?.expiryLabel }}</span>
|
||||
<span>{{ pluginExpiryLabel(p) }}</span>
|
||||
<small>غیرفعال</small>
|
||||
</button>
|
||||
</div>
|
||||
@@ -431,23 +562,51 @@ onMounted(loadDetail)
|
||||
<a-modal
|
||||
v-model:open="showDetail"
|
||||
:title="selected?.name || 'جزئیات افزونه'"
|
||||
width="40rem"
|
||||
wrap-class-name="sa-modal-wrap"
|
||||
width="48rem"
|
||||
wrap-class-name="sa-modal-wrap plugin-detail-modal"
|
||||
:footer="null"
|
||||
@cancel="closeDetail"
|
||||
>
|
||||
<div v-if="selected" class="detail">
|
||||
<div class="status-banner" :class="toneClass(selected.summary?.expiryLevel)">
|
||||
<strong>{{ selected.name }} — {{ selected.summary?.expiryLabel }}</strong>
|
||||
<span v-if="selected.summary?.expiresAt">
|
||||
انقضا: {{ formatIranDate(selected.summary.expiresAt) }}
|
||||
<div class="status-banner" :class="toneClass(liveExpiryLevel)">
|
||||
<strong>{{ selected.name }} — {{ liveExpiryLabel }}</strong>
|
||||
<span v-if="form.expiresAt">
|
||||
انقضا: {{ formatIranDate(form.expiresAt) }}
|
||||
<template v-if="durationDays"> · {{ durationDays }} روز از شارژ</template>
|
||||
<template v-if="selected.summary?.planLabel"> · {{ selected.summary.planLabel }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="detail-tabs" role="tablist">
|
||||
<a-button
|
||||
:type="detailTab === 'info' ? 'primary' : 'default'"
|
||||
ghost
|
||||
@click="detailTab = 'info'"
|
||||
>
|
||||
مشخصات
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="detailTab === 'credit' ? 'primary' : 'default'"
|
||||
ghost
|
||||
@click="detailTab = 'credit'"
|
||||
>
|
||||
اعتبار
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="detailTab === 'renew' ? 'primary' : 'default'"
|
||||
ghost
|
||||
@click="detailTab = 'renew'"
|
||||
>
|
||||
تمدید
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section v-show="detailTab === 'info'" class="detail-pane">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>مشخصات افزونه</h3>
|
||||
<p class="field-hint">نام، وضعیت فعال و کلید فروشگاه.</p>
|
||||
</div>
|
||||
<a-button v-if="canWrite" danger type="text" @click="confirmDelete">
|
||||
<DeleteOutlined /> حذف
|
||||
</a-button>
|
||||
@@ -456,17 +615,31 @@ onMounted(loadDetail)
|
||||
<a-form-item label="نام افزونه" required>
|
||||
<a-input v-model:value="form.name" size="large" placeholder="مثلاً ماژول حسابداری / پنل طلا" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="[12, 0]">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="مشخصات">
|
||||
<a-textarea v-model:value="form.specs" :rows="2" placeholder="جزئیات فنی یا یادداشت داخلی…" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="توضیحات">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="[12, 8]">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="فعال">
|
||||
<a-switch v-model:checked="form.isActive" />
|
||||
</a-form-item>
|
||||
<template v-if="isKhanegi">
|
||||
<a-form-item label="کلید تنظیمات (فروشگاه)">
|
||||
</a-col>
|
||||
<a-col v-if="isKhanegi" :xs="24" :sm="12">
|
||||
<a-form-item label="قطع خودکار بعد از انقضا">
|
||||
<a-switch v-model:checked="form.autoCutOnExpiry" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item v-if="isKhanegi" label="کلید تنظیمات (فروشگاه)">
|
||||
<a-select
|
||||
v-model:value="form.settingKey"
|
||||
allow-clear
|
||||
@@ -474,23 +647,48 @@ onMounted(loadDetail)
|
||||
:options="catalogKeys.map((c) => ({ value: c.settingKey, label: `${c.titleFa} (${c.settingKey})` }))"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="قطع خودکار بعد از انقضا">
|
||||
<a-switch v-model:checked="form.autoCutOnExpiry" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-form>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="saving"
|
||||
@click="savePlugin"
|
||||
>
|
||||
ذخیره مشخصات
|
||||
</a-button>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>اعتبار و قیمت (وضعیت فعلی)</h3>
|
||||
<section v-show="detailTab === 'credit'" class="detail-pane">
|
||||
<h3>اعتبار فعلی</h3>
|
||||
<p class="field-hint">
|
||||
تاریخ شارژ را عقب/جلو کنید تا انقضا با همان فاصله حرکت کند. تعداد روز را کموزیاد کنید تا فقط انقضا جابهجا شود.
|
||||
</p>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-row :gutter="[12, 0]">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="تاریخ شارژ (شمسی)">
|
||||
<JalaliDatePicker v-model="form.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="تاریخ انقضا (شمسی)">
|
||||
<JalaliDatePicker v-model="form.expiresAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="فاصله تا انقضا (روز)">
|
||||
<a-input-number
|
||||
v-model:value="durationDays"
|
||||
:min="0"
|
||||
:max="4000"
|
||||
size="large"
|
||||
class="full-field"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="مدت شارژ">
|
||||
<a-select
|
||||
v-model:value="form.planMonths"
|
||||
@@ -499,44 +697,66 @@ onMounted(loadDetail)
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="قیمت دوره (تومان)">
|
||||
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 150,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="شروع هشدار SMS (روز مانده به انقضا)">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="هشدار SMS (روز مانده)">
|
||||
<a-input-number
|
||||
v-model:value="form.alertDaysBefore"
|
||||
:min="1"
|
||||
:max="30"
|
||||
size="large"
|
||||
style="width: 160px"
|
||||
class="full-field"
|
||||
/>
|
||||
<span class="inline-hint">پیشفرض ۳ روز — روزانه یک پیام تا تمدید</span>
|
||||
<span class="inline-hint">پیشفرض ۳ روز — روزانه تا تمدید</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<a-button
|
||||
v-if="canWrite"
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="saving"
|
||||
@click="savePlugin"
|
||||
>
|
||||
ذخیره اعتبار
|
||||
</a-button>
|
||||
</section>
|
||||
|
||||
<div v-if="canWrite" class="sticky-actions settings-save">
|
||||
<a-button type="primary" size="large" block :loading="saving" @click="savePlugin">
|
||||
ذخیره تنظیمات
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<section v-if="canWrite" class="form-section renew-section">
|
||||
<section v-show="detailTab === 'renew'" class="detail-pane">
|
||||
<h3>شارژ / تمدید جدید</h3>
|
||||
<p class="field-hint">
|
||||
با ثبت شارژ، تاریخ انقضا تمدید میشود و هشدارهای قبلی قطع میگردد تا دوباره نزدیک پایان برسد.
|
||||
دورهٔ جدید ثبت میشود؛ انقضا عوض میشود و هشدارهای قبلی تا نزدیک پایان قطع میگردد.
|
||||
</p>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="12">
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-form v-if="canWrite" layout="vertical">
|
||||
<a-row :gutter="[12, 0]">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="تاریخ شروع دوره (شمسی)">
|
||||
<JalaliDatePicker v-model="renewForm.chargedAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="تاریخ انقضا (شمسی)">
|
||||
<JalaliDatePicker v-model="renewForm.expiresAt" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="فاصله تا انقضا (روز)">
|
||||
<a-input-number
|
||||
v-model:value="renewDurationDays"
|
||||
:min="0"
|
||||
:max="4000"
|
||||
size="large"
|
||||
class="full-field"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="مدت">
|
||||
<a-select
|
||||
v-model:value="renewForm.planMonths"
|
||||
@@ -545,23 +765,23 @@ onMounted(loadDetail)
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="8">
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="مبلغ (تومان)">
|
||||
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 150,000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-col :xs="24" :sm="12">
|
||||
<a-form-item label="یادداشت">
|
||||
<a-input v-model:value="renewForm.note" />
|
||||
<a-input v-model:value="renewForm.note" size="large" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" size="large" :loading="renewing" @click="renewPlugin">
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-button type="primary" size="large" block :loading="renewing" @click="renewPlugin">
|
||||
ثبت شارژ / تمدید
|
||||
</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>تاریخچه شارژ</h3>
|
||||
<h3 class="history-head">تاریخچه شارژ</h3>
|
||||
<div v-if="selected.charges?.length" class="charge-list">
|
||||
<article v-for="item in selected.charges" :key="item.id" class="charge-item">
|
||||
<div>
|
||||
@@ -724,12 +944,81 @@ onMounted(loadDetail)
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
.detail-tabs {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
margin-bottom: 12px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.ant-btn) {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 10px;
|
||||
padding-inline: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.detail-tabs :deep(.ant-btn-primary) {
|
||||
background: #fff;
|
||||
color: #0f766e;
|
||||
box-shadow: 0 1px 4px rgba(45, 55, 72, 0.08);
|
||||
}
|
||||
|
||||
.detail-pane h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.98rem;
|
||||
color: #17324d;
|
||||
}
|
||||
|
||||
.detail-pane .section-head {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.detail-pane .section-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-pane .section-head .field-hint {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.full-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detail-pane :deep(.ant-input-number),
|
||||
.detail-pane :deep(.ant-select),
|
||||
.detail-pane :deep(.jalali-date-field) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
margin: 18px 0 8px;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.status-banner strong,
|
||||
.status-banner span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.status-banner.healthy { background: #dff8ea; color: #0f7a45; }
|
||||
@@ -746,23 +1035,15 @@ onMounted(loadDetail)
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0 0 8px;
|
||||
margin: 0 0 10px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.settings-save {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.renew-section {
|
||||
margin-top: 28px;
|
||||
padding-top: 22px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.inline-hint {
|
||||
margin-right: 10px;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,11 @@ button, input, textarea, select,
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.plugin-detail-modal .ant-modal-body {
|
||||
max-height: min(78vh, 760px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dash-modules {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -61,3 +61,123 @@ export function isoToIranDateInput(iso?: string | null): string {
|
||||
export function todayIranDateInput(): string {
|
||||
return iranYmd(new Date())
|
||||
}
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
function latinDigits(value: string) {
|
||||
return value
|
||||
.replace(/[۰-۹]/g, (ch) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(ch)))
|
||||
.replace(/[٠-٩]/g, (ch) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(ch)))
|
||||
}
|
||||
|
||||
/** Jalali y/m/d → Gregorian (jalaali-js algorithm). */
|
||||
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
|
||||
let gy = jy <= 979 ? 621 : 1600
|
||||
const jy0 = jy <= 979 ? jy : jy - 979
|
||||
let days =
|
||||
365 * jy0 +
|
||||
Math.floor(jy0 / 33) * 8 +
|
||||
Math.floor(((jy0 % 33) + 3) / 4) +
|
||||
78 +
|
||||
jd +
|
||||
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186)
|
||||
gy += 400 * Math.floor(days / 146097)
|
||||
days %= 146097
|
||||
if (days > 36524) {
|
||||
gy += 100 * Math.floor(--days / 36524)
|
||||
days %= 36524
|
||||
if (days >= 365) days++
|
||||
}
|
||||
gy += 4 * Math.floor(days / 1461)
|
||||
days %= 1461
|
||||
gy += Math.floor((days - 1) / 365)
|
||||
if (days > 365) days = (days - 1) % 365
|
||||
let gd = days + 1
|
||||
const leap = (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0
|
||||
const sal = [0, 31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
let gm = 1
|
||||
for (; gm <= 12 && gd > sal[gm]; gm++) gd -= sal[gm]
|
||||
return { gy, gm, gd }
|
||||
}
|
||||
|
||||
function jalaliMonthLength(year: number, month: number) {
|
||||
if (month <= 6) return 31
|
||||
if (month <= 11) return 30
|
||||
const a = (year - (year > 0 ? 474 : 473)) % 2820
|
||||
const leap = ((((a + 474 + 38) * 682) % 2816) < 682)
|
||||
return leap ? 30 : 29
|
||||
}
|
||||
|
||||
/** ISO/Date → `1405/06/21` for the Jalali picker */
|
||||
export function isoToJalaliInput(iso?: string | Date | null): string {
|
||||
const d = toDate(iso)
|
||||
if (!d) return ''
|
||||
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
timeZone: IRAN_TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
numberingSystem: 'latn',
|
||||
}).formatToParts(d)
|
||||
const num = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((p) => p.type === type)?.value ?? ''
|
||||
const year = num('year')
|
||||
const month = num('month')
|
||||
const day = num('day')
|
||||
if (!year || !month || !day) return ''
|
||||
return `${year}/${month}/${day}`
|
||||
}
|
||||
|
||||
function jalaliPartsFromDate(value: Date) {
|
||||
const [year, month, day] = isoToJalaliInput(value).split('/').map(Number)
|
||||
return { year, month, day }
|
||||
}
|
||||
|
||||
/** `1405/06/21` or `1405-06-21` → ISO noon Iran */
|
||||
export function jalaliInputToIso(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
const m = latinDigits(value.trim()).match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/)
|
||||
if (!m) return null
|
||||
const jy = Number(m[1])
|
||||
const jm = Number(m[2])
|
||||
const jd = Number(m[3])
|
||||
if (jy < 1200 || jy > 1600 || jm < 1 || jm > 12 || jd < 1 || jd > 31) return null
|
||||
const { gy, gm, gd } = jalaliToGregorian(jy, jm, jd)
|
||||
return iranDateInputToIso(`${gy}-${pad2(gm)}-${pad2(gd)}`)
|
||||
}
|
||||
|
||||
/** Whole Iran-calendar days from `from` to `to` (negative if `to` is earlier). */
|
||||
export function iranDaysBetween(
|
||||
from?: string | Date | number | null,
|
||||
to?: string | Date | number | null,
|
||||
): number | null {
|
||||
const a = toDate(from)
|
||||
const b = toDate(to)
|
||||
if (!a || !b) return null
|
||||
const start = Date.parse(`${iranYmd(a)}T12:00:00+03:30`)
|
||||
const end = Date.parse(`${iranYmd(b)}T12:00:00+03:30`)
|
||||
return Math.round((end - start) / 86_400_000)
|
||||
}
|
||||
|
||||
export function addIranDaysToIso(iso: string, days: number): string {
|
||||
const ymd = iranYmd(iso)
|
||||
const base = Date.parse(`${ymd}T12:00:00+03:30`)
|
||||
return new Date(base + days * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
/** Same rule as SuperAdmin API: start + N Jalali months, minus 1 day. */
|
||||
export function addIranPlanExpiryIso(iso: string, months: number): string | null {
|
||||
const d = toDate(iso)
|
||||
if (!d || !Number.isFinite(months)) return null
|
||||
const { year, month, day } = jalaliPartsFromDate(d)
|
||||
const total = year * 12 + (month - 1) + months
|
||||
const nextYear = Math.floor(total / 12)
|
||||
const nextMonth = (total % 12) + 1
|
||||
const nextDay = Math.min(day, jalaliMonthLength(nextYear, nextMonth))
|
||||
const { gy, gm, gd } = jalaliToGregorian(nextYear, nextMonth, nextDay)
|
||||
const plusMonths = iranDateInputToIso(`${gy}-${pad2(gm)}-${pad2(gd)}`)
|
||||
if (!plusMonths) return null
|
||||
return addIranDaysToIso(plusMonths, -1)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user