Files
web/src/pages/SupportTenantPage.vue
T

666 lines
21 KiB
Vue
Raw Normal View History

<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 { 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>
<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="مثلاً 1,500,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="مثلاً 1,500,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="مثلاً 1,500,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>