chore: commit pending admin-panel, api auth and parity doc changes

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-14 04:33:24 +03:30
co-authored by Claude Haiku 5.5
parent 55856cac89
commit cf014e1f6c
41 changed files with 3471 additions and 969 deletions
+17 -5
View File
@@ -13,12 +13,13 @@
variables that ant itself ever consumed are carried over — `secondary-color`
and `third-color` were never real ant variables (confirmed dead in the old
LESS build) and stay only as the CSS custom properties in utilities.less.
ant derives its light "primary background" steps from colorPrimary; for a
navy this dark the generated steps are mid-greys (#8d9296, #81858a), which
painted every selected option / tree node / picker cell grey. Those steps
are pinned to light navy tints instead (see `themeTokens`).
-->
<a-config-provider
:locale="faIR"
direction="rtl"
:theme="{ token: { colorPrimary: '#1c2d56', colorLink: '#1DA57A', borderRadius: 6 } }"
>
<a-config-provider :locale="faIR" direction="rtl" :theme="{ token: themeTokens }">
<div id="app" :class="vueAppClasses">
<router-view @setAppClasses="setAppClasses" />
<AppUpdateGate />
@@ -40,6 +41,17 @@ import { useLayoutStore } from './stores/layout';
import AppUpdateGate from './components/common/AppUpdateGate.vue';
import { userName } from './utils';
const themeTokens = {
colorPrimary: '#1c2d56',
colorLink: '#1DA57A',
borderRadius: 6,
controlHeight: 36,
colorPrimaryBg: '#eef1f8',
colorPrimaryBgHover: '#e1e6f2',
colorPrimaryBorder: '#c5cde0',
colorPrimaryBorderHover: '#9ea9c6',
};
const layout = useLayoutStore();
const vueAppClasses = ref<string[]>([]);
@@ -21,6 +21,73 @@
z-index: 2500000;
}
/*
* Global light selected/active states — soft primary tint, never grey/dark.
* Wins over ant CSS-in-JS when specificity fails on nested menus / portals.
* Keep in sync with App.vue `antTheme` tokens.
*/
:root {
--selected-bg: rgba(28, 45, 86, 0.08);
--selected-bg-hover: rgba(28, 45, 86, 0.12);
--hover-bg: rgba(28, 45, 86, 0.04);
}
.ant-menu-light .ant-menu-item-selected,
.ant-menu-light .ant-menu-item-selected:hover,
.ant-menu-inline .ant-menu-item-selected,
.ant-menu-inline .ant-menu-item-selected:hover,
.ant-menu-submenu .ant-menu-item-selected,
.ant-menu-submenu .ant-menu-item-selected:hover,
.vertical-nav-menu .ant-menu-item-selected,
.vertical-nav-menu .ant-menu-item-selected:hover,
.vertical-nav-menu .ant-menu-sub .ant-menu-item-selected,
.vertical-nav-menu .ant-menu-sub .ant-menu-item-selected:hover {
color: var(--primary-color, #1c2d56) !important;
background: var(--selected-bg) !important;
font-weight: 600;
}
.ant-menu-light .ant-menu-item-selected .anticon,
.ant-menu-inline .ant-menu-item-selected .anticon,
.vertical-nav-menu .ant-menu-item-selected .anticon {
color: var(--primary-color, #1c2d56) !important;
}
.ant-menu-light .ant-menu-item-selected::after,
.ant-menu-inline .ant-menu-item-selected::after,
.vertical-nav-menu .ant-menu-item-selected::after {
display: none !important;
border: none !important;
}
/* Select / Cascader / TreeSelect / Transfer / Mentions / Dropdown */
.ant-select-item-option-selected:not(.ant-select-item-option-disabled),
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
color: var(--primary-color, #1c2d56) !important;
}
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
background: var(--selected-bg) !important;
font-weight: 600;
}
.ant-select-item-option-active:not(.ant-select-item-option-disabled):not(
.ant-select-item-option-selected
) {
background: var(--hover-bg) !important;
}
.ant-cascader-menu-item-active:not(.ant-cascader-menu-item-disabled),
.ant-tree-treenode-selected .ant-tree-node-content-wrapper,
.ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover,
.ant-dropdown-menu-item-selected,
.ant-dropdown-menu-submenu-title-selected,
.ant-transfer-list-content-item-checked,
.ant-mentions-dropdown-menu-item-active {
color: var(--primary-color, #1c2d56) !important;
background: var(--selected-bg) !important;
}
/* REMOVED: an `.ant-tabs-nav { direction: ltr }` override used to live here,
forcing the tab strip itself to lay out left-to-right while each tab's
content stayed RTL. That fought ADV4's own RTL handling (ConfigProvider's
@@ -102,11 +102,6 @@ body #app .ant-card {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.03);
}
// ── Sidebar menu selection colour ────────────────────────────────────
body #app .ant-menu-item-selected {
background-color: #f1f1f1;
}
// ── Full-height list pages ──────────────────────────────────────────
// Main.vue stretches #content-area/.content-area__content to fill the
// viewport below the navbar; the routed page's own root needs to pick that
@@ -1,22 +1,17 @@
<template>
<!--
Ported from legacy henza_admin/src/components/statistics-cards/StatisticsCardLine.vue.
The legacy component renders an ApexCharts sparkline under the figure, but
EVERY live caller passes :hideChart="true" (verified: 3 of 3 on the wholesale
dashboard), and the only apexchart markup in either dashboard sits inside
commented-out Vuexy demo cards. So the chart branch is not ported and
vue-apexcharts is not a dependency — adding it would ship a library nothing
renders.
Visual chrome unified with admin panel cards (2026-10-14). Props and call-sites
unchanged. Sparkline branch still omitted (every live caller used hideChart).
-->
<a-card class="stat-card">
<a-card class="stat-card" :bordered="true">
<div class="stat-row">
<span class="stat-icon" :class="`icon-${color}`">
<span class="stat-icon">
<component :is="iconComponent" />
</span>
<div>
<h2 class="stat-value">{{ statistic }}</h2>
<div class="stat-copy">
<span class="stat-title">{{ statisticTitle }}</span>
<h2 class="stat-value">{{ statistic }}</h2>
</div>
</div>
</a-card>
@@ -29,6 +24,7 @@ import {
TeamOutlined,
DollarOutlined,
BarChartOutlined,
CreditCardOutlined,
} from '@ant-design/icons-vue';
const props = withDefaults(
@@ -36,6 +32,7 @@ const props = withDefaults(
icon?: string;
statistic?: string | number;
statisticTitle?: string;
/** Kept for call-site compatibility; chrome no longer varies by colour. */
color?: string;
/** Kept for call-site parity; the chart branch is not ported (see above). */
hideChart?: boolean;
@@ -49,6 +46,7 @@ const ICONS: Record<string, unknown> = {
ShoppingBagIcon: ShoppingOutlined,
DollarSignIcon: DollarOutlined,
BarChartIcon: BarChartOutlined,
CreditCardIcon: CreditCardOutlined,
};
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined);
@@ -57,46 +55,50 @@ const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined
<style scoped>
.stat-card {
height: 100%;
border-radius: 8px;
background: #ffffff;
}
.stat-card :deep(.ant-card-body) {
padding: 16px;
}
.stat-row {
display: flex;
align-items: center;
gap: 1rem;
gap: 12px;
}
.stat-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
font-size: 22px;
width: 40px;
height: 40px;
border-radius: 8px;
font-size: 20px;
flex-shrink: 0;
}
.icon-primary {
background: rgba(28, 45, 86, 0.1);
background: rgba(28, 45, 86, 0.06);
color: #1c2d56;
}
.icon-warning {
background: rgba(255, 159, 67, 0.12);
color: #ff9f43;
.stat-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.icon-success {
background: rgba(40, 199, 111, 0.12);
color: #28c76f;
}
.icon-danger {
background: rgba(234, 84, 85, 0.12);
color: #ea5455;
.stat-title {
font-size: 12px;
line-height: 1.4;
color: #8c8c8c;
font-weight: 400;
}
.stat-value {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1c2d56;
}
.stat-title {
font-size: 12px;
color: #888;
line-height: 1.25;
word-break: break-word;
}
@media (max-width: 575px) {
@@ -104,24 +106,18 @@ const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined
padding: 12px;
}
.stat-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
gap: 10px;
}
.stat-icon {
width: 36px;
height: 36px;
font-size: 17px;
font-size: 18px;
}
.stat-value {
font-size: 16px;
line-height: 1.4;
word-break: break-word;
}
.stat-title {
display: block;
font-size: 11px;
line-height: 1.5;
}
}
</style>
@@ -0,0 +1,323 @@
<template>
<!--
Multi-file gallery uploader. Same `file/create` endpoint as
ImageUploadInput (one file per request), but files can be picked or dropped
in bulk; each uploads on its own with progress, and the bound value stays
the ordered list of stored URLs.
-->
<div class="gallery-upload">
<a-upload-dragger
accept="image/*"
multiple
:show-upload-list="false"
:custom-request="doUpload"
:before-upload="beforeUpload"
>
<div class="gallery-upload__zone">
<CloudUploadOutlined class="gallery-upload__icon" />
<p class="gallery-upload__title">{{ placeholder }}</p>
<p class="gallery-upload__hint">
تصاویر را اینجا بکشید و رها کنید یا کلیک کنید — انتخاب چند تصویر به‌صورت همزمان امکان‌پذیر است
</p>
</div>
</a-upload-dragger>
<div v-if="items.length" class="gallery-upload__grid">
<div
v-for="item in items"
:key="item.uid"
class="tile"
:class="{ 'is-uploading': item.status === 'uploading' }"
>
<img :src="item.url ?? item.preview" alt="" />
<div v-if="item.status === 'uploading'" class="tile__progress">
<a-progress type="circle" :percent="item.percent" :size="40" :stroke-width="8" stroke-color="#fff" trail-color="rgba(255,255,255,.3)" :show-info="false" />
</div>
<div v-else class="tile__overlay">
<button type="button" class="tile__btn" title="نمایش" @click="preview(item)">
<EyeOutlined />
</button>
<button type="button" class="tile__btn tile__btn--danger" title="حذف" @click="remove(item.uid)">
<DeleteOutlined />
</button>
</div>
</div>
</div>
<div v-if="items.length" class="gallery-upload__meta">
<span>{{ doneCount }} تصویر</span>
<span v-if="pendingCount" class="gallery-upload__pending">
<LoadingOutlined /> در حال بارگذاری {{ pendingCount }} تصویر…
</span>
</div>
<a-modal :open="previewVisible" :footer="null" @cancel="previewVisible = false">
<img :src="previewSrc" style="width: 100%" alt="" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { message, Upload } from 'ant-design-vue';
import { CloudUploadOutlined, DeleteOutlined, EyeOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import api from '../../api/client';
const props = withDefaults(
defineProps<{
/** Ordered list of stored file URLs. */
modelValue?: string[];
/** Free-form tag recorded on each upload, e.g. 'product.gallery'. */
purpose?: string;
placeholder?: string;
}>(),
{ modelValue: () => [], purpose: undefined, placeholder: 'افزودن تصاویر گالری' },
);
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>();
const MAX_PARALLEL = 3;
interface Item {
uid: string;
status: 'uploading' | 'done';
url?: string;
/** Local object URL shown while the file is still uploading. */
preview?: string;
percent: number;
}
const items = ref<Item[]>([]);
const previewVisible = ref(false);
const previewSrc = ref('');
const doneUrls = () => items.value.filter((i) => i.status === 'done' && i.url).map((i) => i.url!);
const doneCount = computed(() => items.value.filter((i) => i.status === 'done').length);
const pendingCount = computed(() => items.value.filter((i) => i.status === 'uploading').length);
function sync() {
emit('update:modelValue', doneUrls());
}
/** Keeps the tiles in step when the parent loads an existing record. */
watch(
() => props.modelValue,
(urls) => {
if (JSON.stringify(doneUrls()) === JSON.stringify(urls ?? [])) return;
const pending = items.value.filter((i) => i.status === 'uploading');
items.value = [
...(urls ?? []).map((url) => ({ uid: url, status: 'done' as const, url, percent: 100 })),
...pending,
];
},
{ immediate: true },
);
function beforeUpload(file: File) {
if (!file.type.startsWith('image/')) {
message.error(`«${file.name}» تصویر نیست`);
return Upload.LIST_IGNORE;
}
return true;
}
// A small queue so a 30-file drop doesn't open 30 requests at once.
let active = 0;
const queue: Array<() => void> = [];
function runQueued<T>(job: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const start = () => {
active++;
job()
.then(resolve, reject)
.finally(() => {
active--;
queue.shift()?.();
});
};
if (active < MAX_PARALLEL) start();
else queue.push(start);
});
}
const find = (uid: string) => items.value.find((i) => i.uid === uid);
function doUpload({ file }: { file: File & { uid?: string } }) {
const uid = file.uid ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
items.value.push({ uid, status: 'uploading', preview: URL.createObjectURL(file), percent: 0 });
runQueued(async () => {
// removed by the user while still waiting in the queue
if (!find(uid)) return null;
const form = new FormData();
form.append('file', file);
if (props.purpose) form.append('purpose', props.purpose);
// Let the browser set the multipart boundary.
const { data } = await api.post('file/create', form, {
onUploadProgress: (e) => {
const item = find(uid);
if (item && e.total) item.percent = Math.round((e.loaded / e.total) * 100);
},
});
return data as { url?: string };
})
.then((data) => {
const item = find(uid);
if (!item || !data?.url) return;
if (item.preview) URL.revokeObjectURL(item.preview);
Object.assign(item, { status: 'done', url: data.url, preview: undefined, percent: 100 });
sync();
})
.catch((e) => {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
`بارگذاری «${file.name}» ناموفق بود`;
message.error(msg);
dropItem(uid);
});
}
function dropItem(uid: string) {
const idx = items.value.findIndex((i) => i.uid === uid);
if (idx === -1) return;
const [gone] = items.value.splice(idx, 1);
if (gone.preview) URL.revokeObjectURL(gone.preview);
}
function remove(uid: string) {
dropItem(uid);
sync();
}
function preview(item: Item) {
previewSrc.value = item.url ?? item.preview ?? '';
previewVisible.value = true;
}
onBeforeUnmount(() => {
items.value.forEach((i) => i.preview && URL.revokeObjectURL(i.preview));
});
</script>
<style scoped>
.gallery-upload {
display: flex;
flex-direction: column;
gap: 12px;
}
.gallery-upload__zone {
padding: 18px 12px;
}
.gallery-upload__icon {
font-size: 16px;
color: #1c2d56;
opacity: 0.75;
}
.gallery-upload__title {
margin: 8px 0 2px;
font-weight: 500;
font-size: 14px;
color: #101828;
}
.gallery-upload__hint {
margin: 0;
font-size: 12px;
color: #8a94a6;
}
/* ── square thumbnails ── */
.gallery-upload__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
}
.tile {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
border: 1px solid #eef0f5;
background: #f5f7fb;
}
.tile img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tile.is-uploading img {
filter: brightness(0.6);
}
.tile__progress {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.tile__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(16, 24, 40, 0.5);
opacity: 0;
transition: opacity 0.2s ease;
}
.tile:hover .tile__overlay,
.tile:focus-within .tile__overlay {
opacity: 1;
}
.tile__btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: none;
border-radius: 9px;
background: rgba(255, 255, 255, 0.92);
color: #1c2d56;
font-size: 15px;
cursor: pointer;
transition: transform 0.15s ease;
}
.tile__btn:hover {
transform: scale(1.08);
}
.tile__btn--danger {
color: #ea5455;
}
/* touch screens have no hover: keep the actions visible */
@media (hover: none) {
.tile__overlay {
opacity: 1;
align-items: flex-end;
justify-content: flex-end;
padding: 6px;
gap: 4px;
background: linear-gradient(to top, rgba(16, 24, 40, 0.55), transparent 55%);
}
.tile__btn {
width: 28px;
height: 28px;
font-size: 13px;
}
}
.gallery-upload__meta {
display: flex;
align-items: center;
gap: 14px;
font-size: 12px;
color: #8a94a6;
}
.gallery-upload__pending {
display: inline-flex;
align-items: center;
gap: 6px;
color: #1c2d56;
}
</style>
@@ -5,7 +5,7 @@
"کیف پول" nesting "لیست کیف پول‌های شارژ شده") — ant-design-vue's a-sub-menu
nests natively, the fixed two-level template just didn't recurse into it.
-->
<a-sub-menu v-if="node.submenu" :key="menuKey">
<a-sub-menu v-if="node.submenu" :key="menuKey" popup-class-name="nav-popup">
<template #icon><component :is="resolveIcon(node.icon)" v-if="node.icon" /></template>
<template #title>{{ node.i18n || node.name }}</template>
<NavMenuNode
@@ -33,6 +33,7 @@ const users = useUsersStore();
// Same visibility rule as VerticalNavMenu.vue: no permissionId = always visible.
function canSee(node: NavMenuNodeType): boolean {
if (node.superAdminOnly && !users.isSuperAdmin) return false;
if (!node.permissionId) return true;
return Boolean(users.hasPermission(node.permissionId));
}
@@ -17,11 +17,18 @@
class="vertical-nav-menu"
theme="light"
>
<div class="nav-header">
<span v-if="!collapsed" class="nav-title">{{ title }}</span>
<div class="nav-header" :class="{ 'is-collapsed': collapsed }">
<span class="nav-logo">
<img src="../../assets/images/logo/montzar-icon.png" alt="" />
</span>
<span v-if="!collapsed" class="nav-brand">
<span class="nav-title">{{ title }}</span>
<span class="nav-subtitle">پنل مدیریت</span>
</span>
</div>
<a-menu
class="nav-menu"
mode="inline"
theme="light"
:inline-collapsed="collapsed"
@@ -78,6 +85,7 @@ const users = useUsersStore();
* Preserved deliberately — see docs/ADMIN-PORT-PLAN.md.
*/
function canSee(node: NavMenuNodeData): boolean {
if (node.superAdminOnly && !users.isSuperAdmin) return false;
if (!node.permissionId) return true;
return Boolean(users.hasPermission(node.permissionId));
}
@@ -176,17 +184,246 @@ function onClick({ key }: { key: string | number }) {
<style scoped>
.vertical-nav-menu {
height: 100vh;
overflow-y: auto; /* pinned in place; its own menu scrolls if it's taller than the viewport */
border-left: 1px solid #f0f0f0;
border-left: 1px solid #eef0f5;
box-shadow: -4px 0 24px -16px rgba(16, 24, 40, 0.12);
}
.vertical-nav-menu :deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
}
.nav-header {
height: 61px; /* legacy ara/ant.less @layout-header-height */
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 0 20px;
border-bottom: 1px solid #eef0f5;
}
.nav-header.is-collapsed {
justify-content: center;
padding: 0;
}
.nav-logo {
width: 38px;
height: 38px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 11px;
background: #fff;
border: 1px solid #eef0f5;
box-shadow: 0 4px 12px -6px rgba(28, 45, 86, 0.35);
}
.nav-logo img {
width: 64%;
}
.nav-brand {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.3;
}
.nav-title {
font-size: 18px;
font-size: 16px;
font-weight: 500;
color: #1c2d56;
}
.nav-subtitle {
font-size: 11px;
color: #8a94a6;
}
/* pinned in place; only the menu scrolls if it's taller than the viewport */
.nav-menu {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 0 24px;
scrollbar-width: thin;
scrollbar-color: #e3e7ef transparent;
}
.nav-menu::-webkit-scrollbar {
width: 6px;
}
.nav-menu::-webkit-scrollbar-thumb {
border-radius: 6px;
background: #e3e7ef;
}
</style>
<!--
Unscoped: the mobile drawer teleports outside #app and ant's collapsed popups
teleport to <body>. :is(#app, ...) also out-ranks utilities.less's
`body #app .ant-menu-item-selected`.
-->
<style>
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .nav-menu.ant-menu {
border-inline-end: none !important;
background: transparent;
font-size: 13.5px;
}
/* section titles */
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item-group-title {
padding: 18px 24px 6px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
color: #a0a8b8;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline-collapsed .ant-menu-item-group-title {
padding: 12px 0 4px;
text-align: center;
font-size: 0;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline-collapsed .ant-menu-item-group-title::after {
content: '';
display: inline-block;
width: 18px;
height: 2px;
border-radius: 2px;
background: #e3e7ef;
}
/* items + submenu titles */
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline .ant-menu-item,
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline .ant-menu-submenu-title {
height: 42px;
line-height: 42px;
margin: 2px 12px;
width: calc(100% - 24px);
border-radius: 10px;
color: #475467;
transition: background 0.2s ease, color 0.2s ease;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item .ant-menu-item-icon,
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-title .ant-menu-item-icon {
font-size: 16px;
color: #98a2b3;
transition: color 0.2s ease;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item:not(.ant-menu-item-selected):hover,
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-title:hover {
background: #f5f7fb !important;
color: #1c2d56 !important;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item:hover .ant-menu-item-icon,
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-title:hover .ant-menu-item-icon {
color: #1c2d56;
}
/* active item */
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item-selected {
position: relative;
background: rgba(28, 45, 86, 0.08) !important;
color: #1c2d56 !important;
font-weight: 500;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item-selected .ant-menu-item-icon {
color: #1c2d56;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline .ant-menu-item-selected::after {
content: '';
position: absolute;
inset-inline-start: 0;
inset-inline-end: auto;
top: 10px;
bottom: 10px;
width: 3px;
border: none;
border-radius: 3px;
background: #1c2d56;
opacity: 1;
transform: none;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-selected > .ant-menu-submenu-title,
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-selected > .ant-menu-submenu-title .ant-menu-item-icon {
color: #1c2d56;
font-weight: 500;
}
/* nested lists: no grey slab, a soft guide line instead */
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-sub.ant-menu-inline {
position: relative;
background: transparent !important;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-sub.ant-menu-inline::before {
content: '';
position: absolute;
inset-inline-start: 32px;
top: 4px;
bottom: 4px;
width: 1px;
background: #e8ebf2;
}
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-sub .ant-menu-item {
height: 38px;
line-height: 38px;
font-size: 13px;
}
/* collapsed rail: submenus open as popups teleported to <body> */
.nav-popup.ant-menu-submenu-popup > .ant-menu {
padding: 6px;
border-radius: 14px;
background: #fff;
border: 1px solid #eef0f5;
box-shadow: 0 12px 32px -12px rgba(16, 24, 40, 0.25);
}
.nav-popup .ant-menu-item,
.nav-popup .ant-menu-submenu-title {
height: 40px;
line-height: 40px;
margin: 2px 0;
width: 100%;
border-radius: 10px;
color: #475467;
font-size: 13px;
transition: background 0.2s ease, color 0.2s ease;
}
.nav-popup .ant-menu-item .ant-menu-item-icon,
.nav-popup .ant-menu-submenu-title .ant-menu-item-icon {
color: #98a2b3;
}
.nav-popup .ant-menu-item:not(.ant-menu-item-selected):hover,
.nav-popup .ant-menu-submenu-title:hover {
background: #f5f7fb !important;
color: #1c2d56 !important;
}
.nav-popup .ant-menu-item-selected {
position: relative;
background: rgba(28, 45, 86, 0.08) !important;
color: #1c2d56 !important;
font-weight: 500;
}
.nav-popup .ant-menu-item-selected .ant-menu-item-icon {
color: #1c2d56;
}
.nav-popup .ant-menu-item-selected::after {
content: '';
position: absolute;
inset-inline-start: 0;
inset-inline-end: auto;
top: 10px;
bottom: 10px;
width: 3px;
border: none;
border-radius: 3px;
background: #1c2d56;
opacity: 1;
transform: none;
}
.nav-popup .ant-menu-submenu-selected > .ant-menu-submenu-title {
color: #1c2d56;
font-weight: 500;
}
/* collapsed rail: the icon of the group holding the active page */
#app .vertical-nav-menu .ant-menu-inline-collapsed .ant-menu-submenu-selected > .ant-menu-submenu-title,
#app .vertical-nav-menu .ant-menu-inline-collapsed > .ant-menu-item-selected {
background: rgba(28, 45, 86, 0.08) !important;
color: #1c2d56 !important;
}
</style>
+8 -1
View File
@@ -12,7 +12,12 @@
sidebar/navbar that used to scroll away with it.
-->
<a-layout class="layout--main" :class="{ 'no-scroll': isAppPage }">
<VerticalNavMenu v-if="!layout.isSmallScreen" :collapsed="collapsed" />
<VerticalNavMenu
v-if="!layout.isSmallScreen"
:collapsed="collapsed"
show-toggle
@toggle="onToggleSidebar"
/>
<a-drawer
v-else
@@ -131,9 +136,11 @@ watch(
<style>
.mobile-nav-drawer .ant-drawer-body {
padding: 0;
background: #ffffff;
}
.mobile-nav-drawer .vertical-nav-menu {
height: 100%;
border-left: none;
background: #ffffff !important;
}
</style>
+15 -6
View File
@@ -36,6 +36,8 @@ export interface NavMenuNode {
tag?: string;
tagColor?: string;
permissionId?: string;
/** Shown to the super admin only, whatever the rid (see utils/superAdmin.ts). */
superAdminOnly?: boolean;
header?: string;
items?: NavMenuNode[];
submenu?: NavMenuNode[];
@@ -109,7 +111,8 @@ export const adminMenu: NavMenuNode[] = [
name: 'List',
slug: 'app-user-list',
i18n: 'لیست مشتریان تک',
permissionId:"persons.list.client.single"
permissionId:"persons.list.client.single",
superAdminOnly: true
},
{
url: '/customer/banker-list',
@@ -121,7 +124,8 @@ export const adminMenu: NavMenuNode[] = [
// API route it points at (admin/customers-omde/banker/list) is
// gated on exactly this rid, so an unauthorized click 403'd.
// Filled in to match what the server actually enforces.
permissionId:"persons.list.client.omde"
permissionId:"persons.list.client.omde",
superAdminOnly: true
},
{
// legacy navMenuItems.js:90 spelled this with a capital S while the route
@@ -133,7 +137,8 @@ export const adminMenu: NavMenuNode[] = [
i18n: 'لیست مغازه داران حضوری',
// Same fix as banker-list above — the backing endpoint
// (admin/customers-omde/shopkeeper/list) requires this rid.
permissionId:"persons.list.client.omde"
permissionId:"persons.list.client.omde",
superAdminOnly: true
},
]
},
@@ -145,6 +150,7 @@ export const adminMenu: NavMenuNode[] = [
name: 'branch and workshop',
icon: 'GridIcon',
i18n: 'شعب و کارگاه',
superAdminOnly: true,
submenu: [
{
url: null,
@@ -227,14 +233,16 @@ export const adminMenu: NavMenuNode[] = [
name: 'orderOmdeList',
slug: 'order-omde-list',
i18n: 'ثبت سفارش تک',
permissionId:"order.create"
permissionId:"order.create",
superAdminOnly: true
},
{
url: '/order/list',
name: 'orderList',
slug: 'order-list',
i18n: 'لیست سفارش های تک',
permissionId:"order.list"
permissionId:"order.list",
superAdminOnly: true
},
{
url: '/order/make',
@@ -461,7 +469,8 @@ export const adminMenu: NavMenuNode[] = [
name: 'wallet Management',
slug: 'manage-const',
i18n: 'مدیریت کیف پول',
permissionId:"config.wallet"
permissionId:"config.wallet",
superAdminOnly: true
},
// D27 — Tahesab / Zarrin integration.
{
+18 -2
View File
@@ -97,6 +97,7 @@ export const adminRoute: RouteRecordRaw[] = [
pageTitle: 'مدیریت کیف پول',
rule: 'editor',
role: 'config.wallet',
superAdminOnly: true,
parent: 'manage',
no_scroll: true
}
@@ -283,6 +284,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
rule: 'editor',
role: 'order.list',
superAdminOnly: true,
parent: 'order',
no_scroll: true
}
@@ -313,6 +315,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
rule: 'editor',
role: 'order.create',
superAdminOnly: true,
parent: 'order',
no_scroll: true
}
@@ -363,6 +366,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
rule: 'editor',
role: 'offers.create.tak',
superAdminOnly: true,
//parent: 'order',
no_scroll: true
}
@@ -373,6 +377,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
rule: 'editor',
role: 'offers.list.tak.edit',
superAdminOnly: true,
//parent: 'order',
no_scroll: true
}
@@ -566,6 +571,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
rule: 'editor',
role: 'persons.list.client.single.edit',
superAdminOnly: true,
parent: 'product',
no_scroll: true
}
@@ -577,6 +583,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
rule: 'editor',
role: 'persons.list.client.single.view',
superAdminOnly: true,
parent: 'product',
no_scroll: true
}
@@ -638,7 +645,8 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'لیست بنکداران',
rule: 'editor',
role: 'persons.list.client.omde'
role: 'persons.list.client.omde',
superAdminOnly: true
}
},
{
@@ -658,7 +666,8 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'لیست مغازه داران حضوری',
rule: 'editor',
role: 'persons.list.client.omde'
role: 'persons.list.client.omde',
superAdminOnly: true
}
},
{
@@ -736,6 +745,7 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'لیست مشتریان',
role: 'persons.list.client.single',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -965,6 +975,7 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'افزودن شعب',
role: 'branch.create',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -975,6 +986,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
pageTitle: 'ویرایش شعب',
role: 'branch.create',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -996,6 +1008,7 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'لیست شعب',
role: 'branch.list',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -1018,6 +1031,7 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'افزودن کارگاه',
role: 'workshop.create',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -1028,6 +1042,7 @@ export const adminRoute: RouteRecordRaw[] = [
meta: {
pageTitle: 'ویرایش کارگاه',
role: 'workshop.create',
superAdminOnly: true,
rule: 'editor'
}
},
@@ -1049,6 +1064,7 @@ export const adminRoute: RouteRecordRaw[] = [
],
pageTitle: 'لیست کارگاه ها',
role: 'workshop.list',
superAdminOnly: true,
rule: 'editor'
}
},
+4
View File
@@ -92,6 +92,7 @@ async function getPermission(): Promise<string[] | undefined> {
try {
const { data } = await api.get('users/show');
users.getRoles(data.permission);
users.setSuperAdmin(data.is_super_admin);
// Legacy stripped these before caching the user blob.
delete data.permission;
delete data.ut;
@@ -149,6 +150,9 @@ router.beforeEach(async (to) => {
const token = localStorage.getItem(tokenName);
if (token) {
const roles = await getPermission();
if (to.meta?.superAdminOnly && !useUsersStore().isSuperAdmin) {
return { name: 'page-not-authorized' };
}
if (roles && to.meta && to.meta.role && !roles.includes(to.meta.role as string)) {
if (to.name === 'dashboard-ecommerce' || to.name === 'dashboard-analytics') {
return { name: 'my-data' };
+1
View File
@@ -59,6 +59,7 @@ export const useAuthStore = defineStore('auth', {
const users = useUsersStore();
users.getRoles((data.roles ?? []).map((x: { rid: string }) => x.rid));
users.setSuperAdmin(data.is_super_admin);
// Legacy stripped these before persisting the user blob. Kept: the panel
// reads permissions from the store, never from the cached user object.
+7
View File
@@ -67,6 +67,9 @@ export const useUsersStore = defineStore('users', {
// undefined = "not fetched yet" (distinct from [] = "fetched, has none").
// The router guard relies on that distinction, as legacy's did.
roles: undefined as string[] | undefined,
// The super-admin-only features (see utils/superAdmin.ts) show for this
// account alone, whatever rids other admins hold. Set by the server.
isSuperAdmin: false,
}),
getters: {
/**
@@ -107,8 +110,12 @@ export const useUsersStore = defineStore('users', {
getRoles(roles: string[]) {
this.roles = checkParent(roles);
},
setSuperAdmin(value: unknown) {
this.isSuperAdmin = value === true;
},
deleteRoles() {
this.roles = undefined;
this.isSuperAdmin = false;
},
},
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Rids of the features only the super admin (09128081392) may use: retail
* customers and orders, branches and workshops, retail promotions and wallet
* management. The permission screen hides them from everyone else. Bankers,
* walk-in shopkeepers and trays ride on rids other screens share, so they are
* gated by `users.isSuperAdmin` in place instead.
*
* Keep in step with SUPER_ADMIN_RIDS in apps/api/src/modules/auth/super-admin.ts.
*/
export const SUPER_ADMIN_RIDS: ReadonlySet<string> = new Set([
'persons.create.single',
'persons.list.client.single',
'persons.list.client.single.view',
'persons.list.client.single.edit',
'branch',
'branch.create',
'branch.list',
'workshop',
'workshop.create',
'workshop.list',
'order.create',
'order.list',
'order.list.view',
'order.list.sts',
'order.list.sts.view',
'order.list.sts.remove',
'order.list.sts.submit',
'order.list.view.edit',
'order.list.pdf',
'offers.create.tak',
'offers.list.tak.edit',
'offers.list.tak.remove',
'config.wallet',
]);
File diff suppressed because it is too large Load Diff
@@ -11,36 +11,185 @@
Permission gates are legacy's, including the third tab's: legacy commented
out its `persons.create.customer.tak` check (AddCustomer.vue:142), so the
retail tab shows for anyone who can open the page.
retail tab shows for anyone who can open the page. The tabs are drawn as
selectable cards; which form shows is unchanged.
-->
<div id="page-user-view">
<div id="user-data">
<a-card class="mb-base page-card">
<a-tabs v-if="hasPermission('persons.create')">
<a-tab-pane v-if="hasPermission('persons.create.client')" key="omde" tab="مشتری عمده">
<BulkCustomer />
</a-tab-pane>
<div id="page-user-view" class="pf-page">
<header class="pf-head">
<a-button class="pf-head__back" shape="circle" @click="handleBack">
<template #icon><ArrowRightOutlined /></template>
</a-button>
<div class="pf-head__text">
<h1>افزودن شخص</h1>
<p>نوع شخص را انتخاب و اطلاعات او را تکمیل کنید.</p>
</div>
</header>
<a-tab-pane v-if="hasPermission('persons.create.seller')" key="reagent" tab="کارشناس فروش">
<ReagentCustomer />
</a-tab-pane>
<template v-if="hasPermission('persons.create') && kinds.length">
<div class="kind-picker" role="tablist">
<button
v-for="k in kinds"
:key="k.key"
type="button"
role="tab"
class="kind-card"
:class="{ 'is-on': activeTab === k.key }"
:aria-selected="activeTab === k.key"
@click="activeTab = k.key"
>
<span class="kind-card__icon"><component :is="k.icon" /></span>
<span class="kind-card__text">
<b>{{ k.title }}</b>
<small>{{ k.hint }}</small>
</span>
<CheckCircleFilled v-if="activeTab === k.key" class="kind-card__check" />
</button>
</div>
<a-tab-pane key="tak" tab="مشتری تک">
<SingleCustomer />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
<KeepAlive>
<component :is="FORMS[activeTab]" v-if="kinds.some((k) => k.key === activeTab)" :key="activeTab" />
</KeepAlive>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, type Component } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
ShopOutlined,
TeamOutlined,
UserOutlined,
ArrowRightOutlined,
CheckCircleFilled,
IdcardOutlined,
} from '@ant-design/icons-vue';
import BulkCustomer from './bulkCustomer.vue';
import ReagentCustomer from './ReagentCustomer.vue';
import SingleCustomer from './singleCustomer.vue';
import { useUsersStore } from '../../../stores/users';
const route = useRoute();
const router = useRouter();
const users = useUsersStore();
const activeTab = ref((route.query.tab as string) || 'omde');
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
// Card picker (design): the same three forms and the same gates as the tabs it
// replaces — عمده: persons.create.client, کارشناس: persons.create.seller,
// تک: super admin only (D30).
interface Kind {
key: 'omde' | 'reagent' | 'tak';
title: string;
hint: string;
icon: Component;
}
const kinds = computed<Kind[]>(() => {
const out: Kind[] = [];
if (hasPermission('persons.create.client')) {
out.push({ key: 'omde', title: 'مشتری عمده', hint: 'مغازه‌دار، بنکدار و خریدار عمده', icon: ShopOutlined });
}
if (hasPermission('persons.create.seller')) {
out.push({ key: 'reagent', title: 'کارشناس فروش', hint: 'کارشناس با کمیسیون و سقف اعتبار', icon: IdcardOutlined });
}
if (users.isSuperAdmin) {
out.push({ key: 'tak', title: 'مشتری تک', hint: 'مشتری خرده‌فروشی حضوری', icon: UserOutlined });
}
return out;
});
const FORMS: Record<string, Component> = { omde: BulkCustomer, reagent: ReagentCustomer, tak: SingleCustomer };
function handleBack() {
if (window.history.length > 1) {
router.back();
} else {
router.push('/customer/omde-list');
}
}
</script>
<style scoped>
b {
font-weight: 500;
}
.kind-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.kind-card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border: 1.5px solid #eef0f5;
border-radius: 16px;
background: #fff;
text-align: start;
cursor: pointer;
font: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.kind-card:hover {
border-color: #c9d0dd;
}
.kind-card.is-on {
border-color: #1c2d56;
background: rgba(28, 45, 86, 0.03);
box-shadow: 0 0 0 3px rgba(28, 45, 86, 0.08);
}
.kind-card__icon {
width: 42px;
height: 42px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 12px;
font-size: 16px;
color: #8a94a6;
background: #f2f4f7;
transition: color 0.2s ease, background 0.2s ease;
}
.kind-card.is-on .kind-card__icon {
color: #fff;
background: #1c2d56;
}
.kind-card__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.kind-card__text b {
font-size: 14px;
color: #101828;
}
.kind-card__text small {
font-size: 12px;
color: #8a94a6;
}
.kind-card__check {
margin-inline-start: auto;
font-size: 16px;
color: #1c2d56;
}
@media (max-width: 575px) {
.kind-picker {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.kind-card {
padding: 10px 12px;
}
.kind-card__icon {
width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>
@@ -18,93 +18,148 @@
operator can still type in directly.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-4">
<a-form-item label="عکس پروفایل" class="avatar-cell">
<ImageUploadInput
v-model="form.image_url"
purpose="reagent.avatar"
placeholder="عکس"
@ok="onAvatar"
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
/>
</a-form-item>
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور" :validate-status="err.password ? 'error' : ''" :help="err.password">
<a-input-password v-model:value="form.password" />
</a-form-item>
</div>
</a-card>
<a-form layout="vertical" class="pf-form" @submit.prevent="handleSubmit">
<div class="pf-layout">
<!-- ══════════ main column ══════════ -->
<div class="pf-main">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><UserOutlined /></span>
<div>
<h3>اطلاعات پایه</h3>
<p>مشخصات هویتی و اطلاعات ورود کارشناس</p>
</div>
</div>
<div class="pf-grid-2">
<a-form-item label="نام " required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<!--
D17 — both optional in the reference (required: false,
ReagentCustomer.vue:31,95) with no uniqueness and no format rule,
so none is invented here.
-->
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" class="ltr-input" inputmode="numeric" />
</a-form-item>
<a-form-item label="تلفن">
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
required
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
class="mb-0"
>
<a-input v-model:value="form.mobile" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
</a-form-item>
<a-form-item
label="رمز عبور"
:required="!id"
:validate-status="err.password ? 'error' : ''"
:help="err.password"
class="mb-0"
>
<a-input-password v-model:value="form.password" />
</a-form-item>
</div>
</section>
<a-card title="اطلاعات تجاری" class="mb-base">
<div class="grid-4">
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="درصد کمیسیون">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</a-form-item>
<!--
D17 — the sales-expert fields the reference collected here.
«شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228).
-->
<a-form-item label="شهر">
<CityTreeSelect
:value="form.city_id"
:cities="cities"
placeholder="شهر را انتخاب نمایید"
@change="onCityChange"
/>
</a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)">
<a-input-number v-model:value="form.gram_level" class="w-full" />
</a-form-item>
</div>
</a-card>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><ShopOutlined /></span>
<div>
<h3>اطلاعات تجاری</h3>
<p>کد و مشخصات فروشگاه</p>
</div>
</div>
<div class="pf-grid-3">
<a-form-item label="کد مشتری" class="mb-0">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="نام مغازه" class="mb-0">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ" class="mb-0">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
</div>
</section>
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-4">
<!--
D17 — both optional in the reference (required: false,
ReagentCustomer.vue:31,95) with no uniqueness and no format rule,
so none is invented here.
-->
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item label="تلفن">
<a-input v-model:value="form.phone" />
</a-form-item>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><EnvironmentOutlined /></span>
<div>
<h3>آدرس</h3>
<p>انتخاب شهر، درصد کمیسیون را بر اساس همان شهر پر می‌کند</p>
</div>
</div>
<!-- D17 — «شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228). -->
<a-form-item label="شهر">
<CityTreeSelect
:value="form.city_id"
:cities="cities"
placeholder="شهر را انتخاب نمایید"
@change="onCityChange"
/>
</a-form-item>
<a-form-item label="آدرس" class="mb-0">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
</section>
</div>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</a-card>
<a-button type="primary" class="form-submit" @click="handleSubmit">ثبت اطلاعات</a-button>
<!-- ══════════ side column ══════════ -->
<aside class="pf-side">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><CameraOutlined /></span>
<div>
<h3>عکس پروفایل</h3>
<p>تصویر نمایش داده‌شده برای کارشناس</p>
</div>
</div>
<div class="avatar-slot">
<ImageUploadInput
v-model="form.image_url"
purpose="reagent.avatar"
placeholder="عکس"
@ok="onAvatar"
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
/>
</div>
</section>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><PercentageOutlined /></span>
<div>
<h3>کمیسیون و اعتبار</h3>
<p>درصد کمیسیون و سقف اعتبار</p>
</div>
</div>
<a-form-item label="درصد کمیسیون">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" addon-after="٪" />
</a-form-item>
<a-form-item label="سقف اعتبار" class="mb-0">
<a-input-number v-model:value="form.gram_level" class="w-full" addon-after="گرم" />
</a-form-item>
</section>
</aside>
</div>
<div class="pf-actions">
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستاره‌دار الزامی هستند.</span>
<a-button @click="router.go(-1)">انصراف</a-button>
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmit">
<template #icon><CheckOutlined /></template>
ثبت اطلاعات
</a-button>
</div>
</a-form>
</a-spin>
</template>
@@ -113,6 +168,17 @@
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
TeamOutlined,
ShopOutlined,
EnvironmentOutlined,
CheckOutlined,
ArrowRightOutlined,
CameraOutlined,
InfoCircleOutlined,
PercentageOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import api from '../../../api/client';
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
@@ -133,25 +199,18 @@ const form = reactive<Record<string, any>>({
pasazh_name: '',
address: '',
percent: undefined,
// D17 — the sales-expert fields the reference collected on this form and the
// port had dropped. They live on SalesExpertProfile now.
ncode: '',
phone: '',
city_id: undefined,
gram_level: undefined,
// legacy kept the FK in `image_id`; `image_url` only drives the thumbnail.
image_id: '',
image_url: null,
});
/** Cities for the «شهر» select and the commission autofill. */
const cities = ref<Array<{ id: string; title: string; parent?: string | null; percent?: number | null }>>([]);
const err = reactive<Record<string, string>>({});
/** Snapshot of the server's record, captured after the edit form is populated. */
const initial = reactive<Record<string, any>>({});
/** The subset of `form` that differs from `initial`, minus `omit` keys. */
function diffFromInitial(omit: string[] = []): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(form)) {
@@ -161,23 +220,10 @@ function diffFromInitial(omit: string[] = []): Record<string, any> {
return out;
}
/** Legacy `uploadIndexImage` — MyUpload emitted the whole response object. */
function onAvatar(file: Record<string, unknown>) {
form.image_id = (file.id as string) ?? '';
}
/**
* Legacy `getPercent(value)` — henza_admin ReagentCustomer.vue:215-228.
*
* Selecting a city fills the commission percentage:
* 1. use `percent` from the already-loaded city row, if it is set;
* 2. otherwise GET cities/get-percent-aux/{id} and use that;
* 3. write the result into the form.
*
* Ported verbatim, including the falsy test on step 1 — legacy used
* `if (!percent)`, so a city percent of 0 also falls through to the aux
* lookup. Replicated rather than "fixed" (D4).
*/
async function onCityChange(value: string | undefined) {
form.city_id = value;
if (!value) return;
@@ -185,9 +231,6 @@ async function onCityChange(value: string | undefined) {
let percent: number | null | undefined = cities.value.find((c) => c.id === value)?.percent;
if (!percent) {
try {
// The endpoint returns a BARE scalar, as the reference does
// (CitiesController::getPercentAux -> _getP, and the target's
// admin-misc.service.ts:125 returns `c.percentAux` directly).
const { data } = await api.get(`cities/get-percent-aux/${value}`);
percent = data as number | null;
} catch (e) {
@@ -197,6 +240,7 @@ async function onCityChange(value: string | undefined) {
}
form.percent = percent === null || percent === undefined ? undefined : Number(percent);
}
const REQUIRED = 'لطفا مقدار را وارد کنید';
async function handleSubmit() {
@@ -209,16 +253,10 @@ async function handleSubmit() {
try {
loading.value = true;
// `image_url` is presentation only — the server stores the FK (`image_id`).
const { image_url: _unusedAvatarUrl, ...allFields } = form;
// On EDIT send only touched fields, and never re-send a blank password
// (blank means "leave unchanged" on the admin-users endpoint).
const fields = id ? diffFromInitial(['image_url', 'password']) : allFields;
if (id && form.password) fields.password = form.password;
const payload: Record<string, any> = { ...fields, role: 'SALES_EXPERT' };
// D0 — fname/lname are real columns and already travel in `fields`. `name`
// goes only when a name part actually changed; sending it unconditionally
// would re-submit the whole name on every save and defeat the diff-send.
if (!id || 'fname' in fields || 'lname' in fields) {
payload.name = `${form.fname} ${form.lname}`.trim();
}
@@ -238,7 +276,6 @@ async function handleSubmit() {
}
onMounted(async () => {
// D17 — the «شهر» select and its commission autofill need the city list.
try {
const { data } = await api.post('cities/list', { page: 1, limit: 500 });
cities.value = (data.rows ?? []).map((c: any) => ({
@@ -260,8 +297,6 @@ onMounted(async () => {
mobile: data.mobile ?? '',
address: data.address ?? '',
percent: data.percent === null ? undefined : Number(data.percent),
// D17 — from SalesExpertProfile. Numbers are coerced so diffFromInitial
// compares like with like and does not re-send an unchanged field.
ncode: data.ncode ?? '',
phone: data.phone ?? '',
city_id: data.city_id ?? undefined,
@@ -269,14 +304,12 @@ onMounted(async () => {
data.gram_level === null || data.gram_level === undefined
? undefined
: Number(data.gram_level),
// guarded, unlike legacy — an agent with no avatar must not throw
image_id: data.image?.id ?? '',
image_url: data.image?.image ?? null,
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
});
// Baseline for the dirty-diff in handleSubmit.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
} catch (e) {
console.log(e);
@@ -286,19 +319,9 @@ onMounted(async () => {
</script>
<style scoped>
/* .grid-4 now comes from the global utilities.less (dedup) */
/* Legacy put the avatar in the first grid cell spanning two rows
(md:row-span-2), with the uploader centred inside it. */
.avatar-cell {
grid-row: span 2;
.avatar-slot {
display: flex;
justify-content: center;
}
/* single-column grid on phones: a 2-row span would only leave a gap */
@media (max-width: 575px) {
.avatar-cell {
grid-row: auto;
}
padding: 4px 0;
}
</style>
@@ -136,118 +136,163 @@
</BaseFormModal>
<!-- route-based fallback for /customer/add and /bulkCustomer/edit/:id -->
<a-form v-else layout="vertical" :model="form" @submit.prevent="handleSubmitOmde">
<a-form v-else layout="vertical" :model="form" class="pf-form" @submit.prevent="handleSubmitOmde">
<a-spin :spinning="loading">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-4">
<a-form-item label="نام" :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="تلفن ثابت">
<a-input v-model:value="form.phone" />
</a-form-item>
</div>
</a-card>
<div class="pf-layout">
<!-- ══════════ main column ══════════ -->
<div class="pf-main">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><UserOutlined /></span>
<div>
<h3>اطلاعات پایه</h3>
<p>مشخصات هویتی و اطلاعات ورود مشتری</p>
</div>
</div>
<div class="pf-grid-2">
<a-form-item label="نام" required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" class="ltr-input" inputmode="numeric" />
</a-form-item>
<DateInput
:model-value="form.birth_date || undefined"
label="تاریخ تولد"
@update:model-value="(v) => (form.birth_date = v ?? '')"
/>
<a-form-item
label="شماره تلفن همراه"
required
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="تلفن ثابت" class="mb-0">
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" />
</a-form-item>
</div>
</section>
<a-card title="اطلاعات تجاری" class="mb-base">
<div class="grid-4">
<a-form-item label="نوع کاربری">
<a-select
v-model:value="form.type"
show-search
:filter-option="filterUserType"
style="width: 100%; direction: rtl !important"
>
<a-select-option v-for="item in userTypes" :key="item.id" :value="item.id">
{{ item.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<DateInput
:model-value="form.birth_date || undefined"
label="تاریخ تولد"
@update:model-value="(v) => (form.birth_date = v ?? '')"
/>
<a-form-item label="کارشناس فروش">
<a-select v-model:value="form.reagent_id" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id">
{{ r.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
<a-input-number v-model:value="form.percent" class="w-full" />
</a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)">
<a-input-number v-model:value="form.gram_level" class="w-full" />
</a-form-item>
</div>
</a-card>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><ShopOutlined /></span>
<div>
<h3>اطلاعات تجاری</h3>
<p>نوع کاربری و مشخصات فروشگاه</p>
</div>
</div>
<div class="pf-grid-2">
<a-form-item label="نوع کاربری">
<a-select
v-model:value="form.type"
show-search
placeholder="انتخاب کنید"
:filter-option="filterUserType"
style="width: 100%; direction: rtl !important"
>
<a-select-option v-for="item in userTypes" :key="item.id" :value="item.id">
{{ item.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="نام مغازه" class="mb-0">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ" class="mb-0">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
</div>
</section>
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-2">
<a-form-item label="شهر">
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><EnvironmentOutlined /></span>
<div>
<h3>آدرس</h3>
<p>انتخاب شهر، درصد را بر اساس همان شهر پر می‌کند</p>
</div>
</div>
<a-form-item label="شهر">
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
</a-form-item>
<a-form-item label="آدرس" class="mb-0">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
</section>
</div>
</a-card>
<a-card title="مدارک" class="mb-base">
<div class="grid-3">
<a-form-item label="عکس کارت ملی">
<ImageUploadInput
v-model="form.national_id_image"
purpose="customer.nationalId"
placeholder="عکس"
/>
</a-form-item>
<a-form-item label="عکس جواز کسب">
<ImageUploadInput
v-model="form.business_license"
purpose="customer.businessLicense"
placeholder="عکس"
/>
</a-form-item>
<a-form-item label="عکس سردر مغازه">
<ImageUploadInput
v-model="form.shop_image"
purpose="customer.shop"
placeholder="عکس"
/>
</a-form-item>
</div>
</a-card>
<!-- ══════════ side column ══════════ -->
<aside class="pf-side">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><PercentageOutlined /></span>
<div>
<h3>کارشناس و اعتبار</h3>
<p>کارشناس فروش، درصد و سقف اعتبار</p>
</div>
</div>
<a-form-item label="کارشناس فروش">
<a-select
v-model:value="form.reagent_id"
allow-clear
show-search
option-filter-prop="label"
placeholder="انتخاب کنید"
>
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id" :label="r.name">
{{ r.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="درصد" required :validate-status="err.percent ? 'error' : ''" :help="err.percent">
<a-input-number v-model:value="form.percent" class="w-full" addon-after="٪" />
</a-form-item>
<a-form-item label="سقف اعتبار" class="mb-0">
<a-input-number v-model:value="form.gram_level" class="w-full" addon-after="گرم" />
</a-form-item>
</section>
<div>
<a-button type="primary" class="mr-4 form-submit" @click="handleSubmitOmde">ثبت اطلاعات</a-button>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><SafetyCertificateOutlined /></span>
<div>
<h3>مدارک</h3>
<p>تصویر مدارک هویتی و فروشگاه</p>
</div>
</div>
<div class="pf-uploads">
<a-form-item label="کارت ملی" class="mb-0">
<ImageUploadInput v-model="form.national_id_image" purpose="customer.nationalId" placeholder="عکس" />
</a-form-item>
<a-form-item label="جواز کسب" class="mb-0">
<ImageUploadInput v-model="form.business_license" purpose="customer.businessLicense" placeholder="عکس" />
</a-form-item>
<a-form-item label="سردر مغازه" class="mb-0">
<ImageUploadInput v-model="form.shop_image" purpose="customer.shop" placeholder="عکس" />
</a-form-item>
</div>
</section>
</aside>
</div>
<div class="pf-actions">
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستاره‌دار الزامی هستند.</span>
<a-button @click="router.go(-1)">انصراف</a-button>
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmitOmde">
<template #icon><CheckOutlined /></template>
ثبت اطلاعات
</a-button>
</div>
</a-spin>
</a-form>
@@ -257,6 +302,17 @@
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
UserOutlined,
ShopOutlined,
EnvironmentOutlined,
FileImageOutlined,
CheckOutlined,
ArrowRightOutlined,
InfoCircleOutlined,
PercentageOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons-vue';
import BaseFormModal from '../../../components/common/BaseFormModal.vue';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
import api from '../../../api/client';
@@ -266,18 +322,6 @@ import DateInput from '../../../components/input/DateInput.vue';
const props = defineProps<{
open?: boolean;
id?: string;
/**
* Set by callers that render this inside a modal (OmdeCustomerList); the
* route-based pages (/customer/add, /bulkCustomer/edit/:id) leave it off
* and get the full-page form instead.
*
* This exists as its own flag because `open` alone could not carry the
* distinction: Vue applies Boolean casting to a prop declared Boolean, so
* an absent `open` arrives as `false`, never `undefined`. Deriving the mode
* from `props.open !== undefined` was therefore always true, and the two
* route pages rendered a CLOSED modal — a blank page showing only the tab
* strip.
*/
asModal?: boolean;
}>();
const emit = defineEmits<{
@@ -308,34 +352,15 @@ const form = reactive<Record<string, any>>({
mcode: '',
birth_date: '',
address: '',
// legacy defaulted to no selection; 2 (مغازه دار اپلیکیشن) is what the
// backend falls back to when the field is absent.
type: undefined,
reagent_id: undefined,
// D9 — the reference defaults a new customer's making-charge offset to 3
// (AdminController.php:338) and makes the field required (:354).
percent: 3,
gram_level: undefined,
// The three document photos legacy collected via MyUpload. Stored as plain
// URLs (schema: nationalIdImageUrl / businessLicenseUrl / shopImageUrl),
// unlike the sales-agent avatar which is an FK.
national_id_image: '',
business_license: '',
shop_image: '',
});
/**
* Legacy bulkCustomer.vue:239-244 offered a 4th option here ("کارشناس فروش",
* id 1) because legacy stored a sales agent as a Customer row too. The
* redesigned system models a sales agent as an AdminUser (role SALES_EXPERT)
* instead — created from "افزودن مدیر" or the "کارشناس فروش" tab next to this
* one (ReagentCustomer.vue), never from this form. Selecting it here used to
* create a Customer row that no screen correctly read back (see the bug
* report that led to this fix), so it is removed; the server also now
* rejects type=1 on this endpoint as defense-in-depth. The server's canonical
* map (Customer::$TYPES) also has 5 => سرپرست کارگاه, which this form never
* offered; that omission is preserved.
*/
const userTypes = [
{ id: 2, title: 'مغازه دار اپلیکیشن' },
{ id: 3, title: 'مغازه دار حضوری' },
@@ -347,14 +372,8 @@ function filterUserType(input: string, option: any) {
}
const err = reactive<Record<string, string>>({});
/**
* Snapshot of the record as the server returned it, captured after the edit
* form is populated. Empty on create, where every field is intentionally new.
*/
const initial = reactive<Record<string, any>>({});
/** The subset of `form` that differs from `initial`. */
function diffFromInitial(): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(form)) {
@@ -363,11 +382,6 @@ function diffFromInitial(): Record<string, any> {
return out;
}
/**
* Legacy getPercent (henza_admin bulkCustomer.vue:260-272): picking a city fills
* «درصد» with the city's own percent, else its commission percent — which, as
* legacy's _getP, a city without one takes from its parent.
*/
async function onCityChange(value: string | undefined) {
if (!value) return;
let percent: unknown = cities.value.find((c) => c.id === value)?.percent;
@@ -385,13 +399,9 @@ async function onCityChange(value: string | undefined) {
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
// legacy required: fname, lname, mobile — message 'لطفا مقدار را وارد کنید'
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید';
// D9 — `percent` is required on create in the reference
// (AdminController.php:354). 0 is a legitimate value, so test for
// "not supplied", not for falsiness.
if (form.percent === undefined || form.percent === null || (form.percent as unknown) === '') {
err.percent = 'لطفا مقدار را وارد کنید';
}
@@ -402,21 +412,12 @@ async function handleSubmitOmde() {
if (!validate()) return;
try {
loading.value = true;
// Legacy composed fname+lname separately; the schema has one fullName, so
// they are joined here and split back on read.
// On EDIT send only the fields the operator actually touched. Spreading the
// whole form means any field the edit path failed to repopulate is sent as
// '' and overwrites good data — see the note on Object.assign below.
const changed = id ? diffFromInitial() : { ...form };
const payload: Record<string, unknown> = {
...changed,
id: id ?? null,
national_code: form.ncode,
};
// D0 — fname/lname are real columns and travel in `changed` on their own.
// `name` is only sent when a name part actually changed; sending it
// unconditionally would re-submit the whole name on every save and defeat
// the diff-send the edit path relies on.
if (!id || 'fname' in changed || 'lname' in changed) {
payload.name = `${form.fname} ${form.lname}`.trim();
}
@@ -433,7 +434,6 @@ async function handleSubmitOmde() {
router.go(-1);
}
} catch (e: any) {
// legacy surfaced the server's mobile-uniqueness error specifically
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
@@ -450,7 +450,7 @@ onMounted(async () => {
}
try {
const { data } = await api.post('cities/list');
cities.value = data.rows;
cities.value = data.rows ?? [];
} catch (e) {
console.log(e);
}
@@ -464,30 +464,19 @@ onMounted(async () => {
mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined,
address: data.address ?? '',
// D9 — the API sends a Decimal, which serialises as a string. Coerce
// it, or a-input-number shows it fine but diffFromInitial() compares
// "3" with 3 and re-sends an unchanged field on every save.
percent: data.percent === null || data.percent === undefined ? undefined : Number(data.percent),
gram_level: data.gram_level ?? undefined,
reagent_id: data.reagent?.id ?? undefined,
// These five were previously OMITTED here. Because handleSubmitOmde
// spread the whole form and the server applies any key that is not
// `undefined`, saving an existing customer overwrote each of them with
// the empty string the reactive initializer left behind — silent data
// loss on every wholesale-customer edit.
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
birth_date: data.birth_date ?? '',
phone: data.phone ?? '',
type: data.type ?? undefined,
// Same reason as the five above: omitted here, they would be sent back
// as '' and wipe the stored photos on every edit.
national_id_image: data.national_id_image ?? '',
business_license: data.business_license ?? '',
shop_image: data.shop_image ?? '',
});
// Baseline for the dirty-diff in handleSubmitOmde.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
} catch (e) {
console.log(e);
@@ -496,4 +485,4 @@ onMounted(async () => {
});
</script>
<!-- .grid-2/.grid-4 now come from the global utilities.less (dedup) -->
@@ -8,41 +8,48 @@
the customer endpoints.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="id ? 'ویرایش مشتری' : 'افزودن مشتری'" class="mb-base">
<div class="grid-3">
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.phone ? 'error' : ''"
:help="err.phone"
>
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="شهر">
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
</a-form-item>
<DateInput
:model-value="form.birth_day || undefined"
label="تاریخ تولد"
@update:model-value="(v) => (form.birth_day = v ?? '')"
/>
<a-form layout="vertical" class="pf-form" @submit.prevent="handleSubmit">
<div class="pf-layout pf-layout--single">
<div class="pf-main">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><UserOutlined /></span>
<div>
<h3>{{ id ? 'ویرایش مشتری' : 'افزودن مشتری' }}</h3>
<p>مشخصات مشتری تک</p>
</div>
</div>
<div class="pf-grid-2">
<a-form-item label="نام " required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="شماره تلفن همراه" required :validate-status="err.phone ? 'error' : ''" :help="err.phone">
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
</a-form-item>
<DateInput
:model-value="form.birth_day || undefined"
label="تاریخ تولد"
@update:model-value="(v) => (form.birth_day = v ?? '')"
/>
<a-form-item label="شهر" class="pf-span-all mb-0">
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
</a-form-item>
</div>
</section>
</div>
</div>
<a-button type="primary" class="form-submit" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
<div class="pf-actions">
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستاره‌دار الزامی هستند.</span>
<a-button @click="router.go(-1)">انصراف</a-button>
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmit">
<template #icon><CheckOutlined /></template>
ثبت اطلاعات
</a-button>
</div>
</a-form>
</a-spin>
</template>
@@ -51,6 +58,13 @@
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
UserOutlined,
EnvironmentOutlined,
CheckOutlined,
ArrowRightOutlined,
InfoCircleOutlined,
} from '@ant-design/icons-vue';
import api from '../../../api/client';
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
import DateInput from '../../../components/input/DateInput.vue';
@@ -83,9 +97,6 @@ async function handleSubmit() {
loading.value = true;
const payload = {
...form,
// D0 — fname and lname are real columns now, so they go as-is (they are
// already in `form`). `name` is still sent for the legacy readers that
// expect a whole name; the server derives it from the parts anyway.
name: `${form.fname} ${form.lname}`.trim(),
mobile: form.phone,
};
@@ -114,9 +125,6 @@ onMounted(async () => {
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
// D0 — the API returns the real parts now. The old split(' ') fallback
// is gone: it guessed at the boundary and put two-token first names in
// the wrong box.
Object.assign(form, {
fname: data.fname ?? '',
lname: data.lname ?? '',
@@ -131,4 +139,9 @@ onMounted(async () => {
});
</script>
<!-- .grid-3 now comes from the global utilities.less (dedup) -->
<style scoped>
/* the form sits under the full-width person-type cards; keep it the same width */
.pf-layout--single {
max-width: none;
}
</style>
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -116,8 +116,8 @@
</div>
</a-tab-pane>
<!-- سینی‌ها -->
<a-tab-pane key="collections" tab="سینی ها">
<!-- سینی‌ها — super admin only -->
<a-tab-pane v-if="users.isSuperAdmin" key="collections" tab="سینی ها">
<div class="row">
<a-input
v-model:value="newCollection"
@@ -341,7 +341,7 @@ async function removeCollection(id: string) {
onMounted(async () => {
try {
loading.value = true;
await Promise.all([loadBrands(), loadCollections()]);
await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
const [t, s] = await Promise.all([
api.get('const/__list__tags'),
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
@@ -54,8 +54,8 @@
type="info"
show-icon
class="mb-4"
message="در زرین سند موقت همان لحظه روی مانده‌ی مشتری اثر دارد"
description="برخلاف ته‌حساب. اگر سند در «ثبت سفارش» ثبت شود، مشتری در زرین پیش از تحویل بدهکار می‌شود؛ برای زرین «تحویل مسئول فروش» پیشنهاد می‌شود."
message="درایور زرین روی نصب واقعی تست نشده است"
description="طبق تصمیم مالک، پیش از استفاده‌ی واقعی یک دوره‌ی آزمایشی با اسناد موقت لازم است (علامت مانده و فیلد هزینه‌ی سنگ را بررسی کنید)."
/>
<a-alert
v-if="active && overview?.config?.provider && (form.features.balances || form.features.products)"
@@ -12,7 +12,12 @@
<template #add>
<div class="list-page-header-row">
<ListPageHeader rid="offers.create" add-label="افزودن تخفیف عمده" @add="openAdd('omde')" />
<ListPageHeader rid="offers.create.tak" add-label="افزودن تخفیف تک" @add="openAdd('tak')" />
<ListPageHeader
v-if="users.isSuperAdmin"
rid="offers.create.tak"
add-label="افزودن تخفیف تک"
@add="openAdd('tak')"
/>
</div>
</template>
@@ -98,17 +103,17 @@ function openAdd(variant: 'omde' | 'tak') {
modalOpen.value = true;
}
/** legacy: tak rows need the .tak.* rid, omde rows the plain one */
/** legacy: tak rows need the .tak.* rid, omde rows the plain one; tak rows are super admin only */
function canEdit(record: Record<string, any>) {
return (
(hasPermission('offers.list.tak.edit') && record.type_customer === 1) ||
(users.isSuperAdmin && hasPermission('offers.list.tak.edit') && record.type_customer === 1) ||
(hasPermission('offers.list.edit') && record.type_customer === 2)
);
}
function canRemove(record: Record<string, any>) {
return (
(hasPermission('offers.list.tak.remove') && record.type_customer === 1) ||
(users.isSuperAdmin && hasPermission('offers.list.tak.remove') && record.type_customer === 1) ||
(hasPermission('offers.list.remove') && record.type_customer === 2)
);
}
+41 -10
View File
@@ -74,6 +74,7 @@
import { computed, onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { treeData, type PermissionNode } from './permissionConst';
import { SUPER_ADMIN_RIDS } from '../../utils/superAdmin';
import { useUsersStore } from '../../stores/users';
import { useAuthStore } from '../../stores/auth';
import api from '../../api/client';
@@ -108,12 +109,39 @@ const visibleUserTypes = computed(() => {
return list;
});
/** legacy drops the whole "custom" branch for anyone outside level 104 */
const visibleTree = computed<PermissionNode[]>(() =>
auth.user?.ut === RESERVED_TYPE_ID
? treeData
: treeData.filter((x) => x.key !== 'custom'),
);
/** Drops every super-admin-only node, with its whole subtree. */
function withoutSuperAdminNodes(nodes: PermissionNode[]): PermissionNode[] {
return nodes
.filter((x) => !SUPER_ADMIN_RIDS.has(x.key))
.map((x) => (x.children ? { ...x, children: withoutSuperAdminNodes(x.children) } : x));
}
/**
* legacy drops the whole "custom" branch for anyone outside level 104; the
* super-admin-only rids are shown to the super admin alone.
*/
const visibleTree = computed<PermissionNode[]>(() => {
const tree =
auth.user?.ut === RESERVED_TYPE_ID ? treeData : treeData.filter((x) => x.key !== 'custom');
return users.isSuperAdmin ? tree : withoutSuperAdminNodes(tree);
});
/**
* Super-admin-only rids the selected level already holds. Not on the tree for
* anyone else, so they are sent back untouched on save instead of being
* revoked by an admin who cannot see them.
*/
const heldHiddenRids = ref<string[]>([]);
function setLoadedRids(loaded: string[]) {
if (users.isSuperAdmin) {
rid.value = loaded;
heldHiddenRids.value = [];
return;
}
rid.value = loaded.filter((x) => !SUPER_ADMIN_RIDS.has(x));
heldHiddenRids.value = loaded.filter((x) => SUPER_ADMIN_RIDS.has(x));
}
async function getData() {
try {
@@ -127,17 +155,17 @@ async function getData() {
/** legacy selectChange(): id 84 means "creating a new level", so start empty */
async function selectChange(id: string | undefined) {
if (!id) {
rid.value = [];
setLoadedRids([]);
return;
}
if (id === NEW_TYPE_ID) {
rid.value = [];
setLoadedRids([]);
return;
}
try {
const { data } = await api.get(`roles/${id}`);
// The server returns a flat array of rid strings.
rid.value = data;
setLoadedRids(data);
} catch (e) {
message.error('خطا در دریافت اطلاعات');
}
@@ -166,7 +194,10 @@ async function handleSubmit() {
targetUt = String(data.id);
}
await api.post('roles/create', { ut: targetUt, roles: rid.value });
await api.post('roles/create', {
ut: targetUt,
roles: [...new Set([...rid.value, ...heldHiddenRids.value])],
});
await getData();
if (newUT.value) {
@@ -1,7 +1,7 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminBranchesService } from './admin-branches.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Legacy endpoints (BranchController + WorkshopController):
@@ -12,6 +12,7 @@ import { RequirePermission } from '../auth/guards';
* GET user/list -> the branch-manager picker
*/
@Controller('admin/branches')
@SuperAdminOnly()
export class AdminBranchesController {
constructor(private readonly branches: AdminBranchesService) {}
@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
import { AdminCategoriesService } from './admin-categories.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Legacy endpoints (CategoryController + the collection screens):
@@ -37,6 +37,7 @@ export class AdminCategoriesController {
// ── collections ("سینی") ─────────────────────────────────────────
@Post('collections/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('collection.list')
listCollections(@Body() query: TableQueryDto) {
return this.categories.listCollections(query);
@@ -44,12 +45,14 @@ export class AdminCategoriesController {
@Post('collections')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('collection.create')
createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) {
return this.categories.createCollection(body);
}
@Put('collections/:id')
@SuperAdminOnly()
@RequirePermission('collection.list.edit')
updateCollection(
@Param('id') id: string,
@@ -59,6 +62,7 @@ export class AdminCategoriesController {
}
@Delete('collections/:id')
@SuperAdminOnly()
@RequirePermission('collection.list.edit')
removeCollection(@Param('id') id: string) {
return this.categories.removeCollection(id);
@@ -1,7 +1,7 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
@@ -31,6 +31,7 @@ export class AdminCustomersController {
@Post('banker/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('persons.list.client.omde')
listBanker(@Body() query: TableQueryDto) {
return this.list(query, 'banker');
@@ -38,6 +39,7 @@ export class AdminCustomersController {
@Post('shopkeeper/list')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('persons.list.client.omde')
listShopkeeper(@Body() query: TableQueryDto) {
return this.list(query, 'shopkeeper');
@@ -60,6 +62,7 @@ export class AdminCustomersController {
/** Legacy POST single-customer — the retail customer list. */
@Post('single/list')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('persons.list.client.single')
listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.customers.listSingle(query, admin.branchId ?? undefined);
@@ -68,6 +71,7 @@ export class AdminCustomersController {
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
@Post('single')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('persons.create')
createSingle(@Body() data: Record<string, unknown>) {
return this.customers.createSingle(data);
@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminDocumentsService } from './admin-documents.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
@@ -46,6 +46,7 @@ export class AdminDocumentsController {
// ── wallet settings (views/manage/wallet.vue) ────────────────────
@Get('wallet/config')
@SuperAdminOnly()
@RequirePermission('config.wallet')
getWalletConfig() {
return this.documents.getSetting('wallet.config');
@@ -53,12 +54,14 @@ export class AdminDocumentsController {
@Post('wallet/config')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('config.wallet')
setWalletConfig(@Body() body: Record<string, unknown>) {
return this.documents.setSetting('wallet.config', body);
}
@Get('wallet/birthday/config')
@SuperAdminOnly()
@RequirePermission('config.wallet')
getBirthdayConfig() {
return this.documents.getSetting('wallet.birthday');
@@ -66,6 +69,7 @@ export class AdminDocumentsController {
@Post('wallet/birthday/config')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('config.wallet')
setBirthdayConfig(@Body() body: Record<string, unknown>) {
return this.documents.setSetting('wallet.birthday', body);
@@ -2,7 +2,7 @@ import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
import { AdminOrdersService } from './admin-orders.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { UpdateOrderStatusDto } from './update-status.dto';
import { CurrentAdmin, RequirePermission } from '../auth/guards';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
/**
@@ -20,6 +20,7 @@ export class AdminOrdersController {
/** Legacy POST single-order. POST, not GET, because XTable posts its filters. */
@Post('list')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.list')
list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
/**
@@ -66,6 +67,7 @@ export class AdminOrdersController {
/** Legacy POST /order from the admin order builder (views/order/create.vue). */
@Post('create')
@HttpCode(200)
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.create')
createFromAdmin(
@Body()
@@ -82,13 +84,17 @@ export class AdminOrdersController {
/** Legacy GET order/type. */
@Get('type-options')
@SuperAdminOnly()
@RequirePermission('order.create')
typeOptions() {
return this.orders.typeOptions();
}
// The three routes below also serve the wholesale and sourcing lists, whose
// staff hold order.omde / order.tamin rather than the super-admin-only
// order.list.
@Get('status-options')
@RequirePermission('order.list')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
statusOptions() {
return this.orders.statusOptions();
}
@@ -96,13 +102,13 @@ export class AdminOrdersController {
/** Legacy POST single/order-sts/edit. */
@Post('status')
@HttpCode(200)
@RequirePermission('order.list')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
return this.orders.setStatus(dto, admin.id);
}
@Get(':id')
@RequirePermission('order.list')
@RequirePermission('order.list', 'order.omde', 'order.tamin')
findOne(@Param('id') id: string) {
return this.orders.findOne(id);
}
@@ -1,7 +1,9 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminProductsService } from './admin-products.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards';
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
import { isSuperAdmin } from '../auth/super-admin';
/**
* Legacy endpoints (ProductController):
@@ -51,6 +53,7 @@ export class AdminProductsController {
*/
@Post('variants/create')
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('order.create')
createVariant(@Body() body: { product_id: string; title: string; grams: number }) {
return this.products.createVariant(body);
@@ -78,6 +81,7 @@ export class AdminProductsController {
@Get('collections')
@SuperAdminOnly()
@RequirePermission('product.list')
collections() {
return this.products.collections();
@@ -100,7 +104,9 @@ export class AdminProductsController {
@Post('create')
@HttpCode(200)
@RequirePermission('product.create')
create(@Body() data: Record<string, unknown>) {
create(@Body() data: Record<string, unknown>, @CurrentAdmin() admin: AuthenticatedAdmin) {
// Trays are super-admin-only; anyone else's product is created without them.
if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids;
return this.products.create(data);
}
@@ -1,7 +1,7 @@
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { AdminBranchesService } from '../admin-branches/admin-branches.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
/**
* Workshops — legacy views/workshop/*, served by WorkshopController:
@@ -14,6 +14,9 @@ import { RequirePermission } from '../auth/guards';
* screen's workshop picker); the CRUD arrived with Batch 11. Both live here so
* there is one owner of /admin/workshops, and the logic sits in
* AdminBranchesService alongside the branch code it mirrors.
*
* Managing workshops is super-admin-only; the list stays open because the
* made-to-order screen picks a workshop from it.
*/
@Controller('admin/workshops')
export class AdminWorkshopsController {
@@ -27,24 +30,28 @@ export class AdminWorkshopsController {
@Post()
@HttpCode(200)
@SuperAdminOnly()
@RequirePermission('workshop.create')
create(@Body() data: Record<string, unknown>) {
return this.branches.createWorkshop(data);
}
@Get('active/:id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
toggleActive(@Param('id') id: string) {
return this.branches.toggleWorkshopActive(id);
}
@Get(':id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
findOne(@Param('id') id: string) {
return this.branches.findWorkshop(id);
}
@Put(':id')
@SuperAdminOnly()
@RequirePermission('workshop.list')
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
return this.branches.updateWorkshop(id, data);
+12 -4
View File
@@ -14,11 +14,12 @@ import { AdminSessionService } from '../sessions/admin-session.service';
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
import type { LoginDto } from './dto/login.dto';
import { compareLocalHash } from './password-hash';
import { SUPER_ADMIN_MOBILE, isSuperAdmin, withSuperAdminRids } from './super-admin';
export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash';
// TEMPORARY hardcoded super-admin login (username = password). Remove before going live.
const STATIC_ADMIN_MOBILE = '09128081392';
const STATIC_ADMIN_MOBILE = SUPER_ADMIN_MOBILE;
@Injectable()
export class AuthService {
@@ -99,8 +100,11 @@ export class AuthService {
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
is_super_admin: isSuperAdmin(user.mobile),
api_token: apiToken,
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
(rid) => ({ rid }),
),
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
};
}
@@ -222,8 +226,11 @@ export class AuthService {
role: user.role,
ut: user.userTypeId,
branch_id: user.branchId,
is_super_admin: isSuperAdmin(user.mobile),
api_token: apiToken,
roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })),
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
(rid) => ({ rid }),
),
};
}
@@ -345,7 +352,8 @@ export class AuthService {
ut: user.userTypeId,
branch_id: user.branchId,
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
permission: (user.userType?.permissions ?? []).map((p) => p.rid),
is_super_admin: isSuperAdmin(user.mobile),
permission: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)),
};
}
}
+85
View File
@@ -0,0 +1,85 @@
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard, RequirePermission, SuperAdminOnly } from './guards';
import type { AuthenticatedAdmin } from './jwt.strategy';
import { SUPER_ADMIN_MOBILE, withSuperAdminRids } from './super-admin';
class Routes {
@SuperAdminOnly()
@RequirePermission('config.wallet')
superOnly() {}
@SuperAdminOnly({ allowBranch: true })
@RequirePermission('order.list')
superOrBranch() {}
@RequirePermission('order.list', 'order.omde', 'order.tamin')
anyOf() {}
open() {}
}
function admin(over: Partial<AuthenticatedAdmin> = {}): AuthenticatedAdmin {
return {
id: 'a1',
fullName: 'x',
mobile: '09120000000',
role: 'SUPER_ADMIN',
userTypeId: 85,
branchId: null,
permissions: ['config.wallet', 'order.list', 'order.omde'],
...over,
};
}
function run(handler: keyof Routes, user: AuthenticatedAdmin) {
const ctx = {
getHandler: () => Routes.prototype[handler],
getClass: () => Routes,
switchToHttp: () => ({ getRequest: () => ({ user }) }),
} as unknown as ExecutionContext;
return new PermissionsGuard(new Reflector()).canActivate(ctx);
}
describe('PermissionsGuard — super-admin-only routes', () => {
it('admits the super admin', () => {
expect(run('superOnly', admin({ mobile: SUPER_ADMIN_MOBILE }))).toBe(true);
});
it('refuses any other admin, even one holding the rid and the SUPER_ADMIN role', () => {
expect(() => run('superOnly', admin())).toThrow(ForbiddenException);
});
it('refuses a branch admin unless the route allows branches', () => {
expect(() => run('superOnly', admin({ branchId: 'b1' }))).toThrow(ForbiddenException);
expect(run('superOrBranch', admin({ branchId: 'b1' }))).toBe(true);
expect(() => run('superOrBranch', admin())).toThrow(ForbiddenException);
});
it('still requires the rid from a branch admin', () => {
expect(() => run('superOrBranch', admin({ branchId: 'b1', permissions: [] }))).toThrow(
ForbiddenException,
);
});
it('leaves routes without the decorator alone', () => {
expect(run('open', admin({ permissions: [] }))).toBe(true);
});
});
describe('PermissionsGuard — several rids', () => {
it('admits a holder of any one of them', () => {
expect(run('anyOf', admin({ permissions: ['order.tamin'] }))).toBe(true);
});
it('refuses a holder of none', () => {
expect(() => run('anyOf', admin({ permissions: ['product.list'] }))).toThrow(ForbiddenException);
});
});
describe('withSuperAdminRids', () => {
it('adds the super-admin rids for the super admin only', () => {
expect(withSuperAdminRids(SUPER_ADMIN_MOBILE, [])).toContain('config.wallet');
expect(withSuperAdminRids('09120000000', ['product.list'])).toEqual(['product.list']);
});
});
+36 -10
View File
@@ -9,14 +9,31 @@ import {
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';
import type { AuthenticatedAdmin } from './jwt.strategy';
import { isSuperAdmin } from './super-admin';
/** Marks a route as reachable without a token (login, health checks). */
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
/** Requires the caller to hold this rid (ancestors count — see expandWithParents). */
/**
* Requires the caller to hold this rid (ancestors count — see expandWithParents).
* With several rids, holding any one of them is enough.
*/
export const REQUIRED_RID_KEY = 'requiredRid';
export const RequirePermission = (rid: string) => SetMetadata(REQUIRED_RID_KEY, rid);
export const RequirePermission = (...rids: [string, ...string[]]) =>
SetMetadata(REQUIRED_RID_KEY, rids);
/**
* Restricts a route (or a whole controller) to the super admin — see
* super-admin.ts. `allowBranch` also admits an admin bound to a branch, because
* the branch panel serves its staff from the same retail endpoints.
*/
export const SUPER_ADMIN_ONLY_KEY = 'superAdminOnly';
export interface SuperAdminOnlyOptions {
allowBranch?: boolean;
}
export const SuperAdminOnly = (options: SuperAdminOnlyOptions = {}) =>
SetMetadata(SUPER_ADMIN_ONLY_KEY, options);
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
export const CurrentAdmin = createParamDecorator(
@@ -48,23 +65,32 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
}
}
/** Enforces @RequirePermission(rid) against the expanded permission list. */
/** Enforces @SuperAdminOnly() and @RequirePermission(rid) against the expanded permission list. */
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRid = this.reflector.getAllAndOverride<string>(REQUIRED_RID_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRid) return true;
const targets = [context.getHandler(), context.getClass()];
const superAdminOnly = this.reflector.getAllAndOverride<SuperAdminOnlyOptions | undefined>(
SUPER_ADMIN_ONLY_KEY,
targets,
);
const requiredRids = this.reflector.getAllAndOverride<string[] | undefined>(REQUIRED_RID_KEY, targets);
if (!superAdminOnly && !requiredRids) return true;
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
if (!user) throw new ForbiddenException();
if (!user.permissions.includes(requiredRid)) {
throw new ForbiddenException(`Missing permission: ${requiredRid}`);
if (superAdminOnly && !isSuperAdmin(user.mobile)) {
if (!(superAdminOnly.allowBranch && user.branchId)) {
throw new ForbiddenException('این بخش فقط در دسترس مدیر ارشد سامانه است');
}
}
if (!requiredRids) return true;
if (!requiredRids.some((rid) => user.permissions.includes(rid))) {
throw new ForbiddenException(`Missing permission: ${requiredRids.join(' | ')}`);
}
return true;
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { AdminSessionService } from '../sessions/admin-session.service';
import { expandWithParents } from './acl.constants';
import { withSuperAdminRids } from './super-admin';
export interface JwtPayload {
sub: string;
@@ -89,7 +90,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
// Permissions are re-read on every request rather than baked into the token,
// so a permission change takes effect immediately instead of at next login.
const rids = user.userType?.permissions.map((p) => p.rid) ?? [];
const rids = withSuperAdminRids(user.mobile, user.userType?.permissions.map((p) => p.rid) ?? []);
return {
id: user.id,
+48
View File
@@ -0,0 +1,48 @@
/**
* The one account allowed to see the super-admin-only features (retail
* customers and orders, bankers, walk-in shopkeepers, branches and workshops,
* retail promotions, wallet management, trays). Matched on mobile, not on
* AdminRole.SUPER_ADMIN: that role can be assigned to any admin from the
* users form, this account cannot.
*/
export const SUPER_ADMIN_MOBILE = '09128081392';
/**
* Rids of those features. The admin panel no longer offers them on the
* permission screen (except to the super admin), so the super admin is granted
* them here whatever its user type holds.
*/
export const SUPER_ADMIN_RIDS = [
'persons.create.single',
'persons.list.client.single',
'persons.list.client.single.view',
'persons.list.client.single.edit',
'branch',
'branch.create',
'branch.list',
'workshop',
'workshop.create',
'workshop.list',
'order.create',
'order.list',
'order.list.view',
'order.list.sts',
'order.list.sts.view',
'order.list.sts.remove',
'order.list.sts.submit',
'order.list.view.edit',
'order.list.pdf',
'offers.create.tak',
'offers.list.tak.edit',
'offers.list.tak.remove',
'config.wallet',
] as const;
export function isSuperAdmin(mobile: string | null | undefined): boolean {
return mobile?.trim() === SUPER_ADMIN_MOBILE;
}
/** The raw rids an admin holds, plus SUPER_ADMIN_RIDS for the super admin. */
export function withSuperAdminRids(mobile: string | null | undefined, rids: string[]): string[] {
return isSuperAdmin(mobile) ? [...new Set([...rids, ...SUPER_ADMIN_RIDS])] : rids;
}