fix some extention bug

This commit is contained in:
2026-09-16 13:47:47 +03:30
parent 35a7ab11f5
commit ff04c67917
4 changed files with 517 additions and 96 deletions
+23 -8
View File
@@ -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
View File
@@ -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;
}
+5
View File
@@ -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;
+120
View File
@@ -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)
}