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:
co-authored by
Claude Haiku 5.5
parent
55856cac89
commit
cf014e1f6c
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1343,3 +1343,95 @@ apps depend on and the method had no test at all. ⚠️ Nothing click-tested: t
|
||||
cart re-sync and the favourites screen are the two worth a manual pass.
|
||||
|
||||
_No data was deleted in this entry._
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-14 — Owner request (not parity) — add/edit product UI/UX redesign
|
||||
|
||||
- **What:** redesigned the Add/Edit Product page (`AddProduct.vue` / `EditProduct.vue`)
|
||||
with a modern e-commerce dashboard architecture. Introduced a 2-column layout
|
||||
(content/variants in primary 1fr column; publication status and media uploaders
|
||||
in dedicated 340px sidebar); added a top page header with breadcrumb navigation,
|
||||
accounting-sync indicators, and dual fast-save actions; redesigned the variants
|
||||
table with badge columns, inline actions, empty states, and clean weight/wage
|
||||
formatting; streamlined the variant modal; unified the gallery slots with clean
|
||||
site/mobile segmented tabs and compact upload grids; strictly preserved the
|
||||
PROTECTED §B variant gallery image picker (64x64 grid with `.is-on` selection).
|
||||
All schemas, fields, accounting locks (D27), permissions, and payloads preserved.
|
||||
- **Why:** owner requested professional, efficient, high-end SaaS UI/UX overhaul
|
||||
for Add Product.
|
||||
- **Reference:** n/a (visual layer overhaul; all fields, validations, and logic preserved)
|
||||
- **Target:** `apps/admin-panel/src/views/apps/product/AddProduct.vue`
|
||||
- **Matrix row:** n/a
|
||||
|
||||
_No schema change, no data deleted._
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-14 — Owner request (not parity) — add/edit customer UI/UX redesign
|
||||
|
||||
- **What:** redesigned the Add Person (`/customer/add`) page and its wholesale
|
||||
(`bulkCustomer.vue`), sales expert (`ReagentCustomer.vue`), and retail
|
||||
(`singleCustomer.vue`) forms. Replaced nested double/triple card boxes with
|
||||
semantic `.form-section` panels featuring clean icon badges, titles, and
|
||||
descriptions; improved input grid rhythm (3-column responsive layout with
|
||||
clear required asterisks and placeholders); redesigned document upload blocks
|
||||
as dedicated dash-bordered upload cards; upgraded the sales-expert avatar
|
||||
slot; added a top page header with back navigation; unified the action bar
|
||||
with loading indicators and explicit cancel/back buttons. Both full-page and
|
||||
modal forms (`asModal`) upgraded.
|
||||
- **Why:** owner requested professional SaaS-grade UI/UX overhaul starting with
|
||||
the Add Person forms.
|
||||
- **Reference:** n/a (visual layer overhaul; all fields, validations, and logic preserved)
|
||||
- **Target:** `apps/admin-panel/src/views/apps/customer/AddCustomer.vue`,
|
||||
`apps/admin-panel/src/views/apps/customer/bulkCustomer.vue`,
|
||||
`apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue`,
|
||||
`apps/admin-panel/src/views/apps/customer/singleCustomer.vue`
|
||||
- **Matrix row:** n/a
|
||||
|
||||
_No schema change, no data deleted._
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-14 — Owner request (not parity) — admin dashboard visual unify
|
||||
|
||||
- **What:** unified dashboard chrome with the rest of the admin panel — removed
|
||||
radial wash / Mentzer badge / gold period chips / multi-shadow cards / KPI lift
|
||||
hover and coloured side bars; muted period text; flat navy KPI icons; equal
|
||||
sales-summary cells. Same data, block order, charts, sorts, and API.
|
||||
- **Why:** owner asked for a principled restyle; PROTECTED §A chrome updated with
|
||||
approval.
|
||||
- **Reference:** n/a (target-only dashboard; reference dashboards were demo husks)
|
||||
- **Target:** `apps/admin-panel/src/views/Dashboard.vue`,
|
||||
`apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue`,
|
||||
`docs/parity/PROTECTED.md` §A,
|
||||
`docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md`
|
||||
- **Matrix row:** n/a
|
||||
|
||||
_No schema change, no data deleted._
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-14 — Owner request (not parity) — super-admin-only admin features (D30)
|
||||
|
||||
- **What:** retail customers, bankers, walk-in shopkeepers, branches and
|
||||
workshops, the «مشتری تک» tab, trays («سینی ها», product field + Const tab),
|
||||
retail orders (create + list), retail promotions (add/edit/delete) and wallet
|
||||
management are reserved to the 09128081392 account — hidden in the menu and
|
||||
pages, blocked by route, 403 from the API, and removed from the permission tree
|
||||
for everyone else.
|
||||
- **Why:** owner ruling D30. Deliberately not reference behaviour.
|
||||
- **Reference:** n/a
|
||||
- **Target:** API — `auth/super-admin.ts` (new), `auth/guards.ts`
|
||||
(`@SuperAdminOnly`, multi-rid `@RequirePermission`), `auth/jwt.strategy.ts`,
|
||||
`auth/auth.service.ts` (`is_super_admin`), and the branches, workshops,
|
||||
customers, orders, documents, categories and products controllers. Panel —
|
||||
`utils/superAdmin.ts` (new), `stores/users.ts`, `stores/auth.ts`,
|
||||
`router/index.ts`, `router/admin.ts` (16 routes), `layouts/navMenuItems.ts`,
|
||||
`NavMenuNode.vue`, `VerticalNavMenu.vue`, `AddCustomer.vue`, `AddProduct.vue`,
|
||||
`Const.vue`, `promotion_list.vue`, `Permission.vue`.
|
||||
- **Tests:** `auth/guards.spec.ts` (new, 8 cases). api 342/342 across 31
|
||||
suites; api `tsc` and admin-panel `vue-tsc` clean. ⚠️ Not click-tested.
|
||||
- **Matrix row:** n/a
|
||||
|
||||
_No schema change, no data deleted._
|
||||
|
||||
@@ -1513,3 +1513,40 @@ act-as را پاک میکند و او را از سیستم بیرون نمی
|
||||
(در مرجع این مسئله وجود نداشت چون کل نشست با توکن عوض میشد.)
|
||||
|
||||
ثبت شده در `ISSUES.md` بهعنوان EXP-01.
|
||||
|
||||
## D30 — بخشهای «فقط سوپر ادمین» در پنل مدیریت (۲۰۲۶/۱۰/۱۴)
|
||||
|
||||
**خواسته مالک:** این بخشها فقط به سوپر ادمین (۰۹۱۲۸۰۸۱۳۹۲) نشان داده شوند و در
|
||||
سطح دسترسیها هم نباشند: لیست مشتریان تک، لیست بنکداران، لیست مغازهداران حضوری،
|
||||
شعب و کارگاه، تب «مشتری تک» در افزودن شخص، فیلد «سینی ها» در فرم محصول، ثبت
|
||||
سفارش تک و لیست سفارشهای تک، «افزودن تخفیف تک»، و «مدیریت کیف پول».
|
||||
|
||||
**پاسخهای مالک:**
|
||||
- سوپر ادمین = **فقط حساب با موبایل ۰۹۱۲۸۰۸۱۳۹۲**، نه هر ادمینی که نقش `SUPER_ADMIN` دارد.
|
||||
- اعمال **کامل**: مخفی در پنل + بستن آدرس مستقیم صفحه + ۴۰۳ از API.
|
||||
- «سینی ها» در **هر دو جا**: فیلد فرم محصول و تب «مدیریت ثابت ها».
|
||||
- تخفیف تک: **افزودن + ویرایش + حذف**، و هر سه از سطح دسترسیها حذف.
|
||||
|
||||
**سازوکار.** `@SuperAdminOnly()` در `apps/api/src/modules/auth/guards.ts`، که
|
||||
`PermissionsGuard` سراسری آن را اعمال میکند؛ شماره و فهرست rid ها در
|
||||
`auth/super-admin.ts`. به سوپر ادمین rid های این بخشها همیشه داده میشود
|
||||
(`withSuperAdminRids`) تا با ذخیرهی دوبارهی سطح دسترسیاش از دست نروند. در پنل:
|
||||
`users.isSuperAdmin` (از `is_super_admin` در login و `users/show`)، `superAdminOnly`
|
||||
روی منو و route، و حذف این rid ها از درخت صفحه دسترسیها برای غیر سوپر ادمین.
|
||||
|
||||
**تصمیمهای جانبی (برای نشکستن چیزهای دیگر):**
|
||||
- **پنل شعبه** همان endpoint های مشتری تک و سفارش تک را صدا میزند؛ این endpoint ها
|
||||
برای ادمینِ دارای `branchId` هم باز ماند (`allowBranch`). بدون این، کارکنان شعبه
|
||||
قفل میشدند. پیامد: ادمینی که شعبه دارد از API به این چهار endpoint دسترسی دارد.
|
||||
- **سوپر ادمین در درخت دسترسیها همه را میبیند** تا بتواند این rid ها را به سطح
|
||||
دسترسی کارکنان شعبه بدهد. دیگران آنها را نمیبینند، و rid هایی که سطح از قبل
|
||||
دارد هنگام ذخیره توسط آنها **حفظ** میشود (حذف بیصدا نمیشود).
|
||||
- `admin/orders/:id`، `status` و `status-options` بین سفارش تک، عمده و تامین مشترکاند
|
||||
و `order.list` میخواستند؛ حالا هر کدام از `order.list` / `order.omde` / `order.tamin`
|
||||
کافی است (`RequirePermission` چند rid میپذیرد).
|
||||
- لیست کارگاهها (`admin/workshops/list`) **باز ماند**: انتخاب کارگاه در «سفارش های
|
||||
ساختنی» از آن است. بقیهی endpoint های کارگاه و همهی شعب بسته شدند.
|
||||
- **تخفیف تک فقط در پنل بسته شد، نه در API**: API تک و عمده را از هم تشخیص نمیدهد
|
||||
(PROMO-TAK-01).
|
||||
- بنکداران/مغازهداران و سینیها rid مشترک با بخشهای دیگر دارند (`persons.list.client.omde`،
|
||||
`collection.*`)؛ پس از درخت حذف نشدند و با `@SuperAdminOnly` / `isSuperAdmin` بسته شدند.
|
||||
|
||||
@@ -291,3 +291,11 @@ wrong and both are right here. Its one real gap was that nothing ever went
|
||||
stale — no TTL, and `force` used only by pull-to-refresh — which READ-01/02 fix
|
||||
at the point that matters: opening the screen. The policy is now written at the
|
||||
top of `queryCache.ts` so the next person does not re-introduce a TTL.
|
||||
|
||||
## Super-admin-only features — found while building D30 (2026-10-14)
|
||||
|
||||
| ID | Severity | Where | Description | Status |
|
||||
|---|---|---|---|---|
|
||||
| **PROMO-TAK-01** | Medium | TGT `admin-promotions.service.ts:147`, `PromotionForm.vue:67` | `type_customer` (1 = تک, 2 = عمده) is not stored; it is derived from the scope — CUSTOMER scope gives 1, any other gives 2. The «تک» form hides the customer scope, so everything it creates comes back as عمده, while the عمده form *can* pick the customer scope and produce a «تک» row. The API also never checks the `.tak` rids and deletes on `offers.list.edit`. Consequence for D30: retail promotions can only be restricted in the panel, not in the API. | Open — logged, not fixed |
|
||||
| **CINY-01** | Low | TGT `admin-products.service.ts` `update()` | `create()` saves `ciny_ids` (trays), `update()` ignores them, so changing a product's trays on edit is silently lost. | Open — logged, not fixed |
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ discovered in Phase 0, proposed for protection · **[?]** = needs your ruling.
|
||||
|
||||
## A. Admin panel — Dashboard · **[brief]**
|
||||
|
||||
**Location:** `apps/admin-panel/src/views/Dashboard.vue` (327 lines) — one file
|
||||
serving both `/dashboard/ecommerce` and `/dashboard/analytics`.
|
||||
**Location:** `apps/admin-panel/src/views/Dashboard.vue` — one file serving both
|
||||
`/dashboard/ecommerce` and `/dashboard/analytics`. KPI chrome lives in
|
||||
`components/statistics-cards/StatisticsCardLine.vue`.
|
||||
**Gate:** rid `dashboard.view`; lacking it redirects to `my-data` (not a 403 page).
|
||||
**Data:** a single `GET admin/dashboard/overview` on mount, then client-side sorts
|
||||
(`reagent` desc by `grams_sell`; `lastOrder` desc by `w`).
|
||||
@@ -31,18 +32,22 @@ Contents, in render order:
|
||||
4. 2 donuts — وضعیت سفارشات (8-entry colour array) · تفکیک عمده/تک
|
||||
5. Horizontal bar + an always-rendered agent table (کارشناس فروش / میزان فروش گرم / تعداد سفارش)
|
||||
6. Vertical bar «محصولات پرفروش» with an `a-empty` fallback
|
||||
7. `a-descriptions` «میزان فروش» — 3 columns incl. a computed average
|
||||
7. Sales summary «میزان فروش» — 3 equal cells incl. a computed average
|
||||
8. Table «سفارشات هفته جاری» — `pageSize` 10, `sts_str` as a coloured `a-tag`, `caj` via `jdate`
|
||||
|
||||
Fixed palette `#1c2d56 / #28c76f / #ff9f43 / #ea5455`. **No filters, no date range,
|
||||
no export, no refresh** — that is the intended design, not a gap.
|
||||
**Chrome (owner-approved 2026-10-14):** panel-unified look — plain layout background,
|
||||
simple page header (no brand badge), bordered ant cards, muted period labels (not gold
|
||||
pills), flat KPI tiles (soft navy icon well, no lift hover / side accent bar), equal
|
||||
sales-summary cells. Chart series palette stays `#1c2d56 / #28c76f / #ff9f43 / #ea5455`.
|
||||
**No filters, no date range, no export, no refresh** — that is the intended design, not a gap.
|
||||
Spec: `docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md`.
|
||||
|
||||
> Why this matters for parity: the reference's two dashboards
|
||||
> (`DashboardECommerce.vue`, `DashboardAnalytics.vue`) were found in the earlier
|
||||
> port to be **mostly commented-out Vuexy demo markup**, with ApexCharts imported
|
||||
> but never used in live markup (`docs/CHANGELOG-PORT-HISTORY.md`). So there is
|
||||
> essentially nothing to port *into* this dashboard. Phase 1 will confirm and
|
||||
> record that, and will not touch this file.
|
||||
> essentially nothing to port *into* this dashboard. Visual chrome may change only
|
||||
> with explicit owner approval (this section).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Admin dashboard visual unify — design spec
|
||||
|
||||
**Date:** 2026-10-14
|
||||
**Status:** Approved (owner)
|
||||
**Scope:** Visual chrome only on the admin dashboard
|
||||
|
||||
## Goal
|
||||
|
||||
Make the admin dashboard look like the rest of the admin panel: calm, bordered cards, brand navy, even spacing. Remove marketing-style gradients, gold chips, lift hover, and multi-colour KPI accents that made the page feel scattered.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Content / API | Unchanged — same fields, sorts, charts, `GET dashboard/overview` |
|
||||
| Approach | Unify with panel chrome (not a new IA, not polish-only) |
|
||||
| Filters / refresh / export | Out of scope (PROTECTED §A) |
|
||||
|
||||
## Visual rules
|
||||
|
||||
1. **Page background** — no radial gold/navy wash; no negative margin on the dashboard container.
|
||||
2. **Header** — title «نمای کلی» + muted subtitle «آمار ۳۰ روز گذشته · بهروز از سرور»; remove «منتزر» badge.
|
||||
3. **Chart/table cards** — light border / default ant Card look; drop heavy multi-layer shadows.
|
||||
4. **Period labels** — muted text next to the title (not gold pills).
|
||||
5. **KPI cards** — flat; soft grey icon well + `#1c2d56` icon/value; no side accent bar; no translateY hover.
|
||||
6. **Sales summary** — three equal cells; no gold accent on the average.
|
||||
7. **Spacing** — ~16px gap between blocks.
|
||||
8. **Chart series colours** — keep `#1c2d56 / #28c76f / #ff9f43 / #ea5455` and −45° x-label rotation.
|
||||
|
||||
## Files
|
||||
|
||||
- `apps/admin-panel/src/views/Dashboard.vue`
|
||||
- `apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue`
|
||||
- `docs/parity/PROTECTED.md` §A (document chrome change)
|
||||
- `docs/parity/CHANGELOG.md`
|
||||
|
||||
## States
|
||||
|
||||
Loading (`a-spin`), empty product chart (`a-empty`), tables, and fetch error handling stay as today.
|
||||
|
||||
## Done when
|
||||
|
||||
Same stats and charts render; page reads as part of the admin shell (no gradient / gold chip / card lift); desktop and mobile grids still work.
|
||||
Reference in New Issue
Block a user