Files
web/src/pages/SupportTenantPage.vue
T

669 lines
21 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { ArrowRightOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
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 { formatMoney } from '@/utils/money'
type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'
type SupportItem = {
id: string
name: string
specs: string | null
description: string | null
priceRials: number | null
isFree: boolean
chargedAt: string | null
planMonths: number | null
expiresAt: string | null
alertDaysBefore: number
isActive: boolean
summary: {
expiryLevel: ExpiryLevel
expiryLabel: string
planLabel: string | null
expiresAt: string | null
}
charges: any[]
}
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const renewing = ref(false)
const creating = ref(false)
const selectedId = ref<string | null>(null)
const showCreate = ref(false)
const tenant = ref<{
id: string
product: 'hamkar' | 'khanegi'
slug: string
nameFa: string
domain: string
} | null>(null)
const supports = ref<SupportItem[]>([])
const planOptions = ref<{ months: number; label: string }[]>([])
const form = reactive({
name: '',
specs: '',
description: '',
priceRials: 0 as number,
isFree: false,
chargedAt: null as string | null,
planMonths: 1 as number,
alertDaysBefore: 3,
isActive: true,
})
const createForm = reactive({
name: '',
specs: '',
description: '',
priceRials: 0 as number,
isFree: false,
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
planMonths: 1,
alertDaysBefore: 3,
})
const renewForm = reactive({
chargedAt: iranDateInputToIso(todayIranDateInput()) as string | null,
planMonths: 1,
priceRials: 0 as number,
isFree: false,
note: '',
})
const tenantId = computed(() => String(route.params.tenantId || ''))
const selected = computed(() => supports.value.find((p) => p.id === selectedId.value) || null)
const planSelectOptions = computed(() =>
(planOptions.value.length
? planOptions.value
: [
{ months: 1, label: 'یک‌ماهه' },
{ months: 3, label: 'سه‌ماهه' },
{ months: 6, label: 'شش‌ماهه' },
{ months: 12, label: 'یک‌ساله' },
]
).map((p) => ({ value: p.months, label: p.label })),
)
function toneClass(level?: ExpiryLevel) {
return level && level !== 'none' ? level : 'unknown'
}
function fillForm(item: SupportItem) {
Object.assign(form, {
name: item.name || '',
specs: item.specs || '',
description: item.description || '',
priceRials: item.isFree ? 0 : (item.priceRials ?? 0),
isFree: Boolean(item.isFree),
chargedAt: item.chargedAt || null,
planMonths: item.planMonths || 1,
alertDaysBefore: item.alertDaysBefore ?? 3,
isActive: item.isActive !== false,
})
renewForm.planMonths = item.planMonths || 1
renewForm.isFree = Boolean(item.isFree)
renewForm.priceRials = item.isFree ? 0 : (item.priceRials ?? 0)
renewForm.chargedAt = iranDateInputToIso(todayIranDateInput())
renewForm.note = ''
}
function selectSupport(id: string) {
selectedId.value = id
const item = supports.value.find((p) => p.id === id)
if (item) fillForm(item)
}
async function loadDetail() {
if (!tenantId.value) return
loading.value = true
try {
const { data } = await api.get(`/support/tenants/${tenantId.value}`)
tenant.value = data.tenant
supports.value = data.supports || []
planOptions.value = data.planOptions || []
const keep =
selectedId.value && supports.value.some((p) => p.id === selectedId.value)
? selectedId.value
: supports.value[0]?.id || null
selectedId.value = keep
if (keep) {
const item = supports.value.find((p) => p.id === keep)
if (item) fillForm(item)
}
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت پشتیبانی‌ها')
} finally {
loading.value = false
}
}
async function createSupport() {
if (!createForm.name.trim()) {
message.warning('نام پشتیبانی لازم است')
return
}
creating.value = true
try {
const isFree = Boolean(createForm.isFree)
const { data } = await api.post(`/support/tenants/${tenantId.value}`, {
name: createForm.name.trim(),
specs: createForm.specs || undefined,
description: createForm.description || undefined,
isFree,
priceRials: isFree ? 0 : Number(createForm.priceRials) || 0,
chargedAt: createForm.chargedAt || undefined,
planMonths: Number(createForm.planMonths),
alertDaysBefore: Number(createForm.alertDaysBefore) || 3,
})
message.success('پشتیبانی اضافه شد')
showCreate.value = false
createForm.name = ''
createForm.specs = ''
createForm.description = ''
createForm.isFree = false
createForm.priceRials = 0
createForm.chargedAt = iranDateInputToIso(todayIranDateInput())
createForm.planMonths = 1
createForm.alertDaysBefore = 3
await loadDetail()
if (data?.support?.id) selectSupport(data.support.id)
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ثبت ناموفق')
} finally {
creating.value = false
}
}
async function saveSupport() {
if (!selectedId.value) return
if (!form.name.trim()) {
message.warning('نام پشتیبانی لازم است')
return
}
saving.value = true
try {
const isFree = Boolean(form.isFree)
await api.patch(`/support/items/${selectedId.value}`, {
name: form.name.trim(),
specs: form.specs || null,
description: form.description || null,
isFree,
priceRials: isFree ? 0 : Number(form.priceRials) || 0,
chargedAt: form.chargedAt,
planMonths: Number(form.planMonths),
alertDaysBefore: Number(form.alertDaysBefore) || 3,
isActive: form.isActive,
})
message.success('ذخیره شد')
await loadDetail()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'ذخیره ناموفق')
} finally {
saving.value = false
}
}
async function renewSupport() {
if (!selectedId.value) return
if (!renewForm.chargedAt) {
message.warning('تاریخ شارژ لازم است')
return
}
renewing.value = true
try {
const isFree = Boolean(renewForm.isFree)
await api.post(`/support/items/${selectedId.value}/renew`, {
chargedAt: renewForm.chargedAt,
planMonths: Number(renewForm.planMonths),
isFree,
priceRials: isFree ? 0 : Number(renewForm.priceRials),
note: renewForm.note || undefined,
})
message.success('پشتیبانی شارژ/تمدید شد')
renewForm.note = ''
await loadDetail()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'شارژ ناموفق')
} finally {
renewing.value = false
}
}
function confirmDelete() {
if (!selected.value) return
Modal.confirm({
title: 'حذف پشتیبانی',
content: `پشتیبانی «${selected.value.name}» و تاریخچه شارژ آن حذف شود؟`,
okText: 'حذف',
okType: 'danger',
cancelText: 'انصراف',
onOk: async () => {
try {
await api.delete(`/support/items/${selected.value!.id}`)
message.success('حذف شد')
selectedId.value = null
await loadDetail()
} catch (e: any) {
message.error(e?.response?.data?.message || 'حذف ناموفق')
}
},
})
}
watch(
() => form.isFree,
(free) => {
if (free) form.priceRials = 0
},
)
watch(
() => createForm.isFree,
(free) => {
if (free) createForm.priceRials = 0
},
)
watch(
() => renewForm.isFree,
(free) => {
if (free) renewForm.priceRials = 0
},
)
watch(() => route.params.tenantId, loadDetail)
onMounted(loadDetail)
</script>
<template>
<div class="page-shell">
<div style="display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px">
<a-button type="text" @click="router.push({ name: 'support-panel' })">
<ArrowRightOutlined />
</a-button>
<div style="flex: 1">
<h1 class="page-title">{{ tenant?.nameFa || 'پشتیبانی‌های نسخه' }}</h1>
<p class="page-sub">
{{ tenant?.product === 'hamkar' ? 'همکار' : 'خانگی' }} · {{ tenant?.domain }}
</p>
</div>
<a-button type="primary" @click="showCreate = true">
<PlusOutlined /> پشتیبانی جدید
</a-button>
</div>
<TenantPanelShortcuts :tenant-id="tenantId" current="support" />
<a-spin :spinning="loading">
<div class="layout">
<aside class="support-list">
<div class="list-head">پشتیبانی‌ها ({{ supports.length }})</div>
<button
v-for="p in supports"
:key="p.id"
type="button"
class="support-card"
:class="[toneClass(p.summary?.expiryLevel), { active: p.id === selectedId, inactive: !p.isActive }]"
@click="selectSupport(p.id)"
>
<strong>
{{ p.name }}
<a-tag v-if="p.isFree" color="blue" class="free-tag">رایگان</a-tag>
</strong>
<span>{{ p.summary?.expiryLabel }}</span>
<small v-if="!p.isActive">غیرفعال</small>
</button>
<div v-if="!supports.length" class="empty-hint">
هنوز پشتیبانی‌ای ثبت نشده — با «پشتیبانی جدید» شروع کنید.
</div>
</aside>
<div v-if="selected" class="detail">
<div class="status-banner" :class="toneClass(selected.summary?.expiryLevel)">
<strong>
{{ selected.name }} — {{ selected.summary?.expiryLabel }}
<a-tag v-if="selected.isFree" color="blue" class="free-tag">رایگان</a-tag>
</strong>
<span v-if="selected.summary?.expiresAt">
انقضا: {{ formatIranDate(selected.summary.expiresAt) }}
<template v-if="selected.summary?.planLabel"> · {{ selected.summary.planLabel }}</template>
</span>
</div>
<section class="form-section">
<div class="section-head">
<h3>مشخصات پشتیبانی</h3>
<a-button danger type="text" @click="confirmDelete">
<DeleteOutlined /> حذف
</a-button>
</div>
<a-form layout="vertical">
<a-form-item label="نام پشتیبانی" required>
<a-input v-model:value="form.name" size="large" placeholder="مثلاً پشتیبانی پایه / ویژه" />
</a-form-item>
<a-form-item label="مشخصات">
<a-textarea v-model:value="form.specs" :rows="2" placeholder="جزئیات فنی یا یادداشت داخلی…" />
</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>
</a-form>
</section>
<section class="form-section">
<h3>اعتبار و قیمت (وضعیت فعلی)</h3>
<a-form layout="vertical">
<a-row :gutter="12">
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شارژ (شمسی)">
<JalaliDatePicker v-model="form.chargedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مدت شارژ">
<a-select
v-model:value="form.planMonths"
size="large"
:options="planSelectOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره رایگان">
<a-switch v-model:checked="form.isFree" />
</a-form-item>
</a-col>
</a-row>
<a-form-item v-if="!form.isFree" label="قیمت دوره (تومان)">
<MoneyInput v-model:value="form.priceRials" placeholder="مثلاً 150,000" />
</a-form-item>
<p v-else class="field-hint">
دوره رایگان فعال است — مبلغ صفر می‌شود. مدت ۱ / ۳ / ۶ / ۱۲ ماه می‌تواند رایگان باشد.
</p>
<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>
</section>
<div class="sticky-actions settings-save">
<a-button type="primary" size="large" block :loading="saving" @click="saveSupport">
ذخیره تنظیمات
</a-button>
</div>
<section class="form-section renew-section">
<h3>شارژ / تمدید جدید</h3>
<p class="field-hint">
با ثبت شارژ، تاریخ انقضا تمدید می‌شود و هشدارهای قبلی قطع می‌گردد تا دوباره نزدیک پایان برسد.
</p>
<a-form layout="vertical">
<a-row :gutter="12">
<a-col :xs="24" :md="8">
<a-form-item label="تاریخ شروع دوره (شمسی)">
<JalaliDatePicker v-model="renewForm.chargedAt" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="مدت">
<a-select
v-model:value="renewForm.planMonths"
size="large"
:options="planSelectOptions"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="دوره رایگان">
<a-switch v-model:checked="renewForm.isFree" />
</a-form-item>
</a-col>
</a-row>
<a-form-item v-if="!renewForm.isFree" label="مبلغ (تومان)">
<MoneyInput v-model:value="renewForm.priceRials" placeholder="مثلاً 150,000" />
</a-form-item>
<p v-else class="field-hint">
دوره رایگان — مبلغ صفر. مدت ۱ / ۳ / ۶ / ۱۲ ماه می‌تواند رایگان باشد.
</p>
<a-form-item label="یادداشت">
<a-input v-model:value="renewForm.note" />
</a-form-item>
<a-button type="primary" size="large" :loading="renewing" @click="renewSupport">
ثبت شارژ / تمدید
</a-button>
</a-form>
</section>
<section class="form-section">
<h3>تاریخچه شارژ</h3>
<div v-if="selected.charges?.length" class="charge-list">
<article v-for="item in selected.charges" :key="item.id" class="charge-item">
<div>
<strong v-if="item.isFree">رایگان</strong>
<strong v-else>{{ formatMoney(item.priceRials) }}</strong>
<a-tag v-if="item.isFree" color="blue" class="free-tag">رایگان</a-tag>
<span> · {{ item.planMonths }} ماهه</span>
</div>
<div class="charge-meta">
از {{ formatIranDate(item.chargedAt) }} تا {{ formatIranDate(item.expiresAt) }}
<template v-if="item.note"> · {{ item.note }}</template>
</div>
</article>
</div>
<div v-else class="hint-box">هنوز شارژی ثبت نشده است.</div>
</section>
</div>
<div v-else class="detail empty-detail">
پشتیبانی‌ای انتخاب نشده است.
</div>
</div>
</a-spin>
<a-modal
v-model:open="showCreate"
title="افزودن پشتیبانی"
ok-text="ثبت"
cancel-text="انصراف"
:confirm-loading="creating"
@ok="createSupport"
>
<a-form layout="vertical" style="margin-top: 12px">
<a-form-item label="نام پشتیبانی" required>
<a-input v-model:value="createForm.name" size="large" placeholder="نام پشتیبانی" />
</a-form-item>
<a-form-item label="مشخصات">
<a-textarea v-model:value="createForm.specs" :rows="2" />
</a-form-item>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item label="تاریخ شارژ">
<JalaliDatePicker v-model="createForm.chargedAt" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="مدت">
<a-select
v-model:value="createForm.planMonths"
size="large"
:options="planSelectOptions"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="دوره رایگان">
<a-switch v-model:checked="createForm.isFree" />
</a-form-item>
<a-form-item v-if="!createForm.isFree" label="قیمت دوره (تومان)">
<MoneyInput v-model:value="createForm.priceRials" placeholder="مثلاً 150,000" />
</a-form-item>
<p v-else class="field-hint">
دوره رایگان — مبلغ صفر. مدت ۱ / ۳ / ۶ / ۱۲ ماه می‌تواند رایگان باشد.
</p>
</a-form>
</a-modal>
</div>
</template>
<style scoped>
.layout {
display: grid;
grid-template-columns: minmax(200px, 260px) 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
}
.support-list {
border-radius: 18px;
background: #fff;
border: 1px solid #e8eef5;
padding: 12px;
display: grid;
gap: 8px;
}
.list-head {
font-weight: 800;
margin-bottom: 4px;
color: #334155;
}
.support-card {
appearance: none;
border: 2px solid transparent;
border-radius: 14px;
padding: 12px;
text-align: right;
cursor: pointer;
font-family: inherit;
display: grid;
gap: 2px;
color: #17324d;
}
.support-card.active {
border-color: #3b6fd4;
}
.support-card.inactive {
opacity: 0.65;
}
.support-card.healthy { background: linear-gradient(135deg, #dff8ea, #c3efd7); }
.support-card.warning { background: linear-gradient(135deg, #fff4c7, #ffe39a); }
.support-card.critical { background: linear-gradient(135deg, #ffd8d8, #ffb9b9); }
.support-card.unknown { background: linear-gradient(135deg, #eef2f7, #dde5ef); }
.free-tag {
margin-inline-start: 6px;
font-size: 11px;
line-height: 18px;
vertical-align: middle;
}
.empty-hint,
.empty-detail,
.hint-box {
color: #94a3b8;
padding: 14px;
font-size: 0.9rem;
}
.detail {
min-width: 0;
}
.status-banner {
border-radius: 18px;
padding: 14px 16px;
margin-bottom: 14px;
display: grid;
gap: 4px;
}
.status-banner.healthy { background: #dff8ea; color: #0f7a45; }
.status-banner.warning { background: #fff4c7; color: #9a6b00; }
.status-banner.critical { background: #ffd8d8; color: #b42318; }
.status-banner.unknown { background: #eef2f7; color: #64748b; }
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.field-hint {
margin: 0 0 8px;
color: #64748b;
font-size: 13px;
line-height: 1.7;
}
.settings-save {
margin-bottom: 8px;
}
.renew-section {
margin-top: 28px;
padding-top: 22px;
}
.inline-hint {
margin-right: 10px;
color: #94a3b8;
font-size: 12px;
}
.charge-list {
display: grid;
gap: 8px;
}
.charge-item {
border-radius: 14px;
background: #f8fafc;
padding: 12px 14px;
}
.charge-meta {
margin-top: 4px;
color: #64748b;
font-size: 0.85rem;
}
</style>