changes of giftCard page

This commit is contained in:
2026-10-20 12:11:46 +03:30
parent 2927d64c64
commit e5b3d0c931
55 changed files with 1495 additions and 140 deletions
+5
View File
@@ -10,5 +10,10 @@ export const getGiftCardTable = async (params: TableInput) =>
export const createGiftCard = async (obj: GiftcardInput) =>
await useAxios.post<Giftcard>(`/admin/giftcard/generate`, obj)
export const updateGiftCard = async (id: number, obj: GiftcardInput) =>
await useAxios.put<Giftcard>(`/admin/giftcard/${id}`, obj)
export const deleteGiftCard = async (id: number) => await useAxios.delete(`/admin/giftcard/${id}`)
@@ -22,6 +22,7 @@
import { defineProps } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import { useRouter } from 'vue-router'
import { confirmMenuAccess } from '@/utils/adminPageAccess'
interface Prop {
menu: RouteRecordRaw[]
@@ -30,7 +31,7 @@ interface Prop {
const router = useRouter()
function handleClickMenu(item: RouteRecordRaw) {
//debugger
if (!confirmMenuAccess(item.name)) return
router.push({ name: item.name })
}
+8 -5
View File
@@ -1,7 +1,7 @@
<template>
<div>
<AMenu
v-model:selectedKeys="selectedKeys"
:selectedKeys="selectedKeys"
:style="{ width: props.width }"
:mode="props.mode"
:theme="resolvedTheme"
@@ -49,10 +49,13 @@ const props = withDefaults(defineProps<Prop>(), {
})
const resolvedTheme = computed(() => props.theme ?? menuTheme.value)
onMounted(() => {
selectedKeys.value = [router.currentRoute.value.name as string]
//emits('onChange', '1')
})
watch(
() => router.currentRoute.value.name,
(name) => {
selectedKeys.value = name ? [String(name)] : []
},
{ immediate: true }
)
</script>
<style scoped></style>
+18
View File
@@ -0,0 +1,18 @@
import { useUserStore } from '@/store/userStore'
import { allowsPageAction, parsePageAccess } from '@/utils/adminPageAccess'
export function usePageActionAccess() {
const route = useRoute()
const userStore = useUserStore()
function can(actionKey: string) {
return allowsPageAction(
parsePageAccess(userStore.userInfo?.page_access),
userStore.hasFullPageAccess,
route.matched.map((item) => item.name),
actionKey
)
}
return { can }
}
@@ -16,6 +16,7 @@
<script setup lang="ts">
import { menuIconSize } from '@root/config'
import type { RouteRecordRaw } from 'vue-router'
import { confirmMenuAccess } from '@/utils/adminPageAccess'
const router = useRouter()
interface Props {
@@ -25,6 +26,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {})
async function handleClickMenuItem(item: RouteRecordRaw) {
if (!confirmMenuAccess(item.name)) return
router.push({ name: item.name })
}
</script>
+18 -19
View File
@@ -10,7 +10,7 @@
<AMenu
:mode="layoutStore.sideMenu.mode"
:theme="menuTheme"
v-model:selectedKeys="selectedKeys"
:selectedKeys="selectedKeys"
>
<template v-for="item in menu">
<BasicSubMenu
@@ -26,7 +26,7 @@
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { ComputedRef } from 'vue'
import { sortBy } from 'lodash'
import { usePrefix } from '@core/composable/usePrefix'
@@ -45,29 +45,28 @@ const selectedKeys = ref<(RouteRecordName | undefined)[]>([])
const routerStore = useRouterStore()
const { menuTheme } = useAdminTheme()
const path: (RouteRecordName | undefined)[] = []
function walkInRoutes(routes: RouteRecordRaw[]) {
for (const route of routes) {
if (route.name == currentRoute.name) {
path.push(route.name)
return true
}
if (route.children && route.children.length > 0) {
path.push(route.name)
const res = walkInRoutes(route.children)
if (!res) {
path.pop()
} else {
function syncSelectedKeys() {
const path: (RouteRecordName | undefined)[] = []
function walkInRoutes(routes: RouteRecordRaw[]): boolean {
for (const route of routes) {
if (route.name == currentRoute.name) {
path.push(route.name)
return true
}
if (route.children && route.children.length > 0) {
path.push(route.name)
const res = walkInRoutes(route.children)
if (!res) path.pop()
else return true
}
}
return false
}
return false
walkInRoutes(routerStore.routes)
selectedKeys.value = path
}
walkInRoutes(routerStore.routes)
selectedKeys.value = path
watch(() => currentRoute.name, syncSelectedKeys, { immediate: true })
function isSupportMenuRoute(item: RouteRecordRaw): boolean {
return item.name === 'support' || item.meta?.permissionsKey === 'support'
+29 -1
View File
@@ -9,6 +9,8 @@ import {
showServerUnavailableOverlay,
} from '@/utils/serverUnavailable'
import { message } from 'ant-design-vue'
import { mutationBlockMessage } from '@/utils/adminPageAccess'
import { readAdminAccessState } from '@/utils/adminAccessState'
//import { isToken } from '@/utils/is'
//import router from '@/router'
//import { getToken, removeToken } from '@/utils/useUtility'
@@ -58,6 +60,18 @@ _axios.interceptors.request.use(
//@ts-ignore
config.headers['Authorization'] = 'Bearer ' + token
}
const routeName = readAdminAccessState().routeName
if (routeName) {
//@ts-ignore
config.headers['X-Admin-Page'] = routeName
}
}
const blocked = mutationBlockMessage(String(config.method || 'get'), config.url)
if (blocked) {
message.warning({ content: blocked, key: 'admin-page-denied' })
const error = new Error(blocked) as Error & { pageAccessDenied?: boolean }
error.pageAccessDenied = true
return Promise.reject(error)
}
return config
},
@@ -77,6 +91,15 @@ _axios.interceptors.response.use(
return response.data
},
async function (error) {
const deniedText = String(error?.message || '')
if (
error?.pageAccessDenied ||
deniedText === 'شما به این صفحه دسترسی ندارید' ||
deniedText === 'شما مجوز ویرایش در این صفحه را ندارید' ||
deniedText === 'شما مجوز حذف در این صفحه را ندارید'
) {
return Promise.reject(error)
}
const router = useRouter()
//console.log('this is axios error', error)
/* if (error.response.data && error.response.data.code) {
@@ -109,7 +132,12 @@ _axios.interceptors.response.use(
await router.push({ name: 'login' })
} else if (error.response && error.response.status == 403) {
if (!error.config?.meta?.silent) {
message.error('شما دسترسی لازم برای انجام این عملیات را ندارید (403)')
const apiMessage = error.response.data?.message
message.error(
typeof apiMessage === 'string' && apiMessage.trim()
? apiMessage
: 'شما دسترسی لازم برای انجام این عملیات را ندارید'
)
}
} else if (error.response && error.response.status == 422) {
if (!error.config?.meta?.silent) {
+30 -2
View File
@@ -14,6 +14,14 @@ import { setupRoutes } from '@/router'
import { useUserStore } from '@/store/userStore'
import { setupPinia } from '@core/plugins/pinia'
import { initStoreModeFromSettings } from '@/utils/config'
import { message } from 'ant-design-vue'
import {
canOpenRoute,
firstAllowedInBranch,
firstAllowedRouteName,
notifyPageDenied
} from '@/utils/adminPageAccess'
import { setCurrentAdminRoute } from '@/utils/adminAccessState'
const init = async () => {
const app = createApp(App)
@@ -57,9 +65,29 @@ const init = async () => {
const token = getToken()
if (token) {
if (to.name == 'login') {
return next({ name: 'dashboard' })
return next({ name: firstAllowedRouteName() || 'dashboardMain' })
}
return next()
if (canOpenRoute(to.name)) {
setCurrentAdminRoute(String(to.name || ''))
return next()
}
const redirectedFrom = to.redirectedFrom?.name
if (redirectedFrom) {
const branchTarget = firstAllowedInBranch(String(redirectedFrom))
if (branchTarget && branchTarget !== String(to.name)) {
return next({ name: branchTarget })
}
}
if (from.name && String(from.name) !== 'login') {
notifyPageDenied()
return next(false)
}
const first = firstAllowedRouteName()
if (first && first !== String(to.name)) {
return next({ name: first })
}
message.warning({ content: 'شما به این صفحه دسترسی ندارید', key: 'admin-page-denied' })
return next(false)
} else {
if (['login', '404'].includes(to.name as string)) {
return next()
+3
View File
@@ -1,4 +1,5 @@
import type { Const } from '@/model/const'
import type { PageAccessMap } from '@/utils/adminPageAccess'
export interface User {
active?: boolean
@@ -19,6 +20,8 @@ export interface User {
settings?: Const[]
sms_billing_enabled?: boolean
sms_panel_access?: boolean
/** null = دسترسی کامل */
page_access?: PageAccessMap | null
}
export interface AddId {
+7 -1
View File
@@ -101,6 +101,7 @@ import OtpInput from 'vue3-otp-input'
import { useUserStore } from '@/store/userStore'
import { setupRoutes } from '@/router'
import { getConfigApp, initStoreModeFromSettings } from '@/utils/config'
import { firstAllowedRouteName } from '@/utils/adminPageAccess'
//define emit
const emits = defineEmits(['enter'])
@@ -175,7 +176,12 @@ const onUserPassFormFinish = async () => {
}*/
//router.removeRoute('usersMain')
//router.removeRoute('users')
await router.push({ name: 'dashboard' })
const landing = firstAllowedRouteName()
if (!landing) {
message.warning({ content: 'شما به این صفحه دسترسی ندارید', key: 'admin-page-denied' })
return
}
await router.push({ name: landing })
} catch (e) {
console.log(e)
/* if (e?.response.status === 422) {
@@ -4,7 +4,7 @@
<MobileSheetHeader :title="sheetTitle" :show-title="isMobile">
<template #actions>
<MobileIconButton
v-if="!row.pargar_ref && !row.customer?.pargar_account_id"
v-if="can('register') && !row.pargar_ref && !row.customer?.pargar_account_id"
plain
icon="vuesax-linear:profile-add"
color="#7C3AED"
@@ -12,7 +12,7 @@
@click="$emit('register-customer', row)"
/>
<MobileIconButton
v-if="canRetry(row)"
v-if="can('send') && canRetry(row)"
plain
icon="vuesax-linear:refresh-circle"
color="#CA8A04"
@@ -88,6 +88,9 @@ import {
} from '@/pages/menuPages/accounting/utils/pargarDisplay'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
visible: boolean
@@ -4,7 +4,7 @@
<MobileSheetHeader :title="sheetTitle" :show-title="isMobile">
<template #actions>
<MobileIconButton
v-if="!row.tahesab_id && !row.customer?.moshtari_code"
v-if="can('register') && !row.tahesab_id && !row.customer?.moshtari_code"
plain
icon="vuesax-linear:profile-add"
color="#7C3AED"
@@ -12,7 +12,7 @@
@click="$emit('register-customer', row)"
/>
<MobileIconButton
v-if="canRetry(row)"
v-if="can('send') && canRetry(row)"
plain
icon="vuesax-linear:refresh-circle"
color="#CA8A04"
@@ -60,6 +60,9 @@ import {
import { canRetryTahesab, tahesabStatusLabel } from '@/pages/menuPages/accounting/utils/tahesabStatus'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
visible: boolean
@@ -4,7 +4,7 @@
<MobileSheetHeader :title="sheetTitle" :show-title="isMobile">
<template #actions>
<MobileIconButton
v-if="!row.zarin_id && !row.customer?.moshtari_code"
v-if="can('register') && !row.zarin_id && !row.customer?.moshtari_code"
plain
icon="vuesax-linear:profile-add"
color="#7C3AED"
@@ -12,7 +12,7 @@
@click="$emit('register-customer', row)"
/>
<MobileIconButton
v-if="canRetry(row)"
v-if="can('send') && canRetry(row)"
plain
icon="vuesax-linear:refresh-circle"
color="#CA8A04"
@@ -60,6 +60,9 @@ import {
import { canRetryZarin, zarinStatusLabel } from '@/pages/menuPages/accounting/utils/zarinStatus'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
visible: boolean
@@ -7,14 +7,14 @@
<div class="flex justify-center gap-2">
<!-- دکمه ۱: اطلاعات کلی (همیشه نمایان) -->
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<a-tooltip v-if="can('doc')" placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>اطلاعات سند</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<!-- دکمه ۲: ثبت مشتری در پرگار (فقط اگر سند هنوز ثبت نشده و مشتری کد ندارد) -->
<a-tooltip
v-if="canRetryPargar(record) && !record.customer?.pargar_account_id"
v-if="can('register') && canRetryPargar(record) && !record.customer?.pargar_account_id"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
@@ -25,7 +25,7 @@
<!-- دکمه ۳: ارسال به پرگار (فقط اگر هنوز در پرگار ثبت نشده) -->
<a-tooltip
v-if="canRetryPargar(record)"
v-if="can('send') && canRetryPargar(record)"
placement="bottom"
class="cursor-pointer"
@click="openSend(record)"
@@ -138,6 +138,9 @@ import {
} from '@/pages/menuPages/accounting/utils/pargarDisplay'
import { toJalali } from '@core/utils/useDataTime'
import { accountingPargarDocsIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -79,12 +79,12 @@
</template>
<template v-else-if="column.key === 'action'">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<a-tooltip v-if="can('doc')" placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>جزئیات</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="!record.tahesab_id && !record.customer?.moshtari_code && record.customer_id"
v-if="can('register') && !record.tahesab_id && !record.customer?.moshtari_code && record.customer_id"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
@@ -93,6 +93,7 @@
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<a-button
v-if="can('send')"
type="link"
size="small"
:loading="syncingRowKey === failedRowKey(record)"
@@ -111,13 +112,13 @@
<BaseTable :key="docsView" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<a-tooltip v-if="can('doc')" placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>اطلاعات سند</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="!record.tahesab_id && !record.customer?.moshtari_code"
v-if="can('register') && !record.tahesab_id && !record.customer?.moshtari_code"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
@@ -127,7 +128,7 @@
</a-tooltip>
<a-tooltip
v-if="!record.tahesab_id"
v-if="can('send') && !record.tahesab_id"
placement="bottom"
class="cursor-pointer"
@click="openSend(record)"
@@ -221,6 +222,9 @@ import {
import { toJalali } from '@core/utils/useDataTime'
import { accountingTahesabDocsIntro } from '@/utils/adminHelpCopy'
import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const docSyncStore = useTahesabDocSyncStore()
@@ -76,12 +76,12 @@
</template>
<template v-else-if="column.key === 'action'">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<a-tooltip v-if="can('doc')" placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>جزئیات</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="!record.zarin_id && !record.customer?.moshtari_code && record.customer_id"
v-if="can('register') && !record.zarin_id && !record.customer?.moshtari_code && record.customer_id"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
@@ -90,6 +90,7 @@
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<a-button
v-if="can('send')"
type="link"
size="small"
:loading="syncingRowKey === failedRowKey(record)"
@@ -108,13 +109,13 @@
<BaseTable :key="docsView" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<a-tooltip v-if="can('doc')" placement="bottom" class="cursor-pointer" @click="openInfo(record)">
<template #title><span>اطلاعات سند</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip
v-if="!record.zarin_id && !record.customer?.moshtari_code"
v-if="can('register') && !record.zarin_id && !record.customer?.moshtari_code"
placement="bottom"
class="cursor-pointer"
@click="openRegister(record)"
@@ -124,7 +125,7 @@
</a-tooltip>
<a-tooltip
v-if="!record.zarin_id"
v-if="can('send') && !record.zarin_id"
placement="bottom"
class="cursor-pointer"
@click="openSend(record)"
@@ -216,6 +217,9 @@ import {
} from '@/pages/menuPages/accounting/utils/accountingDocAmounts'
import { toJalali } from '@core/utils/useDataTime'
import { accountingZarinDocsIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
customerId?: number
@@ -27,7 +27,7 @@
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom">
<a-tooltip v-if="can('recipients')" placement="bottom">
<template #title>گیرندگان</template>
<Icon
icon="vuesax-linear:profile-2user"
@@ -36,7 +36,7 @@
@click="emits('recipients', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('readers')" placement="bottom">
<template #title>بازدیدکنندگان</template>
<Icon
icon="vuesax-linear:people"
@@ -45,7 +45,7 @@
@click="emits('readers', record)"
/>
</a-tooltip>
<a-tooltip v-if="!isSystemRecord(record)" placement="bottom">
<a-tooltip v-if="can('edit') && !isSystemRecord(record)" placement="bottom">
<template #title>ویرایش</template>
<Icon
icon="vuesax-linear:edit"
@@ -54,7 +54,7 @@
@click="emits('edit', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('toggle')" placement="bottom">
<template #title>{{ record.is_active ? 'غیرفعال کردن' : 'فعال کردن' }}</template>
<Icon
:icon="record.is_active ? 'vuesax-linear:close-circle' : 'vuesax-linear:tick-circle'"
@@ -63,7 +63,7 @@
@click="emits('toggle', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('delete')" placement="bottom">
<template #title>حذف</template>
<Icon
icon="vuesax-linear:trash"
@@ -91,6 +91,9 @@ import {
APP_NOTIFICATION_SOURCE_TABS,
type AppNotificationSourceFilter,
} from '@/model/appNotifications'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
@@ -18,19 +18,21 @@
<div class="truncate font-bold text-slate-800 dark:text-slate-100">{{ item.name }}</div>
<div class="text-xs text-gray-500">{{ item.code }}</div>
</div>
<a-switch :checked="item.is_active" size="small" @change="emits('toggle', item)" />
<a-switch v-if="can('toggle')" :checked="item.is_active" size="small" @change="emits('toggle', item)" />
</div>
<div class="line-clamp-2 text-sm text-slate-600 dark:text-slate-300">{{ item.body }}</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>ارسال {{ Number(item.sent_count ?? 0) }} · ناموفق {{ Number(item.failed_count ?? 0) }}</span>
<div class="flex gap-3">
<Icon
v-if="can('edit')"
icon="vuesax-linear:edit"
color="#4572E9"
class="cursor-pointer"
@click="emits('edit', item)"
/>
<Icon
v-if="can('delete')"
icon="vuesax-linear:trash"
color="#C53030"
class="cursor-pointer"
@@ -46,11 +48,11 @@
<BaseTable v-else class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #is_active="{ record }">
<a-switch :checked="record.is_active" size="small" @change="emits('toggle', record)" />
<a-switch v-if="can('toggle')" :checked="record.is_active" size="small" @change="emits('toggle', record)" />
</template>
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom">
<a-tooltip v-if="can('preview')" placement="bottom">
<template #title>اجرای آزمایشی</template>
<Icon
icon="vuesax-linear:play"
@@ -59,7 +61,7 @@
@click="emits('runNow', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('log')" placement="bottom">
<template #title>لاگ ارسال</template>
<Icon
icon="vuesax-linear:document-text"
@@ -68,7 +70,7 @@
@click="emits('sends', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('edit')" placement="bottom">
<template #title>ویرایش</template>
<Icon
icon="vuesax-linear:edit"
@@ -77,7 +79,7 @@
@click="emits('edit', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('delete')" placement="bottom">
<template #title>حذف</template>
<Icon
icon="vuesax-linear:trash"
@@ -104,6 +106,9 @@ import { getAutomationsTable } from '@/api/automations'
import type { Automation } from '@/model/automations'
import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList'
import { automationsPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
@@ -15,12 +15,14 @@
</div>
<MobileIconButton
v-if="can('toggle')"
:icon="item.sts === 2 ? 'vuesax-linear:card-tick' : 'vuesax-linear:card-remove'"
:color="item.sts === 2 ? '#16A34A' : '#DC2626'"
:size="20"
@click="$emit('toggleSts', item)"
/>
<MobileIconButton
v-if="can('delete')"
icon="vuesax-linear:trash"
color="#DC2626"
:size="20"
@@ -34,6 +36,9 @@
import type { Card } from '@/model/card'
import MobileCard from '@/components/mobile/MobileCard.vue'
import MobileIconButton from '@/components/mobile/MobileIconButton.vue'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
type AccountRow = Card & { sts_str?: string; created_at?: string; customer_id?: number }
@@ -12,6 +12,7 @@
<template #action="{ column, record }">
<div class="gap-2 flex justify-center items-center">
<a-tooltip
v-if="can('toggle')"
:key="record.sts"
placement="bottom"
class="cursor-pointer"
@@ -25,7 +26,7 @@
:color="record.sts !== 2 ? 'red' : 'green'"
/>
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('delete', record.id)">
<a-tooltip v-if="can('delete')" placement="bottom" class="cursor-pointer" @click="emits('delete', record.id)">
<template #title>
<span>حذف کارت</span>
</template>
@@ -63,6 +64,9 @@ import type { Card } from '@/model/card'
import AccountMobileCardList from './AccountMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { cardsPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -13,7 +13,7 @@
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom">
<a-tooltip v-if="can('edit')" placement="bottom">
<template #title>ویرایش</template>
<Icon
icon="vuesax-linear:edit"
@@ -22,7 +22,7 @@
@click="emits('edit', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('toggle')" placement="bottom">
<template #title>{{ record.is_active ? 'غیرفعال کردن' : 'فعال کردن' }}</template>
<Icon
:icon="record.is_active ? 'vuesax-linear:eye-slash' : 'vuesax-linear:eye'"
@@ -31,7 +31,7 @@
@click="emits('toggle', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('delete')" placement="bottom">
<template #title>حذف</template>
<Icon
icon="vuesax-linear:trash"
@@ -54,6 +54,9 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/conditionalOrdersColumns'
import { getConditionalOrdersTable } from '@/api/conditionalOrders'
import { conditionalOrdersPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
@@ -4,12 +4,14 @@
<div class="flex min-w-0 flex-1 items-center gap-2">
<BaseIcon icon="vuesax-linear:user" size="20" color="#6b7280" />
<button
v-if="can('info')"
type="button"
class="truncate border-none bg-transparent p-0 text-right text-sm font-700 text-gray-900 cursor-pointer"
@click.stop="$emit('info', item)"
>
{{ item.name || '-' }}
</button>
<span v-else class="truncate text-sm font-700 text-gray-900">{{ item.name || '-' }}</span>
</div>
<div class="shrink-0 text-sm text-gray-600">{{ item.mobile || '-' }}</div>
@@ -19,6 +21,7 @@
{{ item.type_str || 'کاربر' }}
</span>
<MobileIconButton
v-if="can('restore')"
icon="vuesax-linear:archive-slash"
color="#e3bf00"
:size="20"
@@ -34,6 +37,9 @@
import type { Customer } from '@/model/customer'
import MobileCard from '@/components/mobile/MobileCard.vue'
import MobileIconButton from '@/components/mobile/MobileIconButton.vue'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
item: Customer
@@ -5,7 +5,7 @@
<BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip :key="record.is_archive" placement="bottom" class="cursor-pointer">
<a-tooltip v-if="can('restore')" :key="record.is_archive" placement="bottom" class="cursor-pointer">
<template #title>
<span>{{ record.is_archive ? 'بیرون بردن از بایگانی' : 'بایگانی کردن' }}</span>
</template>
@@ -20,7 +20,7 @@
</a-popconfirm>
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<a-tooltip v-if="can('info')" placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title><span>مشخصات</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
@@ -52,6 +52,9 @@ import useBaseTable from '@core/components/BaseTable/hooks/useBaseTable'
import type { TableInput } from '@core/type/baseTable'
import { isMobile } from '@core/utils/is'
import { customersArchivePageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const emits = defineEmits(['delete', 'edit', 'add', 'info', 'charge'])
@@ -28,6 +28,7 @@
</section>
<BaseButton
v-if="can('charge')"
class="customer-mobile-sheet__charge-btn"
size="middle"
type="primary"
@@ -39,7 +40,7 @@
<div class="customer-mobile-sheet__actions">
<BaseButton
v-if="accountingCodeLabel"
v-if="can('accountingCode') && accountingCodeLabel"
size="small"
type="default"
icon="vuesax-linear:card-edit"
@@ -47,14 +48,14 @@
>
کد حسابداری
</BaseButton>
<BaseButton size="small" type="default" icon="vuesax-linear:stickynote" @click="closeAndEmit('info', row)">
<BaseButton v-if="can('info')" size="small" type="default" icon="vuesax-linear:stickynote" @click="closeAndEmit('info', row)">
مشخصات
</BaseButton>
<BaseButton size="small" type="default" icon="vuesax-linear:edit" @click="closeAndEmit('edit', row)">
<BaseButton v-if="can('edit')" size="small" type="default" icon="vuesax-linear:edit" @click="closeAndEmit('edit', row)">
ویرایش
</BaseButton>
<BaseButton
v-if="customerLevelProfitEnabled"
v-if="can('referral') && customerLevelProfitEnabled"
size="small"
type="default"
icon="vuesax-linear:hierarchy-2"
@@ -62,11 +63,11 @@
>
سود چندسطحی
</BaseButton>
<BaseButton size="small" type="default" icon="vuesax-linear:archive-tick" @click="closeAndEmit('archive', row)">
<BaseButton v-if="can('archive')" size="small" type="default" icon="vuesax-linear:archive-tick" @click="closeAndEmit('archive', row)">
بایگانی
</BaseButton>
<BaseButton
v-if="customerDirectSmsEnabled"
v-if="can('sms') && customerDirectSmsEnabled"
size="small"
type="default"
icon="vuesax-linear:sms"
@@ -75,7 +76,7 @@
پیامک
</BaseButton>
<BaseButton
v-if="!row.is_legal"
v-if="can('approve') && !row.is_legal"
size="small"
type="default"
icon="vuesax-linear:user-search"
@@ -84,6 +85,7 @@
تایید
</BaseButton>
<BaseButton
v-if="can('active')"
size="small"
:type="row.active ? 'default' : 'primary'"
:icon="row.active ? 'vuesax-linear:user-remove' : 'vuesax-linear:user-tick'"
@@ -92,6 +94,7 @@
{{ row.active ? 'غیرفعال' : 'فعال' }}
</BaseButton>
<BaseButton
v-if="can('trade')"
size="small"
:type="row.trade_active === 1 ? 'default' : 'primary'"
:icon="row.trade_active === 1 ? 'vuesax-linear:box-remove' : 'vuesax-linear:box-tick'"
@@ -135,6 +138,9 @@ import {
} from '@/utils/config'
import { displayAccountingCode } from '@/utils/accountingCode'
import CustomerPresenceBadge from '@/pages/menuPages/customers/base/components/CustomerPresenceBadge.vue'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
visible: boolean
@@ -10,12 +10,14 @@
<div class="min-w-0 flex-1 space-y-1.5">
<div class="flex items-center gap-2">
<button
v-if="can('info')"
type="button"
class="min-w-0 flex-1 truncate border-none bg-transparent p-0 text-right text-sm font-700 text-gray-900"
@click.stop="$emit('info', item)"
>
{{ item.name || '-' }}
</button>
<span v-else class="min-w-0 flex-1 truncate text-sm font-700 text-gray-900">{{ item.name || '-' }}</span>
<span
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
:class="item.active ? 'bg-green-500' : 'bg-red-500'"
@@ -41,6 +43,7 @@
عملیات
</BaseButton>
<BaseButton
v-if="can('edit')"
type="default"
size="small"
class="min-w-[4.5rem]"
@@ -57,6 +60,9 @@
<script setup lang="ts">
import type { Customer } from '@/model/customer'
import MobileCard from '@/components/mobile/MobileCard.vue'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
item: Customer
@@ -21,6 +21,7 @@
</template>
<template #accountingCode="{ record }">
<button
v-if="can('accountingCode')"
type="button"
class="inline-flex items-center gap-1.5 border-none bg-transparent p-0 text-sm cursor-pointer"
style="color: var(--admin-text)"
@@ -29,10 +30,11 @@
<span dir="ltr">{{ accountingCodeOf(record) }}</span>
<Icon icon="vuesax-linear:edit-2" size="16" color="#4572E9" />
</button>
<span v-else dir="ltr">{{ accountingCodeOf(record) }}</span>
</template>
<template #action="{ record }">
<div class="gap-2 hidden md:flex justify-center">
<a-tooltip :key="record.is_archive" placement="bottom" class="cursor-pointer">
<a-tooltip v-if="can('archive')" :key="record.is_archive" placement="bottom" class="cursor-pointer">
<template #title>
<span>{{ record.is_archive ? 'بیرون بردن از بایگانی' : 'بایگانی کردن' }}</span>
</template>
@@ -47,6 +49,7 @@
</a-popconfirm>
</a-tooltip>
<a-tooltip
v-if="can('active')"
:key="record.active"
placement="bottom"
class="cursor-pointer"
@@ -60,12 +63,12 @@
:color="record.active ? '#54AD06' : '#ad0627'"
/>
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('chargeWallet', record)">
<a-tooltip v-if="can('charge')" placement="bottom" class="cursor-pointer" @click="emits('chargeWallet', record)">
<template #title><span>شارژ کیف پول</span></template>
<Icon icon="vuesax-linear:wallet-add" color="#9333ea" />
</a-tooltip>
<a-tooltip
v-if="tahesabEnabled && !isValidAccountingCode(record.moshtari_code)"
v-if="can('tahesab') && tahesabEnabled && !isValidAccountingCode(record.moshtari_code)"
placement="bottom"
class="cursor-pointer"
@click="openTahesabRegister(record)"
@@ -74,7 +77,7 @@
<Icon icon="vuesax-linear:profile-add" color="#CA8A04" />
</a-tooltip>
<a-tooltip
v-if="zarinEnabled && !isValidAccountingCode(record.moshtari_code)"
v-if="can('zarin') && zarinEnabled && !isValidAccountingCode(record.moshtari_code)"
placement="bottom"
class="cursor-pointer"
@click="openZarinRegister(record)"
@@ -83,7 +86,7 @@
<Icon icon="vuesax-linear:profile-add" color="#0284C7" />
</a-tooltip>
<a-tooltip
v-if="pargarEnabled && !isValidAccountingCode(record.pargar_account_id)"
v-if="can('pargar') && pargarEnabled && !isValidAccountingCode(record.pargar_account_id)"
placement="bottom"
class="cursor-pointer"
@click="openPargarRegister(record)"
@@ -92,7 +95,7 @@
<Icon icon="vuesax-linear:profile-add" color="#7C3AED" />
</a-tooltip>
<a-tooltip
v-if="accountingEnabled"
v-if="can('accountingCode') && accountingEnabled"
placement="bottom"
class="cursor-pointer"
@click="openAccountingCodeEdit(record)"
@@ -100,16 +103,16 @@
<template #title><span>ویرایش کد حسابداری</span></template>
<Icon icon="vuesax-linear:card-edit" color="#0d9488" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<a-tooltip v-if="can('info')" placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title><span>مشخصات</span></template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<a-tooltip v-if="can('edit')" placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<template #title><span>ویرایش</span></template>
<Icon icon="vuesax-linear:edit" color="#4572E9" />
</a-tooltip>
<a-tooltip
v-if="customerLevelProfitEnabled"
v-if="can('referral') && customerLevelProfitEnabled"
placement="bottom"
class="cursor-pointer"
@click="openLevelProfit(record)"
@@ -118,6 +121,7 @@
<Icon icon="vuesax-linear:hierarchy-2" color="#ca8a04" />
</a-tooltip>
<a-tooltip
v-if="can('trade')"
:key="record.trade_active"
placement="bottom"
class="cursor-pointer"
@@ -132,7 +136,7 @@
/>
</a-tooltip>
<a-tooltip
v-if="customerDirectSmsEnabled"
v-if="can('sms') && customerDirectSmsEnabled"
placement="bottom"
class="cursor-pointer"
@click="openDirectSms(record)"
@@ -141,7 +145,7 @@
<Icon icon="vuesax-linear:sms" color="#0ea5e9" />
</a-tooltip>
<a-tooltip
v-if="!record.is_legal"
v-if="can('approve') && !record.is_legal"
placement="bottom"
class="cursor-pointer"
@click="openConfirmUser(record)"
@@ -274,6 +278,9 @@ import {
} from '@/utils/config'
import { displayAccountingCode, isValidAccountingCode } from '@/utils/accountingCode'
import { customersPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const emits = defineEmits(['delete', 'edit', 'add', 'info', 'chargeWallet'])
@@ -15,7 +15,7 @@
</template>
<template #action="{ record }">
<div class="flex gap-3 justify-center items-center">
<a-tooltip placement="top">
<a-tooltip v-if="can('detail')" placement="top">
<template #title>جزئیات</template>
<Icon
icon="vuesax-linear:eye"
@@ -24,7 +24,7 @@
@click.stop="openDetails(record)"
/>
</a-tooltip>
<a-tooltip v-if="record.status === 'active'" placement="top">
<a-tooltip v-if="can('edit') && record.status === 'active'" placement="top">
<template #title>ویرایش</template>
<Icon
icon="vuesax-linear:edit"
@@ -86,6 +86,9 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { getCustomersList } from '@/api/customer'
import { message } from 'ant-design-vue'
import type { Item } from '@/model/Item'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const modalVisible = ref(false)
@@ -3,10 +3,10 @@
<BaseTable v-bind="tblProps" @register="registerTable" ref="tbl">
<template #action="{ record }">
<div v-if="record.status === 'pending'" class="flex gap-2 justify-center">
<BaseButton type="primary" size="small" @click.stop="openApprove(record)">
<BaseButton v-if="can('approve')" type="primary" size="small" @click.stop="openApprove(record)">
تأیید
</BaseButton>
<BaseButton size="small" danger @click.stop="openReject(record)">رد</BaseButton>
<BaseButton v-if="can('reject')" size="small" danger @click.stop="openReject(record)">رد</BaseButton>
</div>
<span v-else class="text-gray-400 text-xs">—</span>
</template>
@@ -46,6 +46,9 @@ import {
} from '@/api/goldCredit'
import GoldCreditModal from '@/pages/menuPages/customers/base/components/goldCreditModal.vue'
import { message } from 'ant-design-vue'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [registerTable, reload] = useBaseTable()
const loading = ref(false)
@@ -15,11 +15,11 @@
</template>
<template #action="{ record }">
<div class="flex items-center justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<a-tooltip v-if="can('edit')" placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<template #title><span>ویرایش</span></template>
<Icon icon="vuesax-linear:edit-2" color="#4572E9" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('toggle', record)">
<a-tooltip v-if="can('toggle')" placement="bottom" class="cursor-pointer" @click="emits('toggle', record)">
<template #title>
<span>{{ record.is_active ? 'غیرفعال کردن' : 'فعال کردن' }}</span>
</template>
@@ -29,6 +29,7 @@
/>
</a-tooltip>
<a-popconfirm
v-if="can('delete')"
title="پکیج حذف شود؟"
ok-text="تایید"
cancel-text="انصراف"
@@ -56,6 +57,9 @@ import { getSpecialPricePackageColumns } from '../hooks/specialPriceColumns'
import { isMobile } from '@core/utils/is'
import { ref } from 'vue'
import { specialPricePageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const emits = defineEmits<{
(e: 'add'): void
@@ -10,7 +10,7 @@
</template>
<template #action="{ column, record }">
<div class="gap-2 flex justify-center">
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<a-tooltip v-if="can('edit')" placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<template #title>
<span>ویرایش</span>
</template>
@@ -30,6 +30,9 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/discountColumns'
import { getDiscountTable } from '@/api/discount'
import { discountPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
@@ -54,6 +54,21 @@
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">{{ index + 1 }}</template>
<template v-else-if="column.key === 'code'">
<div class="flex items-center justify-center gap-1" dir="ltr">
<span class="font-mono text-xs">{{ record.code || '-' }}</span>
<a-tooltip v-if="record.code" title="کپی کد">
<button
type="button"
class="inline-flex cursor-pointer items-center border-none bg-transparent p-0"
aria-label="کپی کد"
@click="copyCode(record.code)"
>
<Icon icon="vuesax-linear:copy" :size="16" color="#4572E9" />
</button>
</a-tooltip>
</div>
</template>
<template v-else-if="column.key === 'sts'">
<CustomTag v-if="record.sts === 1" textColor="#ca8a04" bgColor="#fef3c7">فعال</CustomTag>
<CustomTag v-else-if="record.sts === 2" textColor="#25855A" bgColor="#E3F4E9">استفاده شده</CustomTag>
@@ -110,6 +125,11 @@ import {
} from '../utils/exportGiftCardCodes'
import { resolveGiftCardCustomer } from '../utils/giftCardCustomer'
import { SELECT_DROPDOWN_Z_INDEX } from '@/constants/overlayZIndex'
import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { message } from 'ant-design-vue'
import { useClipboard } from '@vueuse/core'
const { copy, isSupported } = useClipboard()
const props = defineProps<{
visible: boolean
@@ -144,6 +164,27 @@ function close() {
visibleProxy.value = false
}
async function copyCode(code?: string) {
const value = String(code ?? '').trim()
if (!value) return
try {
if (isSupported.value) await copy(value)
else {
const el = document.createElement('textarea')
el.value = value
el.style.position = 'fixed'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
message.success('کد کپی شد')
} catch {
message.error('کپی انجام نشد')
}
}
const giftCardCustomer = computed(() => resolveGiftCardCustomer(props.giftCard))
const exportMeta = computed<GiftCardCodesExportMeta>(() => ({
@@ -16,8 +16,13 @@
:key="item.id"
:item="item"
:hide-customer="!!customerId"
:can-edit="canEdit"
:can-delete="canDelete"
:can-codes="canCodes"
@open="openSheet"
@gift-card-codes="$emit('giftCardCodes', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
/>
</MobileCardList>
@@ -38,10 +43,15 @@ type GiftCardRow = Giftcard & { customer?: { name?: string; mobile?: string } }
const props = defineProps<{
customerId?: number
canEdit?: boolean
canDelete?: boolean
canCodes?: boolean
}>()
defineEmits<{
(e: 'giftCardCodes', item: GiftCardRow): void
(e: 'edit', item: GiftCardRow): void
(e: 'delete', id: number): void
}>()
const { sheetVisible, selectedItem, openSheet, syncSelectedItem } = useMobileDetailSheet<GiftCardRow>()
@@ -18,11 +18,26 @@
</div>
<MobileIconButton
v-if="canCodes"
icon="vuesax-linear:document-text"
color="#1890ff"
:size="20"
@click="$emit('giftCardCodes', item)"
/>
<MobileIconButton
v-if="canEdit"
icon="vuesax-linear:edit"
color="#4572E9"
:size="20"
@click="$emit('edit', item)"
/>
<MobileIconButton
v-if="canDelete"
icon="vuesax-linear:trash"
color="#EB3535"
:size="20"
@click="item.id && $emit('delete', item.id)"
/>
</div>
</MobileCard>
</template>
@@ -38,11 +53,16 @@ type GiftCardRow = Giftcard & { customer?: { name?: string; mobile?: string } }
const props = defineProps<{
item: GiftCardRow
hideCustomer?: boolean
canEdit?: boolean
canDelete?: boolean
canCodes?: boolean
}>()
defineEmits<{
(e: 'open', item: GiftCardRow): void
(e: 'giftCardCodes', item: GiftCardRow): void
(e: 'edit', item: GiftCardRow): void
(e: 'delete', id: number): void
}>()
const unitLabel = computed(() => (props.item.unit === 2 ? metalLabel.goldWalletShort() : 'ریالی'))
@@ -27,18 +27,30 @@
</template>
<template #action="{ column, record }">
<div class="gap-2 flex justify-center">
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<a-tooltip v-if="can('info')" placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title>
<span>مشخصات</span>
</template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('giftcardcodes', record)">
<a-tooltip v-if="can('codes')" placement="bottom" class="cursor-pointer" @click="emits('giftcardcodes', record)">
<template #title>
<span>کدهای {{ record.unit == 3 ? 'تخفیف' : 'کارت هدیه' }}</span>
</template>
<Icon icon="vuesax-linear:document-text" color="#1890ff" />
</a-tooltip>
<a-tooltip v-if="can('edit')" placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<template #title>
<span>ویرایش</span>
</template>
<Icon icon="vuesax-linear:edit" color="#4572E9" />
</a-tooltip>
<a-tooltip v-if="can('delete')" placement="bottom" class="cursor-pointer" @click="emits('delete', record.id)">
<template #title>
<span>حذف</span>
</template>
<Icon icon="vuesax-linear:trash" color="#EB3535" />
</a-tooltip>
</div>
</template>
</BaseTable>
@@ -49,6 +61,11 @@
class="min-h-0 flex-1"
:customer-id="customerId"
@gift-card-codes="emits('giftcardcodes', $event)"
:can-edit="can('edit')"
:can-delete="can('delete')"
:can-codes="can('codes')"
@edit="emits('edit', $event)"
@delete="emits('delete', $event)"
/>
</div>
</BasicShowContext>
@@ -66,6 +83,7 @@ import GiftCardMobileCardList from './GiftCardMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { isStoreApp } from '@/utils/config'
import { giftCardsPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
interface Prop {
customerId?: number
@@ -73,6 +91,7 @@ interface Prop {
const props = defineProps<Prop>()
const emits = defineEmits(['delete', 'edit', 'add', 'info', 'giftcardcodes'])
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
+41 -11
View File
@@ -116,10 +116,11 @@ import { confirmDeleteItem } from '@/utils/confirmModal'
import { cloneDeep } from 'lodash'
import type { Item } from '@/model/Item'
import type { Customer } from '@/model/customer'
import { createCustomer, deleteCustomer, getCustomersList, updateCustomer } from '@/api/customer'
import { createGiftCard, deleteGiftCard, updateGiftCard } from '@/api/giftCard'
import { useUserStore } from '@/store/userStore'
import { allowsPageAction, notifyPageActionDenied, parsePageAccess } from '@/utils/adminPageAccess'
import { usePrice } from '@core/utils/usePrice'
import type { Giftcard, Giftcardcode, GiftcardInput } from '@/model/giftCard'
import { createGiftCard } from '@/api/giftCard'
import dayjs from 'dayjs'
import { isMobile } from '@core/utils/is'
import { metalLabel } from '@/utils/adminMetalLabels'
@@ -187,6 +188,10 @@ const selectedGiftCardCodes = ref<Giftcardcode[]>([])
const selectedGiftCard = ref<Giftcard | null>(null)
function info(val: Giftcard) {
if (!giftPageAllows('info')) {
notifyPageActionDenied('info')
return
}
// نمایش اطلاعات اصلی در BasicInfoModal
infoParams.title = 'اطلاعات کارت هدیه'
infoParams.name = giftCardCustomerLabel(val)
@@ -227,12 +232,6 @@ function info(val: Giftcard) {
key: 'مبلغ کل (ریال)',
value: usePrice(val.total ?? 0)
},
{
key: val.giftcardcodes?.length > 1 ? 'کدهای یکتا' : 'کد یکتا',
value: val.giftcardcodes?.length
? val.giftcardcodes.map((c) => c.code).join('، ')
: (val.unique_code ?? '-')
},
{
key: 'تعداد استفاده شده',
value: val.used_count ?? '-'
@@ -258,6 +257,10 @@ function info(val: Giftcard) {
}
function showGiftCardCodes(val: Giftcard) {
if (!giftPageAllows('codes')) {
notifyPageActionDenied('codes')
return
}
// نمایش کدهای کارت هدیه در مودال جداگانه
if (val.giftcardcodes && val.giftcardcodes.length > 0) {
selectedGiftCard.value = val
@@ -271,7 +274,23 @@ function showGiftCardCodes(val: Giftcard) {
//edit
let editId: number | undefined = undefined
const route = useRoute()
const userStore = useUserStore()
function giftPageAllows(flag: string) {
return allowsPageAction(
parsePageAccess(userStore.userInfo?.page_access),
userStore.hasFullPageAccess,
route.matched.map((item) => item.name),
flag
)
}
async function edit(record: Giftcard) {
if (!giftPageAllows('edit')) {
notifyPageActionDenied('edit')
return
}
addGiftCardVisible.value = true
editId = record.id
form.value = {
@@ -357,7 +376,13 @@ const resetData = () => {
}
//delete
const deleteItem = (id: number) => confirmDeleteItem('کارت هدیه', id, deleteCustomer, refreshTable)
const deleteItem = (id: number) => {
if (!giftPageAllows('delete')) {
notifyPageActionDenied('delete')
return
}
confirmDeleteItem('کارت هدیه', id, deleteGiftCard, refreshTable)
}
//add project modal
const addGiftCardVisible = ref<boolean>(false)
@@ -388,8 +413,13 @@ async function ok() {
.endOf('day')
.format('YYYY-MM-DD HH:mm:ss')
}
await createGiftCard(formData as GiftcardInput)
message.success('کارت هدیه با موفقیت ثبت شد')
if (editId) {
await updateGiftCard(editId, formData as GiftcardInput)
message.success('کارت هدیه با موفقیت ویرایش شد')
} else {
await createGiftCard(formData as GiftcardInput)
message.success('کارت هدیه با موفقیت ثبت شد')
}
addGiftCardVisible.value = false
resetData()
refreshTable()
@@ -15,7 +15,7 @@
</template>
<template #action="{ column, record }">
<div class="gap-2 flex justify-center">
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<a-tooltip v-if="can('info')" placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title>
<span>مشخصات</span>
</template>
@@ -50,6 +50,9 @@ import { getOrderTable } from '@/api/order'
import OrderMobileCardList from './OrderMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { ordersPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -12,7 +12,7 @@
</template>
<template #action="{ record }">
<div class="flex items-center justify-center gap-2">
<a-tooltip v-if="canAdjust(record)">
<a-tooltip v-if="can('amount') && canAdjust(record)">
<template #title>ویرایش مقدار</template>
<Icon
icon="vuesax-linear:edit-2"
@@ -22,7 +22,7 @@
/>
</a-tooltip>
<a-tooltip v-if="canApprove(record)">
<a-tooltip v-if="can('approve') && canApprove(record)">
<template #title>{{ physicalWithdrawApproveHelp }}</template>
<Icon
icon="vuesax-linear:tick-circle"
@@ -33,7 +33,7 @@
/>
</a-tooltip>
<ADropdown v-if="canChangeStatus(record)" :trigger="['click']">
<ADropdown v-if="can('status') && canChangeStatus(record)" :trigger="['click']">
<a-tooltip>
<template #title>{{ physicalWithdrawStatusHelp }}</template>
<Icon icon="vuesax-linear:status-up" color="#CA8A04" class="cursor-pointer" />
@@ -53,7 +53,7 @@
</template>
</ADropdown>
<a-tooltip v-if="record.sts >= 50 && record.sts <= 70">
<a-tooltip v-if="can('factor') && record.sts >= 50 && record.sts <= 70">
<template #title>دریافت فاکتور</template>
<Icon
icon="vuesax-linear:document-download"
@@ -92,6 +92,9 @@ import {
physicalWithdrawPageIntro,
physicalWithdrawStatusHelp
} from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const emits = defineEmits(['add'])
@@ -11,7 +11,7 @@
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom">
<a-tooltip v-if="can('edit')" placement="bottom">
<template #title>ویرایش</template>
<Icon
icon="vuesax-linear:edit"
@@ -20,7 +20,7 @@
@click="emits('edit', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('toggle')" placement="bottom">
<template #title>{{ record.is_active ? 'غیرفعال کردن' : 'فعال کردن' }}</template>
<Icon
:icon="record.is_active ? 'vuesax-linear:eye-slash' : 'vuesax-linear:eye'"
@@ -29,7 +29,7 @@
@click="emits('toggle', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="can('delete')" placement="bottom">
<template #title>حذف</template>
<Icon
icon="vuesax-linear:trash"
@@ -52,6 +52,9 @@ import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { cols } from '../hooks/priceTriggersColumns'
import { getPriceTriggersTable } from '@/api/priceTriggers'
import { priceTriggersPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
@@ -31,7 +31,7 @@
</template>
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('detail', record)">
<a-tooltip v-if="can('details')" placement="bottom" class="cursor-pointer" @click="emits('detail', record)">
<template #title>
<span>جزئیات محصولات</span>
</template>
@@ -63,6 +63,9 @@ import PriceMobileCardList from './PriceMobileCardList.vue'
import { pricingPageIntro } from '@/utils/adminHelpCopy'
import { isMobile } from '@core/utils/is'
import type { PriceSyncLog } from '@/model/priceSyncLog'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
defineProps<{ syncing?: boolean }>()
@@ -6,8 +6,8 @@
<span class="truncate text-sm font-700 text-gray-900">{{ item.title || '-' }}</span>
</div>
<div class="flex items-center gap-2">
<MobileIconButton icon="vuesax-linear:archive-slash" color="#e3bf00" @click="$emit('unarchive', item)" />
<MobileIconButton icon="vuesax-linear:stickynote" color="#10913A" @click="$emit('info', item)" />
<MobileIconButton v-if="can('restore')" icon="vuesax-linear:archive-slash" color="#e3bf00" @click="$emit('unarchive', item)" />
<MobileIconButton v-if="can('info')" icon="vuesax-linear:stickynote" color="#10913A" @click="$emit('info', item)" />
</div>
</div>
@@ -32,6 +32,9 @@ import type { Product } from '@/model/product'
import MobileCard from '@/components/mobile/MobileCard.vue'
import MobileIconButton from '@/components/mobile/MobileIconButton.vue'
import { usePrice } from '@core/utils/usePrice'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
defineProps<{
item: Product
@@ -11,7 +11,7 @@
<BaseTable v-if="!isMobile" class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="gap-2 flex justify-center">
<a-tooltip placement="bottom" class="cursor-pointer">
<a-tooltip v-if="can('restore')" placement="bottom" class="cursor-pointer">
<template #title>
<span>بیرون بردن از بایگانی</span>
</template>
@@ -26,7 +26,7 @@
</a-popconfirm>
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<a-tooltip v-if="can('info')" placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title>
<span>مشخصات</span>
</template>
@@ -56,6 +56,9 @@ import { getProductArchiveColumns } from '../hooks/archiveColumns'
import { isMobile } from '@core/utils/is'
import type { DashboardHelp } from '@/pages/dashboard/dashboardSectionCopy'
import DashboardLabelWithHelp from '@/pages/dashboard/components/DashboardLabelWithHelp.vue'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const productArchiveHelp: DashboardHelp = {
description:
@@ -7,6 +7,7 @@
<span class="min-w-0 truncate">{{ row.title || '-' }}</span>
<div class="flex shrink-0 items-center gap-2">
<MobileIconButton
v-if="can('buy')"
plain
:key="`buy-${row.id}-${row.open_buy}`"
icon="vuesax-linear:money-receive"
@@ -15,6 +16,7 @@
@click="$emit('toggleBuy', row)"
/>
<MobileIconButton
v-if="can('sell')"
plain
:key="`sell-${row.id}-${row.open_sell}`"
icon="vuesax-linear:money-send"
@@ -23,6 +25,7 @@
@click="$emit('toggleSell', row)"
/>
<MobileIconButton
v-if="can('list')"
plain
:key="`list-${row.id}-${row.show_in_list ?? row.active}`"
:icon="(row.show_in_list ?? row.active) ? 'vuesax-linear:tick-circle' : 'vuesax-linear:close-circle'"
@@ -31,6 +34,7 @@
@click="$emit('toggleActive', row)"
/>
<MobileIconButton
v-if="can('tv')"
plain
:key="`tv-${row.id}-${row.show_on_tv}`"
icon="vuesax-linear:monitor"
@@ -76,6 +80,7 @@
</MobileInfoGrid>
<BaseButton
v-if="can('archive')"
class="w-full"
size="middle"
type="default"
@@ -100,6 +105,9 @@ import { resolveProductTradeTypeLabel } from '@/utils/productCategories'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
import { usePrice } from '@core/utils/usePrice'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
visible: boolean
@@ -11,7 +11,7 @@
{{ item.title || '-' }}
</button>
</div>
<MobileIconButton icon="vuesax-linear:edit" color="#2563eb" :size="20" @click="$emit('edit', item)" />
<MobileIconButton v-if="can('edit')" icon="vuesax-linear:edit" color="#2563eb" :size="20" @click="$emit('edit', item)" />
</div>
<div class="mt-3 flex items-center justify-between gap-2">
@@ -26,6 +26,9 @@ import type { Product } from '@/model/product'
import MobileCard from '@/components/mobile/MobileCard.vue'
import MobileIconButton from '@/components/mobile/MobileIconButton.vue'
import { usePrice } from '@core/utils/usePrice'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
item: Product
@@ -57,7 +57,7 @@
</template>
<template #action="{ record }">
<div class="gap-2 flex justify-center">
<a-tooltip class="cursor-pointer" :key="`buy-${record.id}-${record.open_buy}`">
<a-tooltip v-if="can('buy')" class="cursor-pointer" :key="`buy-${record.id}-${record.open_buy}`">
<template v-if="!record.open_buy" #title><span>فعال کردن خرید</span></template>
<template v-else #title><span>غیر فعال کردن خرید</span></template>
<BaseIcon
@@ -66,7 +66,7 @@
@click="activeBuy(record)"
/>
</a-tooltip>
<a-tooltip class="cursor-pointer" :key="`sell-${record.id}-${record.open_sell}`">
<a-tooltip v-if="can('sell')" class="cursor-pointer" :key="`sell-${record.id}-${record.open_sell}`">
<template v-if="!record.open_sell" #title><span>فعال کردن فروش</span></template>
<template v-else #title><span>غیر فعال کردن فروش</span></template>
<BaseIcon
@@ -75,7 +75,7 @@
@click="activeSell(record)"
/>
</a-tooltip>
<a-tooltip class="cursor-pointer" :key="`list-${record.id}-${record.show_in_list ?? record.active}`">
<a-tooltip v-if="can('list')" class="cursor-pointer" :key="`list-${record.id}-${record.show_in_list ?? record.active}`">
<template v-if="!(record.show_in_list ?? record.active)" #title>
<span>فعال کردن نمایش در لیست محصولات</span>
</template>
@@ -95,7 +95,7 @@
@click="toggleShowInList(record)"
/>
</a-tooltip>
<a-tooltip class="cursor-pointer" :key="`tv-${record.id}-${record.show_on_tv}`">
<a-tooltip v-if="can('tv')" class="cursor-pointer" :key="`tv-${record.id}-${record.show_on_tv}`">
<template v-if="!record.show_on_tv" #title>
<span>فعال کردن نمایش در تابلو TV</span>
</template>
@@ -108,11 +108,11 @@
@click="toggleShowOnTv(record)"
/>
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<a-tooltip v-if="can('edit')" placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<template #title><span>ویرایش</span></template>
<BaseIcon icon="vuesax-linear:edit" color="#4572E9" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer">
<a-tooltip v-if="can('archive')" placement="bottom" class="cursor-pointer">
<template #title><span>بایگانی کردن</span></template>
<a-popconfirm
title="آیا این محصول بایگانی شود؟ نمایش، خرید و فروش آن غیرفعال می‌شود."
@@ -168,6 +168,9 @@ import type { TableInput } from '@core/type/baseTable'
import type { Product } from '@/model/product'
import { isMobile } from '@core/utils/is'
import { productsPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
dayjs.extend(customParseFormat)
@@ -25,7 +25,7 @@
:color="record.active ? '#54AD06' : '#ad0627'"
/>
</a-tooltip>-->
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<a-tooltip v-if="can('info')" placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title>
<span>مشخصات</span>
</template>
@@ -76,6 +76,9 @@ import { getTransactionTable } from '@/api/transaction'
import TransactionMobileCardList from './TransactionMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { transactionsPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
interface Prop {
customerId?: number
@@ -6,6 +6,7 @@
<div class="flex w-full items-center justify-between gap-3">
<span class="min-w-0 truncate">{{ sheetTitle }}</span>
<MobileIconButton
v-if="can('delete')"
plain
icon="vuesax-linear:user-remove"
color="#EB3535"
@@ -35,6 +36,9 @@ import MobileInfoGrid from '@/components/mobile/MobileInfoGrid.vue'
import MobileSheetHeader from '@/components/mobile/MobileSheetHeader.vue'
import { isMobile } from '@core/utils/is'
import { toJalaliNoTime } from '@core/utils/useDataTime'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
visible: boolean
@@ -11,6 +11,7 @@
</div>
<MobileIconButton
v-if="can('edit')"
icon="vuesax-linear:edit"
color="#4572E9"
:size="20"
@@ -24,6 +25,9 @@
import type { User } from '@/model/user'
import MobileCard from '@/components/mobile/MobileCard.vue'
import MobileIconButton from '@/components/mobile/MobileIconButton.vue'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const props = defineProps<{
item: User
@@ -0,0 +1,325 @@
<template>
<div :class="scroll ? 'access-scroll' : ''">
<a-collapse
v-model:activeKey="openKey"
accordion
:bordered="false"
expand-icon-position="start"
class="access-collapse"
:class="{ 'access-collapse--nested': !scroll }"
>
<a-collapse-panel v-for="item in list" :key="item.key">
<template #header>
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate text-sm font-medium">{{ item.title }}</span>
<span class="access-chip shrink-0" :data-state="chipState(item)">{{ summary(item) }}</span>
</div>
</template>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span class="text-xs leading-5 opacity-70">
{{ item.route ? 'ورود به این صفحه' : 'برای همهٔ صفحه‌های این بخش' }}
</span>
<div class="flex shrink-0 items-center gap-3">
<a-checkbox
class="access-check"
:checked="viewState(item).checked"
:indeterminate="viewState(item).indeterminate"
@change="toggleView(item, $event)"
>
مشاهده
</a-checkbox>
<a-checkbox
v-if="!item.route && actionPairs(item).length"
class="access-check"
:checked="opsState(item).checked"
:indeterminate="opsState(item).indeterminate"
@change="toggleAllActions(item, $event)"
>
همه عملیات
</a-checkbox>
</div>
</div>
<div
v-if="item.route && pageActions(item.route).length"
class="grid grid-cols-1 gap-1.5 sm:grid-cols-2"
>
<a-checkbox
v-for="action in pageActions(item.route)"
:key="action.key"
class="access-op"
:checked="actionEnabled(modelValue?.[item.route], action.key)"
@change="toggleAction(item.route, action.key, $event)"
>
<span class="text-xs">{{ action.label }}</span>
</a-checkbox>
</div>
<p v-else-if="item.route" class="m-0 text-xs leading-5 opacity-60">
این صفحه عملیات جدولی ندارد.
</p>
<ManagerPageAccessTree
v-if="shownChildren(item).length"
:nodes="shownChildren(item)"
:scroll="false"
:model-value="modelValue"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
</a-collapse-panel>
</a-collapse>
</div>
</template>
<script lang="ts">
export default { name: 'ManagerPageAccessTree' }
</script>
<script setup lang="ts">
import {
ADMIN_PAGE_TREE,
PAGE_TABLE_ACTIONS,
actionEnabled,
type AccessNode,
type PageAccessFlags,
type PageAccessMap
} from '@/utils/adminPageAccess'
const props = withDefaults(
defineProps<{
nodes?: AccessNode[]
scroll?: boolean
modelValue?: PageAccessMap | null
}>(),
{
scroll: true,
modelValue: () => ({})
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: PageAccessMap): void
}>()
const openKey = ref<string>()
function isShown(node: AccessNode): boolean {
if (node.enabled && !node.enabled()) return false
if (node.children?.length) return (node.children ?? []).some((child) => isShown(child))
return true
}
function shownChildren(node: AccessNode): AccessNode[] {
return (node.children ?? []).filter((child) => isShown(child))
}
const list = computed(() => (props.nodes ?? ADMIN_PAGE_TREE).filter((item) => isShown(item)))
function leavesOf(node: AccessNode): string[] {
if (!isShown(node)) return []
if (node.route) return [node.route]
return shownChildren(node).flatMap(leavesOf)
}
function pageActions(route?: string) {
return route ? PAGE_TABLE_ACTIONS[route] ?? [] : []
}
function actionPairs(node: AccessNode): { route: string; key: string }[] {
if (!isShown(node)) return []
if (node.route) {
return pageActions(node.route).map((item) => ({ route: node.route as string, key: item.key }))
}
return shownChildren(node).flatMap(actionPairs)
}
function blankRow(): PageAccessFlags {
return { view: false, edit: false, delete: false, actions: {} }
}
function withActions(route: string, row: PageAccessFlags): PageAccessFlags {
const actions: Record<string, boolean> = {}
const hasMap = !!row.actions
for (const item of pageActions(route)) {
if (hasMap) {
actions[item.key] = Object.prototype.hasOwnProperty.call(row.actions, item.key)
? Boolean(row.actions?.[item.key])
: false
} else {
actions[item.key] = actionEnabled({ ...row, view: true }, item.key)
}
}
return {
...row,
actions,
edit: Boolean(actions.edit),
delete: Boolean(actions.delete)
}
}
function viewState(node: AccessNode) {
const leaves = leavesOf(node)
const on = leaves.filter((key) => props.modelValue?.[key]?.view).length
return {
checked: leaves.length > 0 && on === leaves.length,
indeterminate: on > 0 && on < leaves.length,
on,
total: leaves.length
}
}
function opsState(node: AccessNode) {
const pairs = actionPairs(node)
const on = pairs.filter((pair) => actionEnabled(props.modelValue?.[pair.route], pair.key)).length
return {
checked: pairs.length > 0 && on === pairs.length,
indeterminate: on > 0 && on < pairs.length,
on,
total: pairs.length
}
}
function chipState(node: AccessNode) {
const view = viewState(node)
if (!view.on) return 'none'
const ops = opsState(node)
if (view.checked && (!ops.total || ops.checked)) return 'full'
return 'part'
}
function summary(node: AccessNode) {
const view = viewState(node)
const ops = opsState(node)
if (!view.on) return 'بدون دسترسی'
if (!view.checked) return `${view.on} از ${view.total} صفحه`
if (!ops.total || !ops.on) return 'فقط مشاهده'
if (ops.checked) return 'دسترسی کامل'
return `${ops.on} از ${ops.total} عملیات`
}
function commit(next: PageAccessMap) {
emit('update:modelValue', next)
}
function toggleView(node: AccessNode, event: { target?: { checked?: boolean } }) {
const checked = Boolean(event?.target?.checked)
const next: PageAccessMap = { ...(props.modelValue ?? {}) }
for (const route of leavesOf(node)) {
if (!checked) {
delete next[route]
continue
}
if (!next[route]) {
next[route] = {
view: true,
edit: false,
delete: false,
actions: Object.fromEntries(pageActions(route).map((item) => [item.key, false]))
}
continue
}
next[route] = withActions(route, { ...next[route], view: true })
}
commit(next)
}
function toggleAction(route: string, actionKey: string, event: { target?: { checked?: boolean } }) {
const checked = Boolean(event?.target?.checked)
const next: PageAccessMap = { ...(props.modelValue ?? {}) }
const row = withActions(route, { ...(next[route] ?? blankRow()), view: true })
row.actions = { ...(row.actions ?? {}), [actionKey]: checked }
row.view = true
row.edit = Boolean(row.actions.edit)
row.delete = Boolean(row.actions.delete)
next[route] = row
commit(next)
}
function toggleAllActions(node: AccessNode, event: { target?: { checked?: boolean } }) {
const checked = Boolean(event?.target?.checked)
const next: PageAccessMap = { ...(props.modelValue ?? {}) }
for (const pair of actionPairs(node)) {
const row = withActions(pair.route, {
...(next[pair.route] ?? blankRow()),
view: next[pair.route]?.view ?? checked
})
if (checked) row.view = true
row.actions = { ...(row.actions ?? {}), [pair.key]: checked }
row.edit = Boolean(row.actions.edit)
row.delete = Boolean(row.actions.delete)
next[pair.route] = row
}
commit(next)
}
</script>
<style scoped>
.access-scroll {
max-height: 28rem;
overflow: auto;
padding-inline-end: 2px;
}
.access-collapse {
background: transparent;
}
.access-collapse :deep(.ant-collapse-item) {
margin-bottom: 8px;
overflow: hidden;
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
border-radius: 10px;
background: transparent;
}
.access-collapse :deep(.ant-collapse-item:last-child) {
margin-bottom: 0;
}
.access-collapse :deep(.ant-collapse-header) {
align-items: center !important;
padding: 8px 10px !important;
}
.access-collapse :deep(.ant-collapse-content) {
background: transparent;
border-top: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
}
.access-collapse :deep(.ant-collapse-content-box) {
padding: 8px 10px 10px !important;
}
.access-collapse--nested :deep(.ant-collapse-item) {
margin-bottom: 6px;
border-radius: 8px;
}
.access-chip {
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
border-radius: 999px;
padding: 0 8px;
font-size: 11px;
line-height: 20px;
opacity: 0.75;
}
.access-chip[data-state='part'] {
color: #d4af37;
opacity: 1;
}
.access-chip[data-state='full'] {
color: #6fbf8b;
opacity: 1;
}
.access-check {
margin-inline-start: 0 !important;
}
.access-op {
display: flex !important;
align-items: center;
width: 100%;
margin: 0 !important;
padding: 6px 8px;
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
border-radius: 8px;
}
.access-check :deep(.ant-checkbox + span),
.access-op :deep(.ant-checkbox + span) {
padding-inline-start: 6px;
padding-inline-end: 0;
}
</style>
@@ -22,19 +22,19 @@
</template>
<template #action="{ column, record }">
<div class="gap-2 flex justify-center">
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<a-tooltip v-if="can('info')" placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title>
<span>مشخصات</span>
</template>
<Icon icon="vuesax-linear:stickynote" color="#10913A" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<a-tooltip v-if="can('edit')" placement="bottom" class="cursor-pointer" @click="emits('edit', record)">
<template #title>
<span>ویرایش</span>
</template>
<Icon icon="vuesax-linear:edit" color="#4572E9" />
</a-tooltip>
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('delete', record.id)">
<a-tooltip v-if="can('delete')" placement="bottom" class="cursor-pointer" @click="emits('delete', record.id)">
<template #title>
<span>حذف مدیر</span>
</template>
@@ -66,6 +66,9 @@ import { cols } from '../hooks/usersColumns'
import ManagerMobileCardList from './ManagerMobileCardList.vue'
import { isMobile } from '@core/utils/is'
import { usersPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
+45 -5
View File
@@ -18,7 +18,7 @@
<!-- </BasicMainCard>-->
<BasicModal
v-model:visible="addUserVisible"
width="40rem"
width="min(52rem, 96vw)"
:title="`${editId ? 'ویرایش' : 'افزودن'} کاربر`"
@ok="ok"
:loading="loading"
@@ -48,6 +48,19 @@
</a-switch>
</a-form-item>
</div>
<div v-if="canAssignAccess" class="mt-1 flex flex-col gap-2">
<div>
<div class="text-sm font-medium">دسترسی صفحات</div>
<p class="mt-0.5 text-xs leading-5 opacity-70">
هر صفحه را باز کنید و مشاهده و عملیات جدول همان صفحه را مشخص کنید. بسته می‌ماند تا وقتی لازم است.
</p>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm">دسترسی کامل به همهٔ صفحات</span>
<a-switch v-model:checked="fullAccess" />
</div>
<ManagerPageAccessTree v-if="!fullAccess" v-model="form.page_access" />
</div>
</a-form>
</BasicModal>
<!-- <BasicInfoModal
@@ -81,12 +94,18 @@ import { toJalaliNoTime, toTime } from '@core/utils/useDataTime'
import { confirmDeleteItem } from '@/utils/confirmModal'
import { cloneDeep } from 'lodash'
import type { Item } from '@/model/Item'
import ManagerPageAccessTree from './components/ManagerPageAccessTree.vue'
import { allGrantedMap, hasAnyView, parsePageAccess } from '@/utils/adminPageAccess'
import { useUserStore } from '@/store/userStore'
//loading
const loading = ref<boolean>(false)
//define store
const store = useStaticApiListsStore()
const userStore = useUserStore()
const canAssignAccess = computed(() => userStore.hasFullPageAccess)
const fullAccess = ref(true)
//define tbl
const table = ref(null)
@@ -112,6 +131,11 @@ const form = ref<User>({
})
const formRef = ref<FormInstance>()
watch(fullAccess, (on) => {
if (on) return
if (form.value.page_access == null) form.value.page_access = allGrantedMap()
})
//info
let infoParams = reactive<Info>({})
@@ -141,6 +165,8 @@ async function edit(record: User) {
addUserVisible.value = true
editId = record.id
form.value = cloneDeep(record)
form.value.page_access = parsePageAccess(record.page_access)
fullAccess.value = form.value.page_access == null
}
//add
@@ -164,8 +190,10 @@ const resetData = () => {
avatar_id: undefined,
sts: undefined,
id: undefined,
mobile: undefined
mobile: undefined,
page_access: null
}
fullAccess.value = true
}
//delete — DELETE /admin/user/:id
@@ -226,21 +254,33 @@ async function ok() {
formRef.value?.validateFields().then(async () => {
// console.log('salam', form.value)
// return
const payload = cloneDeep(form.value)
delete payload.repassword
if (canAssignAccess.value) {
payload.page_access = fullAccess.value ? null : payload.page_access ?? allGrantedMap()
if (!fullAccess.value && !hasAnyView(payload.page_access)) {
message.error('حداقل مشاهدهٔ یک صفحه را انتخاب کنید')
return
}
} else {
delete payload.page_access
}
loading.value = true
try {
if (editId) {
if (form.value.password) {
if (form.value.password === form.value.repassword) {
await updateUser(editId, form.value)
await updateUser(editId, payload)
} else {
message.error('پسورد وارد شده با تکرار پسورد وارد شده یکسان نیست')
return
}
} else {
await updateUser(editId, form.value)
delete payload.password
await updateUser(editId, payload)
}
} else {
await createUser(form.value)
await createUser(payload)
}
addUserVisible.value = false
refreshTable()
@@ -47,7 +47,7 @@
<BaseTable v-if="!isMobile" class="min-h-0 min-w-0 flex-1" v-bind="tblProps" @register="submit" ref="tbl">
<template #action="{ record }">
<div class="flex justify-center gap-2">
<a-tooltip placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<a-tooltip v-if="can('info')" placement="bottom" class="cursor-pointer" @click="emits('info', record)">
<template #title>
<span>مشخصات کیف پول</span>
</template>
@@ -82,6 +82,9 @@ import { totalMetalWalletTitle } from '@/utils/adminMetalLabels'
import type { WalletTableSums } from '@/model/wallet'
import { formatMetalDisplayValue, metalDisplayUnit } from '@/utils/metalWeight'
import { walletPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const [submit, reload] = useBaseTable()
const mobileListRef = ref<null | { fetch: () => Promise<void> }>(null)
@@ -12,7 +12,7 @@
<template #tracked="{ column, record }">
<div v-if="record.tracked_number">{{ record.tracked_number }}</div>
<div v-else-if="needsTrackingConfirmation(record)">
<BaseButton @click="emits('withdraw', record)">
<BaseButton v-if="can('confirm')" @click="emits('withdraw', record)">
{{ confirmLabelForRecord(record, props.flowType) }}
</BaseButton>
</div>
@@ -28,14 +28,14 @@
<Icon icon="vuesax-linear:message-text" color="#64748b" />
</a-badge>
</a-tooltip>
<a-tooltip v-if="needsTrackingConfirmation(record)" class="cursor-pointer">
<a-tooltip v-if="can('confirm') && needsTrackingConfirmation(record)" class="cursor-pointer">
<template #title><span>{{ confirmLabelForRecord(record, props.flowType) }}</span></template>
<BaseButton size="small" type="primary" @click="emits('withdraw', record)">
{{ confirmLabelForRecord(record, props.flowType) }}
</BaseButton>
</a-tooltip>
<div class="flex gap-2" v-if="record.sts <= 70 && record.sts >= 50">
<a-tooltip class="cursor-pointer">
<a-tooltip v-if="can('factor')" class="cursor-pointer">
<template #title><span>دریافت فاکتور</span></template>
<Icon
icon="vuesax-linear:document-download"
@@ -43,7 +43,7 @@
@click="$router.push({ name: 'factor', params: { id: record.id } })"
/>
</a-tooltip>
<a-tooltip class="cursor-pointer">
<a-tooltip v-if="can('status')" class="cursor-pointer">
<template #title><span>وضعیت</span></template>
<ADropdown :trigger="['click']">
<Icon icon="vuesax-linear:status-up" color="#CA8A04" @click="test(record)" />
@@ -66,7 +66,7 @@
</ADropdown>
</a-tooltip>
</div>
<a-tooltip v-if="needsWeightAssignment(record)" class="cursor-pointer">
<a-tooltip v-if="can('weight') && needsWeightAssignment(record)" class="cursor-pointer">
<template #title><span>تعیین وزن</span></template>
<Icon
icon="vuesax-linear:3d-cube-scan"
@@ -75,7 +75,7 @@
/>
</a-tooltip>
<a-tooltip
v-if="tahesabEnabled && record.tahesab_failed_id"
v-if="can('tahesab') && tahesabEnabled && record.tahesab_failed_id"
class="cursor-pointer"
@click="openTahesabRetry(record)"
>
@@ -83,7 +83,7 @@
<Icon icon="vuesax-linear:refresh-circle" color="#CA8A04" />
</a-tooltip>
<a-tooltip
v-if="zarinEnabled && record.zarin_failed_id"
v-if="can('zarin') && zarinEnabled && record.zarin_failed_id"
class="cursor-pointer"
@click="openZarinRetry(record)"
>
@@ -242,6 +242,9 @@ onMounted(() => {
import { status } from '@/pages/menuPages/withdraw/information/hooks/status'
import {toNumber} from "lodash";
import { withdrawPageIntro } from '@/utils/adminHelpCopy'
import { usePageActionAccess } from '@/composables/usePageActionAccess'
const { can } = usePageActionAccess()
const withdrawId = ref(null)
const sts = ref()
const statusList = computed(() => {
+24
View File
@@ -4,12 +4,30 @@ import { getMyInformation } from '@/api/user'
import { get } from 'lodash'
import { isDeployAdmin } from '@/utils/deployAdmin'
import { hasSmsPanelAccess, isSmsBillingEnabled } from '@/utils/smsPanelAccess'
import { setAdminAccessState } from '@/utils/adminAccessState'
import { parsePageAccess } from '@/utils/adminPageAccess'
export const useUserStore = defineStore('user', () => {
const userInfo = ref<User | undefined>(undefined)
function syncPageAccess() {
const info = userInfo.value
const deployAdmin = isDeployAdmin(info?.mobile)
const pageAccess = parsePageAccess(info?.page_access)
const unrestricted = deployAdmin || pageAccess == null
setAdminAccessState({
ready: true,
unrestricted,
pageAccess: unrestricted ? null : pageAccess ?? null,
deployAdmin,
smsPanel: hasSmsPanelAccess(info) && isSmsBillingEnabled(info)
})
}
async function getMyInfo() {
try {
userInfo.value = await getMyInformation()
syncPageAccess()
return userInfo.value
} catch (e) {
console.log(e)
@@ -37,6 +55,11 @@ export const useUserStore = defineStore('user', () => {
const isDeployAdminUser = computed(() => isDeployAdmin(userInfo.value?.mobile))
const hasFullPageAccess = computed(() => {
if (isDeployAdminUser.value) return true
return parsePageAccess(userInfo.value?.page_access) == null
})
const smsPanelAccess = computed(() => hasSmsPanelAccess(userInfo.value))
const smsBillingEnabled = computed(() => isSmsBillingEnabled(userInfo.value))
@@ -48,6 +71,7 @@ export const useUserStore = defineStore('user', () => {
canAccess,
isAdmin,
isDeployAdminUser,
hasFullPageAccess,
smsPanelAccess,
smsBillingEnabled
}
+40
View File
@@ -0,0 +1,40 @@
export type PageAccessFlags = {
view: boolean
edit?: boolean
delete?: boolean
/** کلیدهای ستون عملیات همان صفحه */
actions?: Record<string, boolean>
}
export type PageAccessMap = Record<string, PageAccessFlags>
export type AdminAccessState = {
/** null یعنی هنوز بارگذاری نشده؛ تا آمدن /admin/me محدود نکن */
ready: boolean
unrestricted: boolean
pageAccess: PageAccessMap | null
deployAdmin: boolean
smsPanel: boolean
routeName: string
}
const state: AdminAccessState = {
ready: false,
unrestricted: true,
pageAccess: null,
deployAdmin: false,
smsPanel: false,
routeName: ''
}
export function readAdminAccessState(): AdminAccessState {
return state
}
export function setAdminAccessState(next: Partial<AdminAccessState>) {
Object.assign(state, next)
}
export function setCurrentAdminRoute(name: string) {
state.routeName = name
}
+580
View File
@@ -0,0 +1,580 @@
import { message } from 'ant-design-vue'
import {
appNotificationEnabledSetting,
automationEnabledSetting,
bannerEnabledSetting,
conditionalOrderEnabledSetting,
customerSpecialPricingEnabledSetting,
getConfigApp,
giftCardEnabledSetting,
goldCreditEnabledSetting,
hasLandingSetting,
isStoreApp,
priceProviderSetting,
priceTriggerEnabledSetting,
supportChatMenuEnabledSetting,
trackingSystemsEnabledSetting
} from '@/utils/config'
import {
readAdminAccessState,
type PageAccessFlags,
type PageAccessMap
} from '@/utils/adminAccessState'
export type { PageAccessFlags, PageAccessMap }
export const PAGE_DENIED_MESSAGE = 'شما به این صفحه دسترسی ندارید'
export const EDIT_DENIED_MESSAGE = 'شما مجوز ویرایش در این صفحه را ندارید'
export const DELETE_DENIED_MESSAGE = 'شما مجوز حذف در این صفحه را ندارید'
export type AccessNode = {
key: string
title: string
route?: string
aliases?: string[]
enabled?: () => boolean
children?: AccessNode[]
}
const OPEN_ALWAYS = new Set(['login', 'notFound', '404', 'components'])
export const ADMIN_PAGE_TREE: AccessNode[] = [
{
key: 'dashboardMain',
title: 'میز کار',
route: 'dashboardMain',
aliases: ['dashboard', 'main']
},
{
key: 'group:customers',
title: 'مشتریان',
aliases: ['customers', 'customersMain'],
children: [
{ key: 'customerMain_limitedOrder', title: 'لیست مشتریان', route: 'customerMain_limitedOrder' },
{ key: 'customerMain_archive', title: 'بایگانی', route: 'customerMain_archive' },
{ key: 'customerMain_online', title: 'کاربران آنلاین', route: 'customerMain_online' },
{
key: 'customerMain_specialPrice',
title: 'قیمت ویژه',
route: 'customerMain_specialPrice',
enabled: () => customerSpecialPricingEnabledSetting.value
},
{
key: 'customerMain_goldCredit',
title: 'اعتبار',
route: 'customerMain_goldCredit',
enabled: () => goldCreditEnabledSetting.value
}
]
},
{
key: 'group:products',
title: 'محصولات',
aliases: ['products', 'productsMain'],
children: [
{ key: 'productsMain_limitedOrder', title: 'لیست محصولات', route: 'productsMain_limitedOrder' },
{ key: 'productsMain_archive', title: 'بایگانی', route: 'productsMain_archive' },
{ key: 'productsMain_categories', title: 'دسته‌بندی‌ها', route: 'productsMain_categories' }
]
},
{
key: 'ordersMain',
title: 'سفارشات',
route: 'ordersMain',
aliases: ['orders']
},
{
key: 'transactionsMain',
title: 'گزارشات',
route: 'transactionsMain',
aliases: ['transactions']
},
{
key: 'group:hesab',
title: 'حساب کتاب',
aliases: ['hesabKetab', 'hesabKetabMain'],
children: [
{ key: 'accountsMain', title: 'حساب‌ها', route: 'accountsMain' },
{ key: 'walletMain', title: 'کیف پول', route: 'walletMain' },
{ key: 'physicalWithdrawMain', title: 'برداشت فیزیکی', route: 'physicalWithdrawMain' },
{
key: 'withdrawMain',
title: 'برداشت و واریز',
route: 'withdrawMain',
aliases: ['factor']
},
{ key: 'pricingMain', title: 'تاریخچه قیمت', route: 'pricingMain' },
{
key: 'priceTriggers',
title: 'هشدار قیمت',
route: 'priceTriggers',
enabled: () => priceTriggerEnabledSetting.value
},
{
key: 'conditionalOrders',
title: 'سفارش شرطی',
route: 'conditionalOrders',
enabled: () => conditionalOrderEnabledSetting.value
},
{
key: 'colleagueOrdersMain',
title: 'تراکنش با اپلیکیشن همکار',
route: 'colleagueOrdersMain',
enabled: () => priceProviderSetting.value === 'montzar'
},
{ key: 'accountingMain', title: 'حسابداری', route: 'accountingMain' }
]
},
{
key: 'supportMain',
title: 'چت پشتیبانی',
route: 'supportMain',
aliases: ['support'],
enabled: () => supportChatMenuEnabledSetting.value
},
{
key: 'group:website',
title: 'تنظیمات وبسایت',
aliases: ['websiteSettings', 'websiteSettingsMain'],
enabled: () => hasLandingSetting.value,
children: [
{ key: 'websiteLandingSite', title: 'تنظیمات کلی سایت', route: 'websiteLandingSite' },
{ key: 'websiteLandingHeader', title: 'هدر و ناوبری', route: 'websiteLandingHeader' },
{ key: 'websiteLandingHero', title: 'هیرو', route: 'websiteLandingHero' },
{ key: 'websiteLandingLivePrice', title: 'لیبل‌های قیمت لحظه‌ای', route: 'websiteLandingLivePrice' },
{ key: 'websiteLandingCalculator', title: 'عنوان ماشین حساب', route: 'websiteLandingCalculator' },
{ key: 'websiteLandingFeature', title: 'امکانات', route: 'websiteLandingFeature' },
{ key: 'websiteLandingSteps', title: 'مراحل شروع', route: 'websiteLandingSteps' },
{ key: 'websiteLandingTrust', title: 'اعتماد و مجوزها', route: 'websiteLandingTrust' },
{ key: 'websiteLandingFaq', title: 'سوالات متداول', route: 'websiteLandingFaq' },
{ key: 'websiteLandingBottomCta', title: 'سکشن پایین صفحه', route: 'websiteLandingBottomCta' },
{ key: 'websiteLandingFooter', title: 'فوتر', route: 'websiteLandingFooter' }
]
},
{
key: 'group:settings',
title: 'پیکربندی',
aliases: ['settings', 'settingMain'],
children: [
{ key: 'configuration', title: 'تنظیمات', route: 'configuration' },
{
key: 'captureSitesMain',
title: 'سایت‌های ری‌گیری',
route: 'captureSitesMain',
enabled: () => trackingSystemsEnabledSetting.value
},
{
key: 'advancedConfiguration',
title: 'تنظیمات پیشرفته',
route: 'advancedConfiguration',
enabled: () => readAdminAccessState().deployAdmin
},
{
key: 'smsPanelMain',
title: 'پنل پیامک',
route: 'smsPanelMain',
enabled: () => readAdminAccessState().smsPanel
},
{ key: 'tradingHours', title: 'ساعت کاری', route: 'tradingHours' },
{
key: 'banners',
title: 'بنرها',
route: 'banners',
enabled: () => bannerEnabledSetting.value
},
{
key: 'appNotifications',
title: 'اعلان‌ها',
route: 'appNotifications',
enabled: () => appNotificationEnabledSetting.value
},
{
key: 'automations',
title: 'اتوماسیون',
route: 'automations',
enabled: () => automationEnabledSetting.value
},
{ key: 'smsMain', title: 'پیامک‌ها', route: 'smsMain' },
{
key: 'giftCardMain',
title: 'کارت هدیه',
route: 'giftCardMain',
enabled: () => giftCardEnabledSetting.value
},
{
key: 'discounts',
title: 'تخفیفات',
route: 'discounts',
enabled: () => !isStoreApp() && getConfigApp('name') === 'goldsun'
},
{ key: 'usersMain', title: 'مدیران', route: 'usersMain' }
]
}
]
export type PageTableAction = { key: string; label: string }
/** عملیات‌های ستون «عملیات» هر صفحه. صفحه‌های بدون جدول فقط مشاهده دارند. */
export const PAGE_TABLE_ACTIONS: Record<string, PageTableAction[]> = {
customerMain_limitedOrder: [
{ key: 'charge', label: 'شارژ کیف پول' },
{ key: 'tahesab', label: 'ثبت در ته‌حساب' },
{ key: 'zarin', label: 'ثبت در زرین' },
{ key: 'pargar', label: 'ثبت در پرگار' },
{ key: 'accountingCode', label: 'ویرایش کد حسابداری' },
{ key: 'info', label: 'مشخصات' },
{ key: 'edit', label: 'ویرایش' },
{ key: 'referral', label: 'سود چندسطحی معرف' },
{ key: 'sms', label: 'ارسال پیامک' },
{ key: 'approve', label: 'تایید کاربر' },
{ key: 'archive', label: 'بایگانی' },
{ key: 'active', label: 'فعال/غیرفعال حساب' },
{ key: 'trade', label: 'مجاز سفارش' }
],
customerMain_archive: [
{ key: 'restore', label: 'خروج از بایگانی' },
{ key: 'info', label: 'مشخصات' }
],
customerMain_specialPrice: [
{ key: 'edit', label: 'ویرایش' },
{ key: 'toggle', label: 'فعال/غیرفعال' },
{ key: 'delete', label: 'حذف' }
],
customerMain_goldCredit: [
{ key: 'detail', label: 'جزئیات' },
{ key: 'edit', label: 'ویرایش' },
{ key: 'approve', label: 'تأیید درخواست' },
{ key: 'reject', label: 'رد درخواست' }
],
productsMain_limitedOrder: [
{ key: 'buy', label: 'خرید' },
{ key: 'sell', label: 'فروش' },
{ key: 'list', label: 'نمایش در لیست' },
{ key: 'tv', label: 'نمایش در تابلو' },
{ key: 'edit', label: 'ویرایش' },
{ key: 'archive', label: 'بایگانی' }
],
productsMain_archive: [
{ key: 'restore', label: 'خروج از بایگانی' },
{ key: 'info', label: 'مشخصات' }
],
ordersMain: [{ key: 'info', label: 'مشخصات' }],
transactionsMain: [{ key: 'info', label: 'مشخصات' }],
accountsMain: [
{ key: 'toggle', label: 'فعال/غیرفعال کارت' },
{ key: 'delete', label: 'حذف کارت' }
],
walletMain: [{ key: 'info', label: 'مشخصات کیف پول' }],
physicalWithdrawMain: [
{ key: 'amount', label: 'ویرایش مقدار' },
{ key: 'approve', label: 'تأیید' },
{ key: 'status', label: 'وضعیت' },
{ key: 'factor', label: 'دریافت فاکتور' }
],
withdrawMain: [
{ key: 'confirm', label: 'تأیید تراکنش' },
{ key: 'factor', label: 'دریافت فاکتور' },
{ key: 'status', label: 'وضعیت' },
{ key: 'weight', label: 'تعیین وزن' },
{ key: 'tahesab', label: 'ارسال به ته‌حساب' },
{ key: 'zarin', label: 'ارسال به زرین' }
],
pricingMain: [{ key: 'details', label: 'جزئیات محصولات' }],
priceTriggers: [
{ key: 'edit', label: 'ویرایش' },
{ key: 'toggle', label: 'فعال/غیرفعال' },
{ key: 'delete', label: 'حذف' }
],
conditionalOrders: [
{ key: 'edit', label: 'ویرایش' },
{ key: 'toggle', label: 'فعال/غیرفعال' },
{ key: 'delete', label: 'حذف' }
],
accountingMain: [
{ key: 'doc', label: 'اطلاعات سند' },
{ key: 'register', label: 'ثبت مشتری' },
{ key: 'send', label: 'ارسال سند' }
],
appNotifications: [
{ key: 'recipients', label: 'گیرندگان' },
{ key: 'readers', label: 'بازدیدکنندگان' },
{ key: 'edit', label: 'ویرایش' },
{ key: 'toggle', label: 'فعال/غیرفعال' },
{ key: 'delete', label: 'حذف' }
],
automations: [
{ key: 'preview', label: 'اجرای آزمایشی' },
{ key: 'log', label: 'لاگ ارسال' },
{ key: 'edit', label: 'ویرایش' },
{ key: 'toggle', label: 'فعال/غیرفعال' },
{ key: 'delete', label: 'حذف' }
],
giftCardMain: [
{ key: 'info', label: 'مشخصات' },
{ key: 'codes', label: 'کدها' },
{ key: 'edit', label: 'ویرایش' },
{ key: 'delete', label: 'حذف' }
],
discounts: [{ key: 'edit', label: 'ویرایش' }],
usersMain: [
{ key: 'info', label: 'مشخصات' },
{ key: 'edit', label: 'ویرایش' },
{ key: 'delete', label: 'حذف مدیر' }
]
}
export function leafRouteKeys(): string[] {
const keys: string[] = []
const walk = (nodes: AccessNode[]) => {
for (const node of nodes) {
if (node.route) keys.push(node.route)
if (node.children?.length) walk(node.children)
}
}
walk(ADMIN_PAGE_TREE)
return keys
}
export function allGrantedMap(): PageAccessMap {
const map: PageAccessMap = {}
for (const key of leafRouteKeys()) {
const actions = Object.fromEntries((PAGE_TABLE_ACTIONS[key] ?? []).map((item) => [item.key, true]))
map[key] = {
view: true,
edit: Boolean(actions.edit),
delete: Boolean(actions.delete),
actions
}
}
return map
}
/** مقدار یک عملیات؛ رکوردهای قدیمی بدون actions، ویرایش/حذف را از همان فیلد و بقیه را از مشاهده می‌گیرند. */
export function actionEnabled(row: PageAccessFlags | undefined, actionKey: string): boolean {
if (!row?.view) return false
if (row.actions && Object.prototype.hasOwnProperty.call(row.actions, actionKey)) {
return Boolean(row.actions[actionKey])
}
if (actionKey === 'edit') return Boolean(row.edit)
if (actionKey === 'delete') return Boolean(row.delete)
return true
}
export function parsePageAccess(value: unknown): PageAccessMap | null {
if (value == null || value === '') return null
let parsed = value
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed)
} catch {
return null
}
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) return null
return parsed as PageAccessMap
}
export function hasAnyView(map?: PageAccessMap | null): boolean {
if (!map) return false
return Object.values(map).some((row) => row?.view)
}
function isNodeEnabled(node: AccessNode): boolean {
if (node.enabled && !node.enabled()) return false
return true
}
function findNode(name: string, nodes: AccessNode[] = ADMIN_PAGE_TREE): AccessNode | null {
for (const node of nodes) {
if (node.key === name || node.route === name || node.aliases?.includes(name)) return node
if (node.children?.length) {
const found = findNode(name, node.children)
if (found) return found
}
}
return null
}
function flagOn(route: string, flag: 'view' | 'edit' | 'delete'): boolean {
const row = readAdminAccessState().pageAccess?.[route]
if (flag === 'view') return Boolean(row?.view)
return actionEnabled(row, flag)
}
function nodeOpens(node: AccessNode): boolean {
if (!isNodeEnabled(node)) return false
if (node.route) return flagOn(node.route, 'view')
return (node.children ?? []).some(nodeOpens)
}
export function canOpenRoute(routeName: unknown): boolean {
const name = String(routeName ?? '')
if (!name || OPEN_ALWAYS.has(name)) return true
const access = readAdminAccessState()
if (!access.ready || access.unrestricted) return true
const node = findNode(name)
if (!node) return false
return nodeOpens(node)
}
export function firstAllowedRouteName(): string | null {
const access = readAdminAccessState()
if (!access.ready || access.unrestricted) return 'dashboardMain'
return firstOpen(ADMIN_PAGE_TREE)
}
export function firstAllowedInBranch(routeName: string): string | null {
const node = findNode(routeName)
if (!node) return null
return firstOpen([node])
}
function firstOpen(nodes: AccessNode[]): string | null {
for (const node of nodes) {
if (!isNodeEnabled(node)) continue
if (node.route && flagOn(node.route, 'view')) return node.route
if (node.children?.length) {
const found = firstOpen(node.children)
if (found) return found
}
}
return null
}
/** دسترسی ویرایش/حذف همان صفحه. routeName می‌تواند نام فعلی یا زنجیرهٔ matched باشد. */
export function allowsPageFlag(
pageAccess: PageAccessMap | null,
fullAccess: boolean,
routeName: unknown,
flag: keyof PageAccessFlags
): boolean {
if (fullAccess) return true
if (!pageAccess) return false
const names = Array.isArray(routeName) ? routeName : [routeName]
for (let i = names.length - 1; i >= 0; i--) {
const node = findNode(String(names[i] ?? ''))
if (!node?.route) continue
const row = pageAccess[node.route]
if (flag === 'view') return Boolean(row?.view)
return actionEnabled(row, flag)
}
return false
}
export function allowsPageAction(
pageAccess: PageAccessMap | null,
fullAccess: boolean,
routeName: unknown,
actionKey: string
): boolean {
if (fullAccess) return true
if (!pageAccess) return false
const names = Array.isArray(routeName) ? routeName : [routeName]
for (let i = names.length - 1; i >= 0; i--) {
const node = findNode(String(names[i] ?? ''))
if (!node?.route) continue
return actionEnabled(pageAccess[node.route], actionKey)
}
return false
}
export function notifyPageActionDenied(flag: string) {
const content =
flag === 'delete'
? DELETE_DENIED_MESSAGE
: flag === 'edit'
? EDIT_DENIED_MESSAGE
: 'شما به این عملیات دسترسی ندارید'
message.warning({
content,
key: 'admin-page-denied'
})
}
export function notifyPageDenied() {
message.warning({ content: PAGE_DENIED_MESSAGE, key: 'admin-page-denied' })
}
export function confirmMenuAccess(routeName: unknown): boolean {
if (canOpenRoute(routeName)) return true
notifyPageDenied()
return false
}
function adminRelativePath(url: string): string | null {
const clean = url.split('?')[0].replace(/^https?:\/\/[^/]+/i, '')
const idx = clean.indexOf('/admin/')
if (idx === -1) return null
return clean.slice(idx + '/admin/'.length).replace(/^\/+|\/+$/g, '').toLowerCase()
}
const READ_POST_EXACT = new Set([
'getbalance',
'prices',
'prices/chart',
'gateway',
'finance',
'check_card',
'dashboard',
'dashboard/finance',
'dashboard/customer/all_sell',
'dashboard/customer/single/all_sell',
'dashboard/customer/flow',
'dashboard/customer/order-peak-hours',
'dashboard/profit-conversions',
'dashboard/montzar-reconcile'
])
function isReadPost(path: string): boolean {
if (path.includes('/table') || path.endsWith('table')) return true
if (path.includes('preview')) return true
if (path.includes('get_customer_list') || path.includes('products-by-categories')) return true
if (path.includes('mande-by-codes')) return true
if (READ_POST_EXACT.has(path)) return true
if (/^customers\/\d+\/(referrals|funds|piggy-funds)$/.test(path)) return true
if (/^customers\/\d+\/assets\/\d+\/(piggy-funds|transactions)$/.test(path)) return true
if (/^customers\/\d+\/piggy-funds\/\d+\/transactions$/.test(path)) return true
if (/^customers\/\d+\/gold_credit\/installments$/.test(path)) return true
if (/^chat\/(focus|unfocus|ensure-webhook)$/.test(path)) return true
return false
}
function currentRouteAllows(flag: keyof PageAccessFlags): boolean {
const access = readAdminAccessState()
if (!access.ready || access.unrestricted) return true
const name = access.routeName
if (!name || OPEN_ALWAYS.has(name)) return true
const node = findNode(name)
const route = node?.route
if (!route) return false
if (node?.enabled && !node.enabled()) return false
return flagOn(route, flag)
}
/** اگر عملیات مجاز نیست، متن پیام را برمی‌گرداند. */
export function mutationBlockMessage(method: string, url?: string): string | null {
const access = readAdminAccessState()
if (!access.ready || access.unrestricted) return null
const path = adminRelativePath(String(url ?? ''))
if (path == null) return null
if (path === 'me' || path === 'me/update') return null
const verb = method.toUpperCase()
if (verb === 'GET' || verb === 'HEAD' || verb === 'OPTIONS') {
if (/\/active(\/|$)/.test(path)) {
return currentRouteAllows('edit') ? null : EDIT_DENIED_MESSAGE
}
return null
}
if (verb === 'DELETE') {
return currentRouteAllows('delete') ? null : DELETE_DENIED_MESSAGE
}
if (verb === 'PUT' || verb === 'PATCH') {
return currentRouteAllows('edit') ? null : EDIT_DENIED_MESSAGE
}
if (verb === 'POST') {
if (isReadPost(path)) return null
return currentRouteAllows('edit') ? null : EDIT_DENIED_MESSAGE
}
return null
}