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 Vue3PersianDatetimePicker from 'vue3-persian-datetime-picker'
|
||||||
import {
|
import {
|
||||||
iranDateInputToIso,
|
iranDateInputToIso,
|
||||||
isoToIranDateInput,
|
iranYmd,
|
||||||
|
isoToJalaliInput,
|
||||||
|
jalaliInputToIso,
|
||||||
todayIranDateInput,
|
todayIranDateInput,
|
||||||
} from '@/utils/datetime'
|
} from '@/utils/datetime'
|
||||||
|
|
||||||
@@ -34,8 +36,10 @@ const emit = defineEmits<{
|
|||||||
const pickerValue = computed({
|
const pickerValue = computed({
|
||||||
get() {
|
get() {
|
||||||
if (!props.modelValue) return ''
|
if (!props.modelValue) return ''
|
||||||
if (props.valueType === 'ymd') return props.modelValue
|
if (props.valueType === 'ymd') {
|
||||||
return isoToIranDateInput(props.modelValue)
|
return isoToJalaliInput(iranDateInputToIso(props.modelValue))
|
||||||
|
}
|
||||||
|
return isoToJalaliInput(props.modelValue)
|
||||||
},
|
},
|
||||||
set(val: string | null | undefined) {
|
set(val: string | null | undefined) {
|
||||||
const raw = (val || '').trim()
|
const raw = (val || '').trim()
|
||||||
@@ -43,11 +47,13 @@ const pickerValue = computed({
|
|||||||
emit('update:modelValue', null)
|
emit('update:modelValue', null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const iso = jalaliInputToIso(raw)
|
||||||
|
if (!iso) return
|
||||||
if (props.valueType === 'ymd') {
|
if (props.valueType === 'ymd') {
|
||||||
emit('update:modelValue', raw)
|
emit('update:modelValue', iranYmd(iso))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emit('update:modelValue', iranDateInputToIso(raw))
|
emit('update:modelValue', iso)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,11 +71,13 @@ function setToday() {
|
|||||||
<div class="jalali-date-field" :class="[`size-${size}`]">
|
<div class="jalali-date-field" :class="[`size-${size}`]">
|
||||||
<Vue3PersianDatetimePicker
|
<Vue3PersianDatetimePicker
|
||||||
v-model="pickerValue"
|
v-model="pickerValue"
|
||||||
simple
|
auto-submit
|
||||||
|
append-to="body"
|
||||||
|
color="#20c997"
|
||||||
|
format="jYYYY/jMM/jDD"
|
||||||
|
display-format="jYYYY/jMM/jDD"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:clearable="clearable"
|
:clearable="clearable"
|
||||||
format="YYYY-MM-DD"
|
|
||||||
display-format="jYYYY/jMM/jDD"
|
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
input-class="jalali-date-input"
|
input-class="jalali-date-input"
|
||||||
class="jalali-picker"
|
class="jalali-picker"
|
||||||
@@ -144,3 +152,10 @@ function setToday() {
|
|||||||
color: #0f766e;
|
color: #0f766e;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Picker is appended to body so it can sit above Ant modal */
|
||||||
|
.vpd-wrapper {
|
||||||
|
z-index: 11000 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+369
-88
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons-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 JalaliDatePicker from '@/components/JalaliDatePicker.vue'
|
||||||
import MoneyInput from '@/components/MoneyInput.vue'
|
import MoneyInput from '@/components/MoneyInput.vue'
|
||||||
import TenantPanelShortcuts from '@/components/TenantPanelShortcuts.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 { formatMoney } from '@/utils/money'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
@@ -48,6 +55,7 @@ const pluginStoreEnabled = computed(() => tenant.value?.pluginStoreEnabled === t
|
|||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const showDetail = ref(false)
|
const showDetail = ref(false)
|
||||||
|
const detailTab = ref<'info' | 'credit' | 'renew'>('info')
|
||||||
|
|
||||||
const tenant = ref<{
|
const tenant = ref<{
|
||||||
id: string
|
id: string
|
||||||
@@ -68,6 +76,7 @@ const form = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
priceRials: 0 as number,
|
priceRials: 0 as number,
|
||||||
chargedAt: null as string | null,
|
chargedAt: null as string | null,
|
||||||
|
expiresAt: null as string | null,
|
||||||
planMonths: 1 as number,
|
planMonths: 1 as number,
|
||||||
alertDaysBefore: 3,
|
alertDaysBefore: 3,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -89,11 +98,14 @@ const createForm = reactive({
|
|||||||
|
|
||||||
const renewForm = reactive({
|
const renewForm = reactive({
|
||||||
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
|
||||||
|
expiresAt: addIranPlanExpiryIso(iranDateInputToIso(todayIranDateInput()) || '', 1),
|
||||||
planMonths: 1,
|
planMonths: 1,
|
||||||
priceRials: 0 as number,
|
priceRials: 0 as number,
|
||||||
note: '',
|
note: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const skipPeriodSync = ref(false)
|
||||||
|
|
||||||
const tenantId = computed(() => String(route.params.tenantId || ''))
|
const tenantId = computed(() => String(route.params.tenantId || ''))
|
||||||
|
|
||||||
const selected = computed(() => plugins.value.find((p) => p.id === selectedId.value) || null)
|
const selected = computed(() => plugins.value.find((p) => p.id === selectedId.value) || null)
|
||||||
@@ -133,12 +145,18 @@ function toneClass(level?: ExpiryLevel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fillForm(plugin: PluginItem) {
|
function fillForm(plugin: PluginItem) {
|
||||||
|
skipPeriodSync.value = true
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
name: plugin.name || '',
|
name: plugin.name || '',
|
||||||
specs: plugin.specs || '',
|
specs: plugin.specs || '',
|
||||||
description: plugin.description || '',
|
description: plugin.description || '',
|
||||||
priceRials: plugin.priceRials ?? 0,
|
priceRials: plugin.priceRials ?? 0,
|
||||||
chargedAt: plugin.chargedAt || null,
|
chargedAt: plugin.chargedAt || null,
|
||||||
|
expiresAt:
|
||||||
|
plugin.expiresAt ||
|
||||||
|
(plugin.chargedAt && plugin.planMonths
|
||||||
|
? addIranPlanExpiryIso(plugin.chargedAt, plugin.planMonths)
|
||||||
|
: null),
|
||||||
planMonths: plugin.planMonths || 1,
|
planMonths: plugin.planMonths || 1,
|
||||||
alertDaysBefore: plugin.alertDaysBefore ?? 3,
|
alertDaysBefore: plugin.alertDaysBefore ?? 3,
|
||||||
isActive: plugin.isActive !== false,
|
isActive: plugin.isActive !== false,
|
||||||
@@ -148,9 +166,118 @@ function fillForm(plugin: PluginItem) {
|
|||||||
renewForm.planMonths = plugin.planMonths || 1
|
renewForm.planMonths = plugin.planMonths || 1
|
||||||
renewForm.priceRials = plugin.priceRials ?? 0
|
renewForm.priceRials = plugin.priceRials ?? 0
|
||||||
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
|
||||||
|
renewForm.expiresAt = addIranPlanExpiryIso(renewForm.chargedAt || '', renewForm.planMonths)
|
||||||
renewForm.note = ''
|
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) {
|
function selectPlugin(id: string) {
|
||||||
selectedId.value = id
|
selectedId.value = id
|
||||||
const plugin = plugins.value.find((p) => p.id === id)
|
const plugin = plugins.value.find((p) => p.id === id)
|
||||||
@@ -159,11 +286,13 @@ function selectPlugin(id: string) {
|
|||||||
|
|
||||||
function openPlugin(id: string) {
|
function openPlugin(id: string) {
|
||||||
selectPlugin(id)
|
selectPlugin(id)
|
||||||
|
detailTab.value = 'info'
|
||||||
showDetail.value = true
|
showDetail.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
showDetail.value = false
|
showDetail.value = false
|
||||||
|
detailTab.value = 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
@@ -246,6 +375,7 @@ async function savePlugin() {
|
|||||||
description: form.description || null,
|
description: form.description || null,
|
||||||
priceRials: Number(form.priceRials) || 0,
|
priceRials: Number(form.priceRials) || 0,
|
||||||
chargedAt: form.chargedAt,
|
chargedAt: form.chargedAt,
|
||||||
|
expiresAt: form.expiresAt,
|
||||||
planMonths: Number(form.planMonths),
|
planMonths: Number(form.planMonths),
|
||||||
alertDaysBefore: Number(form.alertDaysBefore) || 3,
|
alertDaysBefore: Number(form.alertDaysBefore) || 3,
|
||||||
isActive: form.isActive,
|
isActive: form.isActive,
|
||||||
@@ -274,6 +404,7 @@ async function renewPlugin() {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/plugins/items/${selectedId.value}/renew`, {
|
await api.post(`/plugins/items/${selectedId.value}/renew`, {
|
||||||
chargedAt: renewForm.chargedAt,
|
chargedAt: renewForm.chargedAt,
|
||||||
|
expiresAt: renewForm.expiresAt,
|
||||||
planMonths: Number(renewForm.planMonths),
|
planMonths: Number(renewForm.planMonths),
|
||||||
priceRials: Number(renewForm.priceRials),
|
priceRials: Number(renewForm.priceRials),
|
||||||
note: renewForm.note || undefined,
|
note: renewForm.note || undefined,
|
||||||
@@ -394,11 +525,11 @@ onMounted(loadDetail)
|
|||||||
:key="p.id"
|
:key="p.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="plugin-card"
|
class="plugin-card"
|
||||||
:class="toneClass(p.summary?.expiryLevel)"
|
:class="toneClass(pluginExpiryTone(p))"
|
||||||
@click="openPlugin(p.id)"
|
@click="openPlugin(p.id)"
|
||||||
>
|
>
|
||||||
<strong>{{ p.name }}</strong>
|
<strong>{{ p.name }}</strong>
|
||||||
<span>{{ p.summary?.expiryLabel }}</span>
|
<span>{{ pluginExpiryLabel(p) }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="!filteredActivePlugins.length" class="empty-hint">
|
<div v-if="!filteredActivePlugins.length" class="empty-hint">
|
||||||
{{ pluginQuery.trim() ? 'افزونهای با این جستجو نیست.' : 'افزونهٔ فعالی نیست — با «افزونه جدید» ثبت کنید.' }}
|
{{ pluginQuery.trim() ? 'افزونهای با این جستجو نیست.' : 'افزونهٔ فعالی نیست — با «افزونه جدید» ثبت کنید.' }}
|
||||||
@@ -416,11 +547,11 @@ onMounted(loadDetail)
|
|||||||
:key="p.id"
|
:key="p.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="plugin-card inactive"
|
class="plugin-card inactive"
|
||||||
:class="toneClass(p.summary?.expiryLevel)"
|
:class="toneClass(pluginExpiryTone(p))"
|
||||||
@click="openPlugin(p.id)"
|
@click="openPlugin(p.id)"
|
||||||
>
|
>
|
||||||
<strong>{{ p.name }}</strong>
|
<strong>{{ p.name }}</strong>
|
||||||
<span>{{ p.summary?.expiryLabel }}</span>
|
<span>{{ pluginExpiryLabel(p) }}</span>
|
||||||
<small>غیرفعال</small>
|
<small>غیرفعال</small>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,23 +562,51 @@ onMounted(loadDetail)
|
|||||||
<a-modal
|
<a-modal
|
||||||
v-model:open="showDetail"
|
v-model:open="showDetail"
|
||||||
:title="selected?.name || 'جزئیات افزونه'"
|
:title="selected?.name || 'جزئیات افزونه'"
|
||||||
width="40rem"
|
width="48rem"
|
||||||
wrap-class-name="sa-modal-wrap"
|
wrap-class-name="sa-modal-wrap plugin-detail-modal"
|
||||||
:footer="null"
|
:footer="null"
|
||||||
@cancel="closeDetail"
|
@cancel="closeDetail"
|
||||||
>
|
>
|
||||||
<div v-if="selected" class="detail">
|
<div v-if="selected" class="detail">
|
||||||
<div class="status-banner" :class="toneClass(selected.summary?.expiryLevel)">
|
<div class="status-banner" :class="toneClass(liveExpiryLevel)">
|
||||||
<strong>{{ selected.name }} — {{ selected.summary?.expiryLabel }}</strong>
|
<strong>{{ selected.name }} — {{ liveExpiryLabel }}</strong>
|
||||||
<span v-if="selected.summary?.expiresAt">
|
<span v-if="form.expiresAt">
|
||||||
انقضا: {{ formatIranDate(selected.summary.expiresAt) }}
|
انقضا: {{ formatIranDate(form.expiresAt) }}
|
||||||
|
<template v-if="durationDays"> · {{ durationDays }} روز از شارژ</template>
|
||||||
<template v-if="selected.summary?.planLabel"> · {{ selected.summary.planLabel }}</template>
|
<template v-if="selected.summary?.planLabel"> · {{ selected.summary.planLabel }}</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 class="section-head">
|
||||||
<h3>مشخصات افزونه</h3>
|
<div>
|
||||||
|
<h3>مشخصات افزونه</h3>
|
||||||
|
<p class="field-hint">نام، وضعیت فعال و کلید فروشگاه.</p>
|
||||||
|
</div>
|
||||||
<a-button v-if="canWrite" danger type="text" @click="confirmDelete">
|
<a-button v-if="canWrite" danger type="text" @click="confirmDelete">
|
||||||
<DeleteOutlined /> حذف
|
<DeleteOutlined /> حذف
|
||||||
</a-button>
|
</a-button>
|
||||||
@@ -456,41 +615,80 @@ onMounted(loadDetail)
|
|||||||
<a-form-item label="نام افزونه" required>
|
<a-form-item label="نام افزونه" required>
|
||||||
<a-input v-model:value="form.name" size="large" placeholder="مثلاً ماژول حسابداری / پنل طلا" />
|
<a-input v-model:value="form.name" size="large" placeholder="مثلاً ماژول حسابداری / پنل طلا" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="مشخصات">
|
<a-row :gutter="[12, 0]">
|
||||||
<a-textarea v-model:value="form.specs" :rows="2" placeholder="جزئیات فنی یا یادداشت داخلی…" />
|
<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>
|
||||||
|
</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
|
||||||
|
placeholder="دستی — بدون فروشگاه"
|
||||||
|
:options="catalogKeys.map((c) => ({ value: c.settingKey, label: `${c.titleFa} (${c.settingKey})` }))"
|
||||||
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="توضیحات">
|
|
||||||
<a-textarea v-model:value="form.description" :rows="2" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="فعال">
|
|
||||||
<a-switch v-model:checked="form.isActive" />
|
|
||||||
</a-form-item>
|
|
||||||
<template v-if="isKhanegi">
|
|
||||||
<a-form-item label="کلید تنظیمات (فروشگاه)">
|
|
||||||
<a-select
|
|
||||||
v-model:value="form.settingKey"
|
|
||||||
allow-clear
|
|
||||||
placeholder="دستی — بدون فروشگاه"
|
|
||||||
: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-form>
|
||||||
|
<a-button
|
||||||
|
v-if="canWrite"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="saving"
|
||||||
|
@click="savePlugin"
|
||||||
|
>
|
||||||
|
ذخیره مشخصات
|
||||||
|
</a-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="form-section">
|
<section v-show="detailTab === 'credit'" class="detail-pane">
|
||||||
<h3>اعتبار و قیمت (وضعیت فعلی)</h3>
|
<h3>اعتبار فعلی</h3>
|
||||||
|
<p class="field-hint">
|
||||||
|
تاریخ شارژ را عقب/جلو کنید تا انقضا با همان فاصله حرکت کند. تعداد روز را کموزیاد کنید تا فقط انقضا جابهجا شود.
|
||||||
|
</p>
|
||||||
<a-form layout="vertical">
|
<a-form layout="vertical">
|
||||||
<a-row :gutter="12">
|
<a-row :gutter="[12, 0]">
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :sm="12">
|
||||||
<a-form-item label="تاریخ شارژ (شمسی)">
|
<a-form-item label="تاریخ شارژ (شمسی)">
|
||||||
<JalaliDatePicker v-model="form.chargedAt" />
|
<JalaliDatePicker v-model="form.chargedAt" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</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-form-item label="مدت شارژ">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="form.planMonths"
|
v-model:value="form.planMonths"
|
||||||
@@ -499,44 +697,66 @@ onMounted(loadDetail)
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :sm="12">
|
||||||
<a-form-item label="قیمت دوره (تومان)">
|
<a-form-item label="قیمت دوره (تومان)">
|
||||||
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 150,000" />
|
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 150,000" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
<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"
|
||||||
|
class="full-field"
|
||||||
|
/>
|
||||||
|
<span class="inline-hint">پیشفرض ۳ روز — روزانه تا تمدید</span>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-form-item label="شروع هشدار SMS (روز مانده به انقضا)">
|
|
||||||
<a-input-number
|
|
||||||
v-model:value="form.alertDaysBefore"
|
|
||||||
:min="1"
|
|
||||||
:max="30"
|
|
||||||
size="large"
|
|
||||||
style="width: 160px"
|
|
||||||
/>
|
|
||||||
<span class="inline-hint">پیشفرض ۳ روز — روزانه یک پیام تا تمدید</span>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
</a-form>
|
||||||
|
<a-button
|
||||||
|
v-if="canWrite"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="saving"
|
||||||
|
@click="savePlugin"
|
||||||
|
>
|
||||||
|
ذخیره اعتبار
|
||||||
|
</a-button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="canWrite" class="sticky-actions settings-save">
|
<section v-show="detailTab === 'renew'" class="detail-pane">
|
||||||
<a-button type="primary" size="large" block :loading="saving" @click="savePlugin">
|
|
||||||
ذخیره تنظیمات
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section v-if="canWrite" class="form-section renew-section">
|
|
||||||
<h3>شارژ / تمدید جدید</h3>
|
<h3>شارژ / تمدید جدید</h3>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
با ثبت شارژ، تاریخ انقضا تمدید میشود و هشدارهای قبلی قطع میگردد تا دوباره نزدیک پایان برسد.
|
دورهٔ جدید ثبت میشود؛ انقضا عوض میشود و هشدارهای قبلی تا نزدیک پایان قطع میگردد.
|
||||||
</p>
|
</p>
|
||||||
<a-form layout="vertical">
|
<a-form v-if="canWrite" layout="vertical">
|
||||||
<a-row :gutter="12">
|
<a-row :gutter="[12, 0]">
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :sm="12">
|
||||||
<a-form-item label="تاریخ شروع دوره (شمسی)">
|
<a-form-item label="تاریخ شروع دوره (شمسی)">
|
||||||
<JalaliDatePicker v-model="renewForm.chargedAt" />
|
<JalaliDatePicker v-model="renewForm.chargedAt" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</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-form-item label="مدت">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="renewForm.planMonths"
|
v-model:value="renewForm.planMonths"
|
||||||
@@ -545,23 +765,23 @@ onMounted(loadDetail)
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :xs="24" :md="8">
|
<a-col :xs="24" :sm="12">
|
||||||
<a-form-item label="مبلغ (تومان)">
|
<a-form-item label="مبلغ (تومان)">
|
||||||
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 150,000" />
|
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 150,000" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
<a-col :xs="24" :sm="12">
|
||||||
|
<a-form-item label="یادداشت">
|
||||||
|
<a-input v-model:value="renewForm.note" size="large" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
</a-row>
|
</a-row>
|
||||||
<a-form-item label="یادداشت">
|
<a-button type="primary" size="large" block :loading="renewing" @click="renewPlugin">
|
||||||
<a-input v-model:value="renewForm.note" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-button type="primary" size="large" :loading="renewing" @click="renewPlugin">
|
|
||||||
ثبت شارژ / تمدید
|
ثبت شارژ / تمدید
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-form>
|
</a-form>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="form-section">
|
<h3 class="history-head">تاریخچه شارژ</h3>
|
||||||
<h3>تاریخچه شارژ</h3>
|
|
||||||
<div v-if="selected.charges?.length" class="charge-list">
|
<div v-if="selected.charges?.length" class="charge-list">
|
||||||
<article v-for="item in selected.charges" :key="item.id" class="charge-item">
|
<article v-for="item in selected.charges" :key="item.id" class="charge-item">
|
||||||
<div>
|
<div>
|
||||||
@@ -724,12 +944,81 @@ onMounted(loadDetail)
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-banner {
|
.detail-tabs {
|
||||||
border-radius: 18px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
display: grid;
|
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; }
|
.status-banner.healthy { background: #dff8ea; color: #0f7a45; }
|
||||||
@@ -746,23 +1035,15 @@ onMounted(loadDetail)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field-hint {
|
.field-hint {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 10px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.7;
|
line-height: 1.6;
|
||||||
}
|
|
||||||
|
|
||||||
.settings-save {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.renew-section {
|
|
||||||
margin-top: 28px;
|
|
||||||
padding-top: 22px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-hint {
|
.inline-hint {
|
||||||
margin-right: 10px;
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,11 @@ button, input, textarea, select,
|
|||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-detail-modal .ant-modal-body {
|
||||||
|
max-height: min(78vh, 760px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.dash-modules {
|
.dash-modules {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -61,3 +61,123 @@ export function isoToIranDateInput(iso?: string | null): string {
|
|||||||
export function todayIranDateInput(): string {
|
export function todayIranDateInput(): string {
|
||||||
return iranYmd(new Date())
|
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