feat(plugins): SuperAdmin store tabs, pricing, and request approval

Khanegi plugin prices and purchase requests sit beside existing version cards. Also rewrite leftover nerkh upload URLs on tenant forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 16:56:52 +03:30
co-authored by Cursor
parent c970d74f75
commit 7eacb513cd
8 changed files with 530 additions and 9 deletions
+159 -6
View File
@@ -20,11 +20,27 @@ type TenantRow = {
}
}
type RequestRow = {
id: string
tenantName: string
domain: string
pluginTitle: string
durationDays: number
finalPriceRials: number
status: 'pending' | 'approved' | 'rejected' | 'cancelled'
createdAt: string
}
const router = useRouter()
const loadingList = ref(false)
const tenants = ref<TenantRow[]>([])
const q = ref('')
const toneFilter = ref<'healthy' | 'warning' | 'critical' | 'unknown' | null>(null)
const mainTab = ref<'khanegi' | 'hamkar' | 'requests'>('khanegi')
const requests = ref<RequestRow[]>([])
const loadingRequests = ref(false)
const actingId = ref<string | null>(null)
const autoCut = ref(true)
const filteredTenants = computed(() => {
const term = q.value.trim().toLowerCase()
@@ -68,7 +84,64 @@ async function loadTenants() {
}
}
onMounted(loadTenants)
const statusLabel: Record<RequestRow['status'], string> = {
pending: 'در انتظار',
approved: 'تأیید شده',
rejected: 'رد شده',
cancelled: 'لغو شده',
}
async function loadRequests() {
loadingRequests.value = true
try {
const { data } = await api.get('/plugins/store/requests')
requests.value = data
} catch (e: any) {
message.error(e?.response?.data?.message || 'خطا در دریافت درخواست‌ها')
} finally {
loadingRequests.value = false
}
}
async function approve(row: RequestRow) {
actingId.value = row.id
try {
await api.post(`/plugins/store/requests/${row.id}/approve`, {
autoCutOnExpiry: autoCut.value,
})
message.success('تأیید شد و سوییچ روشن شد')
await loadRequests()
await loadTenants()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'تأیید ناموفق')
} finally {
actingId.value = null
}
}
async function reject(row: RequestRow) {
actingId.value = row.id
try {
await api.post(`/plugins/store/requests/${row.id}/reject`)
message.success('رد شد')
await loadRequests()
} catch (e: any) {
const msg = e?.response?.data?.message
message.error(Array.isArray(msg) ? msg.join('، ') : msg || 'رد ناموفق')
} finally {
actingId.value = null
}
}
function setTab(tab: 'khanegi' | 'hamkar' | 'requests') {
mainTab.value = tab
if (tab === 'requests') void loadRequests()
}
onMounted(() => {
loadTenants()
})
</script>
<template>
@@ -86,7 +159,13 @@ onMounted(loadTenants)
</div>
</div>
<div class="toolbar">
<div class="main-tabs">
<button type="button" :class="{ active: mainTab === 'khanegi' }" @click="setTab('khanegi')">خانگی</button>
<button type="button" :class="{ active: mainTab === 'hamkar' }" @click="setTab('hamkar')">همکار</button>
<button type="button" :class="{ active: mainTab === 'requests' }" @click="setTab('requests')">درخواست‌ها</button>
</div>
<div v-if="mainTab !== 'requests'" class="toolbar">
<a-input
v-model:value="q"
allow-clear
@@ -95,9 +174,12 @@ onMounted(loadTenants)
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-button v-if="mainTab === 'khanegi'" type="primary" @click="router.push({ name: 'plugin-catalog' })">
قیمت فروشگاه
</a-button>
</div>
<div class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
<div v-if="mainTab !== 'requests'" class="legend" role="group" aria-label="فیلتر وضعیت افزونه">
<button
type="button"
class="chip all"
@@ -140,11 +222,55 @@ onMounted(loadTenants)
</button>
</div>
<section class="sms-selector">
<section v-if="mainTab === 'requests'" class="sms-selector">
<div class="sms-side-head">درخواست‌های خانگی</div>
<div class="req-toolbar">
<a-checkbox v-model:checked="autoCut">قطع خودکار بعد از انقضا (موقع تأیید)</a-checkbox>
</div>
<a-spin :spinning="loadingRequests">
<div class="req-table-wrap">
<table class="req-table">
<thead>
<tr>
<th>نسخه</th>
<th>افزونه</th>
<th>مدت</th>
<th>مبلغ</th>
<th>وضعیت</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="row in requests" :key="row.id">
<td>
<strong>{{ row.tenantName }}</strong>
<small>{{ row.domain }}</small>
</td>
<td>{{ row.pluginTitle }}</td>
<td>{{ row.durationDays }} روز</td>
<td>{{ Number(row.finalPriceRials || 0).toLocaleString('fa-IR') }}</td>
<td>{{ statusLabel[row.status] }}</td>
<td>
<template v-if="row.status === 'pending'">
<a-button size="small" type="primary" :loading="actingId === row.id" @click="approve(row)">تأیید</a-button>
<a-button size="small" danger :loading="actingId === row.id" @click="reject(row)">رد</a-button>
</template>
</td>
</tr>
<tr v-if="!requests.length">
<td colspan="6" class="empty-hint">درخواستی نیست</td>
</tr>
</tbody>
</table>
</div>
</a-spin>
</section>
<section v-else class="sms-selector">
<div class="sms-side-head"><AppstoreAddOutlined /> نسخه‌ها</div>
<a-spin :spinning="loadingList">
<div class="tenant-groups">
<section class="tenant-group">
<section v-if="mainTab === 'hamkar'" class="tenant-group">
<div class="tenant-group-head">
<h3>همکار</h3>
<span>{{ groupedTenants.hamkar.length }} نسخه</span>
@@ -166,7 +292,7 @@ onMounted(loadTenants)
</div>
</section>
<section class="tenant-group">
<section v-if="mainTab === 'khanegi'" class="tenant-group">
<div class="tenant-group-head">
<h3>خانگی</h3>
<span>{{ groupedTenants.khanegi.length }} نسخه</span>
@@ -194,6 +320,33 @@ onMounted(loadTenants)
</template>
<style scoped>
.main-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.main-tabs button {
appearance: none;
border: 1px solid #d7e0ea;
background: #fff;
border-radius: 999px;
padding: 8px 16px;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.main-tabs button.active {
background: #17324d;
color: #fff;
border-color: #17324d;
}
.req-toolbar { margin-bottom: 12px; }
.req-table-wrap { overflow-x: auto; }
.req-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.req-table th, .req-table td { padding: 8px 6px; text-align: right; border-bottom: 1px solid #eef2f7; }
.req-table small { display: block; color: #94a3b8; }
.req-table .ant-btn + .ant-btn { margin-right: 6px; }
.legend {
display: flex;
flex-wrap: wrap;