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`
|
variables that ant itself ever consumed are carried over — `secondary-color`
|
||||||
and `third-color` were never real ant variables (confirmed dead in the old
|
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.
|
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
|
<a-config-provider :locale="faIR" direction="rtl" :theme="{ token: themeTokens }">
|
||||||
:locale="faIR"
|
|
||||||
direction="rtl"
|
|
||||||
:theme="{ token: { colorPrimary: '#1c2d56', colorLink: '#1DA57A', borderRadius: 6 } }"
|
|
||||||
>
|
|
||||||
<div id="app" :class="vueAppClasses">
|
<div id="app" :class="vueAppClasses">
|
||||||
<router-view @setAppClasses="setAppClasses" />
|
<router-view @setAppClasses="setAppClasses" />
|
||||||
<AppUpdateGate />
|
<AppUpdateGate />
|
||||||
@@ -40,6 +41,17 @@ import { useLayoutStore } from './stores/layout';
|
|||||||
import AppUpdateGate from './components/common/AppUpdateGate.vue';
|
import AppUpdateGate from './components/common/AppUpdateGate.vue';
|
||||||
import { userName } from './utils';
|
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 layout = useLayoutStore();
|
||||||
const vueAppClasses = ref<string[]>([]);
|
const vueAppClasses = ref<string[]>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,73 @@
|
|||||||
z-index: 2500000;
|
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,
|
/* 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
|
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
|
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);
|
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 ──────────────────────────────────────────
|
// ── Full-height list pages ──────────────────────────────────────────
|
||||||
// Main.vue stretches #content-area/.content-area__content to fill the
|
// 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
|
// viewport below the navbar; the routed page's own root needs to pick that
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
Ported from legacy henza_admin/src/components/statistics-cards/StatisticsCardLine.vue.
|
Ported from legacy henza_admin/src/components/statistics-cards/StatisticsCardLine.vue.
|
||||||
|
Visual chrome unified with admin panel cards (2026-10-14). Props and call-sites
|
||||||
The legacy component renders an ApexCharts sparkline under the figure, but
|
unchanged. Sparkline branch still omitted (every live caller used hideChart).
|
||||||
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.
|
|
||||||
-->
|
-->
|
||||||
<a-card class="stat-card">
|
<a-card class="stat-card" :bordered="true">
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-icon" :class="`icon-${color}`">
|
<span class="stat-icon">
|
||||||
<component :is="iconComponent" />
|
<component :is="iconComponent" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div class="stat-copy">
|
||||||
<h2 class="stat-value">{{ statistic }}</h2>
|
|
||||||
<span class="stat-title">{{ statisticTitle }}</span>
|
<span class="stat-title">{{ statisticTitle }}</span>
|
||||||
|
<h2 class="stat-value">{{ statistic }}</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
@@ -29,6 +24,7 @@ import {
|
|||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
DollarOutlined,
|
DollarOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
|
CreditCardOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -36,6 +32,7 @@ const props = withDefaults(
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
statistic?: string | number;
|
statistic?: string | number;
|
||||||
statisticTitle?: string;
|
statisticTitle?: string;
|
||||||
|
/** Kept for call-site compatibility; chrome no longer varies by colour. */
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Kept for call-site parity; the chart branch is not ported (see above). */
|
/** Kept for call-site parity; the chart branch is not ported (see above). */
|
||||||
hideChart?: boolean;
|
hideChart?: boolean;
|
||||||
@@ -49,6 +46,7 @@ const ICONS: Record<string, unknown> = {
|
|||||||
ShoppingBagIcon: ShoppingOutlined,
|
ShoppingBagIcon: ShoppingOutlined,
|
||||||
DollarSignIcon: DollarOutlined,
|
DollarSignIcon: DollarOutlined,
|
||||||
BarChartIcon: BarChartOutlined,
|
BarChartIcon: BarChartOutlined,
|
||||||
|
CreditCardIcon: CreditCardOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined);
|
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined);
|
||||||
@@ -57,46 +55,50 @@ const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.stat-card {
|
.stat-card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
.stat-card :deep(.ant-card-body) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-row {
|
.stat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 48px;
|
width: 40px;
|
||||||
height: 48px;
|
height: 40px;
|
||||||
border-radius: 50%;
|
border-radius: 8px;
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
background: rgba(28, 45, 86, 0.06);
|
||||||
.icon-primary {
|
|
||||||
background: rgba(28, 45, 86, 0.1);
|
|
||||||
color: #1c2d56;
|
color: #1c2d56;
|
||||||
}
|
}
|
||||||
.icon-warning {
|
|
||||||
background: rgba(255, 159, 67, 0.12);
|
.stat-copy {
|
||||||
color: #ff9f43;
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.icon-success {
|
.stat-title {
|
||||||
background: rgba(40, 199, 111, 0.12);
|
font-size: 12px;
|
||||||
color: #28c76f;
|
line-height: 1.4;
|
||||||
}
|
color: #8c8c8c;
|
||||||
.icon-danger {
|
font-weight: 400;
|
||||||
background: rgba(234, 84, 85, 0.12);
|
|
||||||
color: #ea5455;
|
|
||||||
}
|
}
|
||||||
.stat-value {
|
.stat-value {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
color: #1c2d56;
|
color: #1c2d56;
|
||||||
}
|
line-height: 1.25;
|
||||||
.stat-title {
|
word-break: break-word;
|
||||||
font-size: 12px;
|
|
||||||
color: #888;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 575px) {
|
@media (max-width: 575px) {
|
||||||
@@ -104,24 +106,18 @@ const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
.stat-row {
|
.stat-row {
|
||||||
flex-direction: column;
|
gap: 10px;
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
font-size: 17px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.4;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
.stat-title {
|
.stat-title {
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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
|
"کیف پول" nesting "لیست کیف پولهای شارژ شده") — ant-design-vue's a-sub-menu
|
||||||
nests natively, the fixed two-level template just didn't recurse into it.
|
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 #icon><component :is="resolveIcon(node.icon)" v-if="node.icon" /></template>
|
||||||
<template #title>{{ node.i18n || node.name }}</template>
|
<template #title>{{ node.i18n || node.name }}</template>
|
||||||
<NavMenuNode
|
<NavMenuNode
|
||||||
@@ -33,6 +33,7 @@ const users = useUsersStore();
|
|||||||
|
|
||||||
// Same visibility rule as VerticalNavMenu.vue: no permissionId = always visible.
|
// Same visibility rule as VerticalNavMenu.vue: no permissionId = always visible.
|
||||||
function canSee(node: NavMenuNodeType): boolean {
|
function canSee(node: NavMenuNodeType): boolean {
|
||||||
|
if (node.superAdminOnly && !users.isSuperAdmin) return false;
|
||||||
if (!node.permissionId) return true;
|
if (!node.permissionId) return true;
|
||||||
return Boolean(users.hasPermission(node.permissionId));
|
return Boolean(users.hasPermission(node.permissionId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,18 @@
|
|||||||
class="vertical-nav-menu"
|
class="vertical-nav-menu"
|
||||||
theme="light"
|
theme="light"
|
||||||
>
|
>
|
||||||
<div class="nav-header">
|
<div class="nav-header" :class="{ 'is-collapsed': collapsed }">
|
||||||
<span v-if="!collapsed" class="nav-title">{{ title }}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<a-menu
|
<a-menu
|
||||||
|
class="nav-menu"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
theme="light"
|
theme="light"
|
||||||
:inline-collapsed="collapsed"
|
:inline-collapsed="collapsed"
|
||||||
@@ -78,6 +85,7 @@ const users = useUsersStore();
|
|||||||
* Preserved deliberately — see docs/ADMIN-PORT-PLAN.md.
|
* Preserved deliberately — see docs/ADMIN-PORT-PLAN.md.
|
||||||
*/
|
*/
|
||||||
function canSee(node: NavMenuNodeData): boolean {
|
function canSee(node: NavMenuNodeData): boolean {
|
||||||
|
if (node.superAdminOnly && !users.isSuperAdmin) return false;
|
||||||
if (!node.permissionId) return true;
|
if (!node.permissionId) return true;
|
||||||
return Boolean(users.hasPermission(node.permissionId));
|
return Boolean(users.hasPermission(node.permissionId));
|
||||||
}
|
}
|
||||||
@@ -176,17 +184,246 @@ function onClick({ key }: { key: string | number }) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.vertical-nav-menu {
|
.vertical-nav-menu {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto; /* pinned in place; its own menu scrolls if it's taller than the viewport */
|
border-left: 1px solid #eef0f5;
|
||||||
border-left: 1px solid #f0f0f0;
|
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 {
|
.nav-header {
|
||||||
height: 61px; /* legacy ara/ant.less @layout-header-height */
|
height: 61px; /* legacy ara/ant.less @layout-header-height */
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid #eef0f5;
|
||||||
|
}
|
||||||
|
.nav-header.is-collapsed {
|
||||||
justify-content: center;
|
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 {
|
.nav-title {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
color: #1c2d56;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -12,7 +12,12 @@
|
|||||||
sidebar/navbar that used to scroll away with it.
|
sidebar/navbar that used to scroll away with it.
|
||||||
-->
|
-->
|
||||||
<a-layout class="layout--main" :class="{ 'no-scroll': isAppPage }">
|
<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
|
<a-drawer
|
||||||
v-else
|
v-else
|
||||||
@@ -131,9 +136,11 @@ watch(
|
|||||||
<style>
|
<style>
|
||||||
.mobile-nav-drawer .ant-drawer-body {
|
.mobile-nav-drawer .ant-drawer-body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
.mobile-nav-drawer .vertical-nav-menu {
|
.mobile-nav-drawer .vertical-nav-menu {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
|
background: #ffffff !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export interface NavMenuNode {
|
|||||||
tag?: string;
|
tag?: string;
|
||||||
tagColor?: string;
|
tagColor?: string;
|
||||||
permissionId?: string;
|
permissionId?: string;
|
||||||
|
/** Shown to the super admin only, whatever the rid (see utils/superAdmin.ts). */
|
||||||
|
superAdminOnly?: boolean;
|
||||||
header?: string;
|
header?: string;
|
||||||
items?: NavMenuNode[];
|
items?: NavMenuNode[];
|
||||||
submenu?: NavMenuNode[];
|
submenu?: NavMenuNode[];
|
||||||
@@ -109,7 +111,8 @@ export const adminMenu: NavMenuNode[] = [
|
|||||||
name: 'List',
|
name: 'List',
|
||||||
slug: 'app-user-list',
|
slug: 'app-user-list',
|
||||||
i18n: 'لیست مشتریان تک',
|
i18n: 'لیست مشتریان تک',
|
||||||
permissionId:"persons.list.client.single"
|
permissionId:"persons.list.client.single",
|
||||||
|
superAdminOnly: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: '/customer/banker-list',
|
url: '/customer/banker-list',
|
||||||
@@ -121,7 +124,8 @@ export const adminMenu: NavMenuNode[] = [
|
|||||||
// API route it points at (admin/customers-omde/banker/list) is
|
// API route it points at (admin/customers-omde/banker/list) is
|
||||||
// gated on exactly this rid, so an unauthorized click 403'd.
|
// gated on exactly this rid, so an unauthorized click 403'd.
|
||||||
// Filled in to match what the server actually enforces.
|
// 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
|
// legacy navMenuItems.js:90 spelled this with a capital S while the route
|
||||||
@@ -133,7 +137,8 @@ export const adminMenu: NavMenuNode[] = [
|
|||||||
i18n: 'لیست مغازه داران حضوری',
|
i18n: 'لیست مغازه داران حضوری',
|
||||||
// Same fix as banker-list above — the backing endpoint
|
// Same fix as banker-list above — the backing endpoint
|
||||||
// (admin/customers-omde/shopkeeper/list) requires this rid.
|
// (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',
|
name: 'branch and workshop',
|
||||||
icon: 'GridIcon',
|
icon: 'GridIcon',
|
||||||
i18n: 'شعب و کارگاه',
|
i18n: 'شعب و کارگاه',
|
||||||
|
superAdminOnly: true,
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
url: null,
|
url: null,
|
||||||
@@ -227,14 +233,16 @@ export const adminMenu: NavMenuNode[] = [
|
|||||||
name: 'orderOmdeList',
|
name: 'orderOmdeList',
|
||||||
slug: 'order-omde-list',
|
slug: 'order-omde-list',
|
||||||
i18n: 'ثبت سفارش تک',
|
i18n: 'ثبت سفارش تک',
|
||||||
permissionId:"order.create"
|
permissionId:"order.create",
|
||||||
|
superAdminOnly: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: '/order/list',
|
url: '/order/list',
|
||||||
name: 'orderList',
|
name: 'orderList',
|
||||||
slug: 'order-list',
|
slug: 'order-list',
|
||||||
i18n: 'لیست سفارش های تک',
|
i18n: 'لیست سفارش های تک',
|
||||||
permissionId:"order.list"
|
permissionId:"order.list",
|
||||||
|
superAdminOnly: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: '/order/make',
|
url: '/order/make',
|
||||||
@@ -461,7 +469,8 @@ export const adminMenu: NavMenuNode[] = [
|
|||||||
name: 'wallet Management',
|
name: 'wallet Management',
|
||||||
slug: 'manage-const',
|
slug: 'manage-const',
|
||||||
i18n: 'مدیریت کیف پول',
|
i18n: 'مدیریت کیف پول',
|
||||||
permissionId:"config.wallet"
|
permissionId:"config.wallet",
|
||||||
|
superAdminOnly: true
|
||||||
},
|
},
|
||||||
// D27 — Tahesab / Zarrin integration.
|
// D27 — Tahesab / Zarrin integration.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
pageTitle: 'مدیریت کیف پول',
|
pageTitle: 'مدیریت کیف پول',
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'config.wallet',
|
role: 'config.wallet',
|
||||||
|
superAdminOnly: true,
|
||||||
parent: 'manage',
|
parent: 'manage',
|
||||||
no_scroll: true
|
no_scroll: true
|
||||||
}
|
}
|
||||||
@@ -283,6 +284,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'order.list',
|
role: 'order.list',
|
||||||
|
superAdminOnly: true,
|
||||||
parent: 'order',
|
parent: 'order',
|
||||||
no_scroll: true
|
no_scroll: true
|
||||||
}
|
}
|
||||||
@@ -313,6 +315,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'order.create',
|
role: 'order.create',
|
||||||
|
superAdminOnly: true,
|
||||||
parent: 'order',
|
parent: 'order',
|
||||||
no_scroll: true
|
no_scroll: true
|
||||||
}
|
}
|
||||||
@@ -363,6 +366,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'offers.create.tak',
|
role: 'offers.create.tak',
|
||||||
|
superAdminOnly: true,
|
||||||
//parent: 'order',
|
//parent: 'order',
|
||||||
no_scroll: true
|
no_scroll: true
|
||||||
}
|
}
|
||||||
@@ -373,6 +377,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'offers.list.tak.edit',
|
role: 'offers.list.tak.edit',
|
||||||
|
superAdminOnly: true,
|
||||||
//parent: 'order',
|
//parent: 'order',
|
||||||
no_scroll: true
|
no_scroll: true
|
||||||
}
|
}
|
||||||
@@ -566,6 +571,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'persons.list.client.single.edit',
|
role: 'persons.list.client.single.edit',
|
||||||
|
superAdminOnly: true,
|
||||||
parent: 'product',
|
parent: 'product',
|
||||||
no_scroll: true
|
no_scroll: true
|
||||||
}
|
}
|
||||||
@@ -577,6 +583,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'persons.list.client.single.view',
|
role: 'persons.list.client.single.view',
|
||||||
|
superAdminOnly: true,
|
||||||
parent: 'product',
|
parent: 'product',
|
||||||
no_scroll: true
|
no_scroll: true
|
||||||
}
|
}
|
||||||
@@ -638,7 +645,8 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
],
|
],
|
||||||
pageTitle: 'لیست بنکداران',
|
pageTitle: 'لیست بنکداران',
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'persons.list.client.omde'
|
role: 'persons.list.client.omde',
|
||||||
|
superAdminOnly: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -658,7 +666,8 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
],
|
],
|
||||||
pageTitle: 'لیست مغازه داران حضوری',
|
pageTitle: 'لیست مغازه داران حضوری',
|
||||||
rule: 'editor',
|
rule: 'editor',
|
||||||
role: 'persons.list.client.omde'
|
role: 'persons.list.client.omde',
|
||||||
|
superAdminOnly: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -736,6 +745,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
],
|
],
|
||||||
pageTitle: 'لیست مشتریان',
|
pageTitle: 'لیست مشتریان',
|
||||||
role: 'persons.list.client.single',
|
role: 'persons.list.client.single',
|
||||||
|
superAdminOnly: true,
|
||||||
rule: 'editor'
|
rule: 'editor'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -965,6 +975,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
],
|
],
|
||||||
pageTitle: 'افزودن شعب',
|
pageTitle: 'افزودن شعب',
|
||||||
role: 'branch.create',
|
role: 'branch.create',
|
||||||
|
superAdminOnly: true,
|
||||||
rule: 'editor'
|
rule: 'editor'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -975,6 +986,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
pageTitle: 'ویرایش شعب',
|
pageTitle: 'ویرایش شعب',
|
||||||
role: 'branch.create',
|
role: 'branch.create',
|
||||||
|
superAdminOnly: true,
|
||||||
rule: 'editor'
|
rule: 'editor'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -996,6 +1008,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
],
|
],
|
||||||
pageTitle: 'لیست شعب',
|
pageTitle: 'لیست شعب',
|
||||||
role: 'branch.list',
|
role: 'branch.list',
|
||||||
|
superAdminOnly: true,
|
||||||
rule: 'editor'
|
rule: 'editor'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1018,6 +1031,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
],
|
],
|
||||||
pageTitle: 'افزودن کارگاه',
|
pageTitle: 'افزودن کارگاه',
|
||||||
role: 'workshop.create',
|
role: 'workshop.create',
|
||||||
|
superAdminOnly: true,
|
||||||
rule: 'editor'
|
rule: 'editor'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1028,6 +1042,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
meta: {
|
meta: {
|
||||||
pageTitle: 'ویرایش کارگاه',
|
pageTitle: 'ویرایش کارگاه',
|
||||||
role: 'workshop.create',
|
role: 'workshop.create',
|
||||||
|
superAdminOnly: true,
|
||||||
rule: 'editor'
|
rule: 'editor'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1049,6 +1064,7 @@ export const adminRoute: RouteRecordRaw[] = [
|
|||||||
],
|
],
|
||||||
pageTitle: 'لیست کارگاه ها',
|
pageTitle: 'لیست کارگاه ها',
|
||||||
role: 'workshop.list',
|
role: 'workshop.list',
|
||||||
|
superAdminOnly: true,
|
||||||
rule: 'editor'
|
rule: 'editor'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ async function getPermission(): Promise<string[] | undefined> {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get('users/show');
|
const { data } = await api.get('users/show');
|
||||||
users.getRoles(data.permission);
|
users.getRoles(data.permission);
|
||||||
|
users.setSuperAdmin(data.is_super_admin);
|
||||||
// Legacy stripped these before caching the user blob.
|
// Legacy stripped these before caching the user blob.
|
||||||
delete data.permission;
|
delete data.permission;
|
||||||
delete data.ut;
|
delete data.ut;
|
||||||
@@ -149,6 +150,9 @@ router.beforeEach(async (to) => {
|
|||||||
const token = localStorage.getItem(tokenName);
|
const token = localStorage.getItem(tokenName);
|
||||||
if (token) {
|
if (token) {
|
||||||
const roles = await getPermission();
|
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 (roles && to.meta && to.meta.role && !roles.includes(to.meta.role as string)) {
|
||||||
if (to.name === 'dashboard-ecommerce' || to.name === 'dashboard-analytics') {
|
if (to.name === 'dashboard-ecommerce' || to.name === 'dashboard-analytics') {
|
||||||
return { name: 'my-data' };
|
return { name: 'my-data' };
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
|
|
||||||
const users = useUsersStore();
|
const users = useUsersStore();
|
||||||
users.getRoles((data.roles ?? []).map((x: { rid: string }) => x.rid));
|
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
|
// Legacy stripped these before persisting the user blob. Kept: the panel
|
||||||
// reads permissions from the store, never from the cached user object.
|
// 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").
|
// undefined = "not fetched yet" (distinct from [] = "fetched, has none").
|
||||||
// The router guard relies on that distinction, as legacy's did.
|
// The router guard relies on that distinction, as legacy's did.
|
||||||
roles: undefined as string[] | undefined,
|
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: {
|
getters: {
|
||||||
/**
|
/**
|
||||||
@@ -107,8 +110,12 @@ export const useUsersStore = defineStore('users', {
|
|||||||
getRoles(roles: string[]) {
|
getRoles(roles: string[]) {
|
||||||
this.roles = checkParent(roles);
|
this.roles = checkParent(roles);
|
||||||
},
|
},
|
||||||
|
setSuperAdmin(value: unknown) {
|
||||||
|
this.isSuperAdmin = value === true;
|
||||||
|
},
|
||||||
deleteRoles() {
|
deleteRoles() {
|
||||||
this.roles = undefined;
|
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
|
Permission gates are legacy's, including the third tab's: legacy commented
|
||||||
out its `persons.create.customer.tak` check (AddCustomer.vue:142), so the
|
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="page-user-view" class="pf-page">
|
||||||
<div id="user-data">
|
<header class="pf-head">
|
||||||
<a-card class="mb-base page-card">
|
<a-button class="pf-head__back" shape="circle" @click="handleBack">
|
||||||
<a-tabs v-if="hasPermission('persons.create')">
|
<template #icon><ArrowRightOutlined /></template>
|
||||||
<a-tab-pane v-if="hasPermission('persons.create.client')" key="omde" tab="مشتری عمده">
|
</a-button>
|
||||||
<BulkCustomer />
|
<div class="pf-head__text">
|
||||||
</a-tab-pane>
|
<h1>افزودن شخص</h1>
|
||||||
|
<p>نوع شخص را انتخاب و اطلاعات او را تکمیل کنید.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<a-tab-pane v-if="hasPermission('persons.create.seller')" key="reagent" tab="کارشناس فروش">
|
<template v-if="hasPermission('persons.create') && kinds.length">
|
||||||
<ReagentCustomer />
|
<div class="kind-picker" role="tablist">
|
||||||
</a-tab-pane>
|
<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="مشتری تک">
|
<KeepAlive>
|
||||||
<SingleCustomer />
|
<component :is="FORMS[activeTab]" v-if="kinds.some((k) => k.key === activeTab)" :key="activeTab" />
|
||||||
</a-tab-pane>
|
</KeepAlive>
|
||||||
</a-tabs>
|
</template>
|
||||||
</a-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 BulkCustomer from './bulkCustomer.vue';
|
||||||
import ReagentCustomer from './ReagentCustomer.vue';
|
import ReagentCustomer from './ReagentCustomer.vue';
|
||||||
import SingleCustomer from './singleCustomer.vue';
|
import SingleCustomer from './singleCustomer.vue';
|
||||||
import { useUsersStore } from '../../../stores/users';
|
import { useUsersStore } from '../../../stores/users';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const users = useUsersStore();
|
const users = useUsersStore();
|
||||||
|
|
||||||
|
const activeTab = ref((route.query.tab as string) || 'omde');
|
||||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
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>
|
</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.
|
operator can still type in directly.
|
||||||
-->
|
-->
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<a-form layout="vertical" @submit.prevent="handleSubmit">
|
<a-form layout="vertical" class="pf-form" @submit.prevent="handleSubmit">
|
||||||
<a-card title="اطلاعات پایه" class="mb-base">
|
<div class="pf-layout">
|
||||||
<div class="grid-4">
|
<!-- ══════════ main column ══════════ -->
|
||||||
<a-form-item label="عکس پروفایل" class="avatar-cell">
|
<div class="pf-main">
|
||||||
<ImageUploadInput
|
<section class="pf-section">
|
||||||
v-model="form.image_url"
|
<div class="pf-section__head">
|
||||||
purpose="reagent.avatar"
|
<span class="pf-section__icon"><UserOutlined /></span>
|
||||||
placeholder="عکس"
|
<div>
|
||||||
@ok="onAvatar"
|
<h3>اطلاعات پایه</h3>
|
||||||
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
|
<p>مشخصات هویتی و اطلاعات ورود کارشناس</p>
|
||||||
/>
|
</div>
|
||||||
</a-form-item>
|
</div>
|
||||||
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
<div class="pf-grid-2">
|
||||||
<a-input v-model:value="form.fname" />
|
<a-form-item label="نام " required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
||||||
</a-form-item>
|
<a-input v-model:value="form.fname" />
|
||||||
<a-form-item
|
</a-form-item>
|
||||||
label="نام خانوادگی"
|
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
|
||||||
:validate-status="err.lname ? 'error' : ''"
|
<a-input v-model:value="form.lname" />
|
||||||
:help="err.lname"
|
</a-form-item>
|
||||||
>
|
<!--
|
||||||
<a-input v-model:value="form.lname" />
|
D17 — both optional in the reference (required: false,
|
||||||
</a-form-item>
|
ReagentCustomer.vue:31,95) with no uniqueness and no format rule,
|
||||||
<a-form-item
|
so none is invented here.
|
||||||
label="شماره تلفن همراه"
|
-->
|
||||||
:validate-status="err.mobile ? 'error' : ''"
|
<a-form-item label="کد ملی">
|
||||||
:help="err.mobile"
|
<a-input v-model:value="form.ncode" class="ltr-input" inputmode="numeric" />
|
||||||
>
|
</a-form-item>
|
||||||
<a-input v-model:value="form.mobile" />
|
<a-form-item label="تلفن">
|
||||||
</a-form-item>
|
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" />
|
||||||
<a-form-item label="رمز عبور" :validate-status="err.password ? 'error' : ''" :help="err.password">
|
</a-form-item>
|
||||||
<a-input-password v-model:value="form.password" />
|
<a-form-item
|
||||||
</a-form-item>
|
label="شماره تلفن همراه"
|
||||||
</div>
|
required
|
||||||
</a-card>
|
: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">
|
<section class="pf-section">
|
||||||
<div class="grid-4">
|
<div class="pf-section__head">
|
||||||
<a-form-item label="کد مشتری">
|
<span class="pf-section__icon"><ShopOutlined /></span>
|
||||||
<a-input v-model:value="form.mcode" />
|
<div>
|
||||||
</a-form-item>
|
<h3>اطلاعات تجاری</h3>
|
||||||
<a-form-item label="نام مغازه">
|
<p>کد و مشخصات فروشگاه</p>
|
||||||
<a-input v-model:value="form.store_name" />
|
</div>
|
||||||
</a-form-item>
|
</div>
|
||||||
<a-form-item label="نام پاساژ">
|
<div class="pf-grid-3">
|
||||||
<a-input v-model:value="form.pasazh_name" />
|
<a-form-item label="کد مشتری" class="mb-0">
|
||||||
</a-form-item>
|
<a-input v-model:value="form.mcode" />
|
||||||
<a-form-item label="درصد کمیسیون">
|
</a-form-item>
|
||||||
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
|
<a-form-item label="نام مغازه" class="mb-0">
|
||||||
</a-form-item>
|
<a-input v-model:value="form.store_name" />
|
||||||
<!--
|
</a-form-item>
|
||||||
D17 — the sales-expert fields the reference collected here.
|
<a-form-item label="نام پاساژ" class="mb-0">
|
||||||
«شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228).
|
<a-input v-model:value="form.pasazh_name" />
|
||||||
-->
|
</a-form-item>
|
||||||
<a-form-item label="شهر">
|
</div>
|
||||||
<CityTreeSelect
|
</section>
|
||||||
: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>
|
|
||||||
|
|
||||||
<a-card title="اطلاعات تکمیلی" class="mb-base">
|
<section class="pf-section">
|
||||||
<div class="grid-4">
|
<div class="pf-section__head">
|
||||||
<!--
|
<span class="pf-section__icon"><EnvironmentOutlined /></span>
|
||||||
D17 — both optional in the reference (required: false,
|
<div>
|
||||||
ReagentCustomer.vue:31,95) with no uniqueness and no format rule,
|
<h3>آدرس</h3>
|
||||||
so none is invented here.
|
<p>انتخاب شهر، درصد کمیسیون را بر اساس همان شهر پر میکند</p>
|
||||||
-->
|
</div>
|
||||||
<a-form-item label="کد ملی">
|
</div>
|
||||||
<a-input v-model:value="form.ncode" />
|
<!-- D17 — «شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228). -->
|
||||||
</a-form-item>
|
<a-form-item label="شهر">
|
||||||
<a-form-item label="تلفن">
|
<CityTreeSelect
|
||||||
<a-input v-model:value="form.phone" />
|
:value="form.city_id"
|
||||||
</a-form-item>
|
: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>
|
</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-form>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</template>
|
</template>
|
||||||
@@ -113,6 +168,17 @@
|
|||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
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 api from '../../../api/client';
|
||||||
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
||||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||||
@@ -133,25 +199,18 @@ const form = reactive<Record<string, any>>({
|
|||||||
pasazh_name: '',
|
pasazh_name: '',
|
||||||
address: '',
|
address: '',
|
||||||
percent: undefined,
|
percent: undefined,
|
||||||
// D17 — the sales-expert fields the reference collected on this form and the
|
|
||||||
// port had dropped. They live on SalesExpertProfile now.
|
|
||||||
ncode: '',
|
ncode: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
city_id: undefined,
|
city_id: undefined,
|
||||||
gram_level: undefined,
|
gram_level: undefined,
|
||||||
// legacy kept the FK in `image_id`; `image_url` only drives the thumbnail.
|
|
||||||
image_id: '',
|
image_id: '',
|
||||||
image_url: null,
|
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 cities = ref<Array<{ id: string; title: string; parent?: string | null; percent?: number | null }>>([]);
|
||||||
const err = reactive<Record<string, string>>({});
|
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>>({});
|
const initial = reactive<Record<string, any>>({});
|
||||||
|
|
||||||
/** The subset of `form` that differs from `initial`, minus `omit` keys. */
|
|
||||||
function diffFromInitial(omit: string[] = []): Record<string, any> {
|
function diffFromInitial(omit: string[] = []): Record<string, any> {
|
||||||
const out: Record<string, any> = {};
|
const out: Record<string, any> = {};
|
||||||
for (const [k, v] of Object.entries(form)) {
|
for (const [k, v] of Object.entries(form)) {
|
||||||
@@ -161,23 +220,10 @@ function diffFromInitial(omit: string[] = []): Record<string, any> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy `uploadIndexImage` — MyUpload emitted the whole response object. */
|
|
||||||
function onAvatar(file: Record<string, unknown>) {
|
function onAvatar(file: Record<string, unknown>) {
|
||||||
form.image_id = (file.id as string) ?? '';
|
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) {
|
async function onCityChange(value: string | undefined) {
|
||||||
form.city_id = value;
|
form.city_id = value;
|
||||||
if (!value) return;
|
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;
|
let percent: number | null | undefined = cities.value.find((c) => c.id === value)?.percent;
|
||||||
if (!percent) {
|
if (!percent) {
|
||||||
try {
|
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}`);
|
const { data } = await api.get(`cities/get-percent-aux/${value}`);
|
||||||
percent = data as number | null;
|
percent = data as number | null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -197,6 +240,7 @@ async function onCityChange(value: string | undefined) {
|
|||||||
}
|
}
|
||||||
form.percent = percent === null || percent === undefined ? undefined : Number(percent);
|
form.percent = percent === null || percent === undefined ? undefined : Number(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
const REQUIRED = 'لطفا مقدار را وارد کنید';
|
const REQUIRED = 'لطفا مقدار را وارد کنید';
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
@@ -209,16 +253,10 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
// `image_url` is presentation only — the server stores the FK (`image_id`).
|
|
||||||
const { image_url: _unusedAvatarUrl, ...allFields } = form;
|
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;
|
const fields = id ? diffFromInitial(['image_url', 'password']) : allFields;
|
||||||
if (id && form.password) fields.password = form.password;
|
if (id && form.password) fields.password = form.password;
|
||||||
const payload: Record<string, any> = { ...fields, role: 'SALES_EXPERT' };
|
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) {
|
if (!id || 'fname' in fields || 'lname' in fields) {
|
||||||
payload.name = `${form.fname} ${form.lname}`.trim();
|
payload.name = `${form.fname} ${form.lname}`.trim();
|
||||||
}
|
}
|
||||||
@@ -238,7 +276,6 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// D17 — the «شهر» select and its commission autofill need the city list.
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('cities/list', { page: 1, limit: 500 });
|
const { data } = await api.post('cities/list', { page: 1, limit: 500 });
|
||||||
cities.value = (data.rows ?? []).map((c: any) => ({
|
cities.value = (data.rows ?? []).map((c: any) => ({
|
||||||
@@ -260,8 +297,6 @@ onMounted(async () => {
|
|||||||
mobile: data.mobile ?? '',
|
mobile: data.mobile ?? '',
|
||||||
address: data.address ?? '',
|
address: data.address ?? '',
|
||||||
percent: data.percent === null ? undefined : Number(data.percent),
|
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 ?? '',
|
ncode: data.ncode ?? '',
|
||||||
phone: data.phone ?? '',
|
phone: data.phone ?? '',
|
||||||
city_id: data.city_id ?? undefined,
|
city_id: data.city_id ?? undefined,
|
||||||
@@ -269,14 +304,12 @@ onMounted(async () => {
|
|||||||
data.gram_level === null || data.gram_level === undefined
|
data.gram_level === null || data.gram_level === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: Number(data.gram_level),
|
: Number(data.gram_level),
|
||||||
// guarded, unlike legacy — an agent with no avatar must not throw
|
|
||||||
image_id: data.image?.id ?? '',
|
image_id: data.image?.id ?? '',
|
||||||
image_url: data.image?.image ?? null,
|
image_url: data.image?.image ?? null,
|
||||||
store_name: data.store_name ?? '',
|
store_name: data.store_name ?? '',
|
||||||
pasazh_name: data.pasazh_name ?? '',
|
pasazh_name: data.pasazh_name ?? '',
|
||||||
mcode: data.mcode ?? '',
|
mcode: data.mcode ?? '',
|
||||||
});
|
});
|
||||||
// Baseline for the dirty-diff in handleSubmit.
|
|
||||||
Object.assign(initial, JSON.parse(JSON.stringify(form)));
|
Object.assign(initial, JSON.parse(JSON.stringify(form)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@@ -286,19 +319,9 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* .grid-4 now comes from the global utilities.less (dedup) */
|
.avatar-slot {
|
||||||
|
|
||||||
/* 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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
padding: 4px 0;
|
||||||
/* single-column grid on phones: a 2-row span would only leave a gap */
|
|
||||||
@media (max-width: 575px) {
|
|
||||||
.avatar-cell {
|
|
||||||
grid-row: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -136,118 +136,163 @@
|
|||||||
</BaseFormModal>
|
</BaseFormModal>
|
||||||
|
|
||||||
<!-- route-based fallback for /customer/add and /bulkCustomer/edit/:id -->
|
<!-- 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-spin :spinning="loading">
|
||||||
<a-card title="اطلاعات پایه" class="mb-base">
|
<div class="pf-layout">
|
||||||
<div class="grid-4">
|
<!-- ══════════ main column ══════════ -->
|
||||||
<a-form-item label="نام" :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
<div class="pf-main">
|
||||||
<a-input v-model:value="form.fname" />
|
<section class="pf-section">
|
||||||
</a-form-item>
|
<div class="pf-section__head">
|
||||||
<a-form-item label="نام خانوادگی" :validate-status="err.lname ? 'error' : ''" :help="err.lname">
|
<span class="pf-section__icon"><UserOutlined /></span>
|
||||||
<a-input v-model:value="form.lname" />
|
<div>
|
||||||
</a-form-item>
|
<h3>اطلاعات پایه</h3>
|
||||||
<a-form-item label="کد ملی">
|
<p>مشخصات هویتی و اطلاعات ورود مشتری</p>
|
||||||
<a-input v-model:value="form.ncode" />
|
</div>
|
||||||
</a-form-item>
|
</div>
|
||||||
<a-form-item
|
<div class="pf-grid-2">
|
||||||
label="شماره تلفن همراه"
|
<a-form-item label="نام" required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
||||||
:validate-status="err.mobile ? 'error' : ''"
|
<a-input v-model:value="form.fname" />
|
||||||
:help="err.mobile"
|
</a-form-item>
|
||||||
>
|
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
|
||||||
<a-input v-model:value="form.mobile" />
|
<a-input v-model:value="form.lname" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="رمز عبور">
|
<a-form-item label="کد ملی">
|
||||||
<a-input-password v-model:value="form.password" />
|
<a-input v-model:value="form.ncode" class="ltr-input" inputmode="numeric" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="تلفن ثابت">
|
<DateInput
|
||||||
<a-input v-model:value="form.phone" />
|
:model-value="form.birth_date || undefined"
|
||||||
</a-form-item>
|
label="تاریخ تولد"
|
||||||
</div>
|
@update:model-value="(v) => (form.birth_date = v ?? '')"
|
||||||
</a-card>
|
/>
|
||||||
|
<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">
|
<section class="pf-section">
|
||||||
<div class="grid-4">
|
<div class="pf-section__head">
|
||||||
<a-form-item label="نوع کاربری">
|
<span class="pf-section__icon"><ShopOutlined /></span>
|
||||||
<a-select
|
<div>
|
||||||
v-model:value="form.type"
|
<h3>اطلاعات تجاری</h3>
|
||||||
show-search
|
<p>نوع کاربری و مشخصات فروشگاه</p>
|
||||||
:filter-option="filterUserType"
|
</div>
|
||||||
style="width: 100%; direction: rtl !important"
|
</div>
|
||||||
>
|
<div class="pf-grid-2">
|
||||||
<a-select-option v-for="item in userTypes" :key="item.id" :value="item.id">
|
<a-form-item label="نوع کاربری">
|
||||||
{{ item.title }}
|
<a-select
|
||||||
</a-select-option>
|
v-model:value="form.type"
|
||||||
</a-select>
|
show-search
|
||||||
</a-form-item>
|
placeholder="انتخاب کنید"
|
||||||
<a-form-item label="نام مغازه">
|
:filter-option="filterUserType"
|
||||||
<a-input v-model:value="form.store_name" />
|
style="width: 100%; direction: rtl !important"
|
||||||
</a-form-item>
|
>
|
||||||
<a-form-item label="نام پاساژ">
|
<a-select-option v-for="item in userTypes" :key="item.id" :value="item.id">
|
||||||
<a-input v-model:value="form.pasazh_name" />
|
{{ item.title }}
|
||||||
</a-form-item>
|
</a-select-option>
|
||||||
<a-form-item label="کد مشتری">
|
</a-select>
|
||||||
<a-input v-model:value="form.mcode" />
|
</a-form-item>
|
||||||
</a-form-item>
|
<a-form-item label="کد مشتری">
|
||||||
<DateInput
|
<a-input v-model:value="form.mcode" />
|
||||||
:model-value="form.birth_date || undefined"
|
</a-form-item>
|
||||||
label="تاریخ تولد"
|
<a-form-item label="نام مغازه" class="mb-0">
|
||||||
@update:model-value="(v) => (form.birth_date = v ?? '')"
|
<a-input v-model:value="form.store_name" />
|
||||||
/>
|
</a-form-item>
|
||||||
<a-form-item label="کارشناس فروش">
|
<a-form-item label="نام پاساژ" class="mb-0">
|
||||||
<a-select v-model:value="form.reagent_id" allow-clear placeholder="انتخاب کنید">
|
<a-input v-model:value="form.pasazh_name" />
|
||||||
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id">
|
</a-form-item>
|
||||||
{{ r.name }}
|
</div>
|
||||||
</a-select-option>
|
</section>
|
||||||
</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>
|
|
||||||
|
|
||||||
<a-card title="اطلاعات تکمیلی" class="mb-base">
|
<section class="pf-section">
|
||||||
<div class="grid-2">
|
<div class="pf-section__head">
|
||||||
<a-form-item label="شهر">
|
<span class="pf-section__icon"><EnvironmentOutlined /></span>
|
||||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
|
<div>
|
||||||
</a-form-item>
|
<h3>آدرس</h3>
|
||||||
<a-form-item label="آدرس">
|
<p>انتخاب شهر، درصد را بر اساس همان شهر پر میکند</p>
|
||||||
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
</div>
|
||||||
</a-form-item>
|
</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>
|
</div>
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<a-card title="مدارک" class="mb-base">
|
<!-- ══════════ side column ══════════ -->
|
||||||
<div class="grid-3">
|
<aside class="pf-side">
|
||||||
<a-form-item label="عکس کارت ملی">
|
<section class="pf-section">
|
||||||
<ImageUploadInput
|
<div class="pf-section__head">
|
||||||
v-model="form.national_id_image"
|
<span class="pf-section__icon"><PercentageOutlined /></span>
|
||||||
purpose="customer.nationalId"
|
<div>
|
||||||
placeholder="عکس"
|
<h3>کارشناس و اعتبار</h3>
|
||||||
/>
|
<p>کارشناس فروش، درصد و سقف اعتبار</p>
|
||||||
</a-form-item>
|
</div>
|
||||||
<a-form-item label="عکس جواز کسب">
|
</div>
|
||||||
<ImageUploadInput
|
<a-form-item label="کارشناس فروش">
|
||||||
v-model="form.business_license"
|
<a-select
|
||||||
purpose="customer.businessLicense"
|
v-model:value="form.reagent_id"
|
||||||
placeholder="عکس"
|
allow-clear
|
||||||
/>
|
show-search
|
||||||
</a-form-item>
|
option-filter-prop="label"
|
||||||
<a-form-item label="عکس سردر مغازه">
|
placeholder="انتخاب کنید"
|
||||||
<ImageUploadInput
|
>
|
||||||
v-model="form.shop_image"
|
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id" :label="r.name">
|
||||||
purpose="customer.shop"
|
{{ r.name }}
|
||||||
placeholder="عکس"
|
</a-select-option>
|
||||||
/>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</div>
|
<a-form-item label="درصد" required :validate-status="err.percent ? 'error' : ''" :help="err.percent">
|
||||||
</a-card>
|
<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>
|
<section class="pf-section">
|
||||||
<a-button type="primary" class="mr-4 form-submit" @click="handleSubmitOmde">ثبت اطلاعات</a-button>
|
<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>
|
</div>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</a-form>
|
</a-form>
|
||||||
@@ -257,6 +302,17 @@
|
|||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
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 BaseFormModal from '../../../components/common/BaseFormModal.vue';
|
||||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||||
import api from '../../../api/client';
|
import api from '../../../api/client';
|
||||||
@@ -266,18 +322,6 @@ import DateInput from '../../../components/input/DateInput.vue';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
id?: string;
|
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;
|
asModal?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -308,34 +352,15 @@ const form = reactive<Record<string, any>>({
|
|||||||
mcode: '',
|
mcode: '',
|
||||||
birth_date: '',
|
birth_date: '',
|
||||||
address: '',
|
address: '',
|
||||||
// legacy defaulted to no selection; 2 (مغازه دار اپلیکیشن) is what the
|
|
||||||
// backend falls back to when the field is absent.
|
|
||||||
type: undefined,
|
type: undefined,
|
||||||
reagent_id: 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,
|
percent: 3,
|
||||||
gram_level: undefined,
|
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: '',
|
national_id_image: '',
|
||||||
business_license: '',
|
business_license: '',
|
||||||
shop_image: '',
|
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 = [
|
const userTypes = [
|
||||||
{ id: 2, title: 'مغازه دار اپلیکیشن' },
|
{ id: 2, title: 'مغازه دار اپلیکیشن' },
|
||||||
{ id: 3, title: 'مغازه دار حضوری' },
|
{ id: 3, title: 'مغازه دار حضوری' },
|
||||||
@@ -347,14 +372,8 @@ function filterUserType(input: string, option: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const err = reactive<Record<string, string>>({});
|
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>>({});
|
const initial = reactive<Record<string, any>>({});
|
||||||
|
|
||||||
/** The subset of `form` that differs from `initial`. */
|
|
||||||
function diffFromInitial(): Record<string, any> {
|
function diffFromInitial(): Record<string, any> {
|
||||||
const out: Record<string, any> = {};
|
const out: Record<string, any> = {};
|
||||||
for (const [k, v] of Object.entries(form)) {
|
for (const [k, v] of Object.entries(form)) {
|
||||||
@@ -363,11 +382,6 @@ function diffFromInitial(): Record<string, any> {
|
|||||||
return out;
|
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) {
|
async function onCityChange(value: string | undefined) {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
let percent: unknown = cities.value.find((c) => c.id === value)?.percent;
|
let percent: unknown = cities.value.find((c) => c.id === value)?.percent;
|
||||||
@@ -385,13 +399,9 @@ async function onCityChange(value: string | undefined) {
|
|||||||
|
|
||||||
function validate(): boolean {
|
function validate(): boolean {
|
||||||
Object.keys(err).forEach((k) => delete err[k]);
|
Object.keys(err).forEach((k) => delete err[k]);
|
||||||
// legacy required: fname, lname, mobile — message 'لطفا مقدار را وارد کنید'
|
|
||||||
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
|
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
|
||||||
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
|
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
|
||||||
if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید';
|
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) === '') {
|
if (form.percent === undefined || form.percent === null || (form.percent as unknown) === '') {
|
||||||
err.percent = 'لطفا مقدار را وارد کنید';
|
err.percent = 'لطفا مقدار را وارد کنید';
|
||||||
}
|
}
|
||||||
@@ -402,21 +412,12 @@ async function handleSubmitOmde() {
|
|||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
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 changed = id ? diffFromInitial() : { ...form };
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
...changed,
|
...changed,
|
||||||
id: id ?? null,
|
id: id ?? null,
|
||||||
national_code: form.ncode,
|
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) {
|
if (!id || 'fname' in changed || 'lname' in changed) {
|
||||||
payload.name = `${form.fname} ${form.lname}`.trim();
|
payload.name = `${form.fname} ${form.lname}`.trim();
|
||||||
}
|
}
|
||||||
@@ -433,7 +434,6 @@ async function handleSubmitOmde() {
|
|||||||
router.go(-1);
|
router.go(-1);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// legacy surfaced the server's mobile-uniqueness error specifically
|
|
||||||
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
|
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
|
||||||
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
|
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -450,7 +450,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('cities/list');
|
const { data } = await api.post('cities/list');
|
||||||
cities.value = data.rows;
|
cities.value = data.rows ?? [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
}
|
}
|
||||||
@@ -464,30 +464,19 @@ onMounted(async () => {
|
|||||||
mobile: data.mobile ?? '',
|
mobile: data.mobile ?? '',
|
||||||
city_id: data.city_id ?? undefined,
|
city_id: data.city_id ?? undefined,
|
||||||
address: data.address ?? '',
|
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),
|
percent: data.percent === null || data.percent === undefined ? undefined : Number(data.percent),
|
||||||
gram_level: data.gram_level ?? undefined,
|
gram_level: data.gram_level ?? undefined,
|
||||||
reagent_id: data.reagent?.id ?? 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 ?? '',
|
store_name: data.store_name ?? '',
|
||||||
pasazh_name: data.pasazh_name ?? '',
|
pasazh_name: data.pasazh_name ?? '',
|
||||||
mcode: data.mcode ?? '',
|
mcode: data.mcode ?? '',
|
||||||
birth_date: data.birth_date ?? '',
|
birth_date: data.birth_date ?? '',
|
||||||
phone: data.phone ?? '',
|
phone: data.phone ?? '',
|
||||||
type: data.type ?? undefined,
|
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 ?? '',
|
national_id_image: data.national_id_image ?? '',
|
||||||
business_license: data.business_license ?? '',
|
business_license: data.business_license ?? '',
|
||||||
shop_image: data.shop_image ?? '',
|
shop_image: data.shop_image ?? '',
|
||||||
});
|
});
|
||||||
// Baseline for the dirty-diff in handleSubmitOmde.
|
|
||||||
Object.assign(initial, JSON.parse(JSON.stringify(form)));
|
Object.assign(initial, JSON.parse(JSON.stringify(form)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e);
|
||||||
@@ -496,4 +485,4 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- .grid-2/.grid-4 now come from the global utilities.less (dedup) -->
|
|
||||||
|
|||||||
@@ -8,41 +8,48 @@
|
|||||||
the customer endpoints.
|
the customer endpoints.
|
||||||
-->
|
-->
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<a-form layout="vertical" @submit.prevent="handleSubmit">
|
<a-form layout="vertical" class="pf-form" @submit.prevent="handleSubmit">
|
||||||
<a-card :title="id ? 'ویرایش مشتری' : 'افزودن مشتری'" class="mb-base">
|
<div class="pf-layout pf-layout--single">
|
||||||
<div class="grid-3">
|
<div class="pf-main">
|
||||||
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
<section class="pf-section">
|
||||||
<a-input v-model:value="form.fname" />
|
<div class="pf-section__head">
|
||||||
</a-form-item>
|
<span class="pf-section__icon"><UserOutlined /></span>
|
||||||
|
<div>
|
||||||
<a-form-item
|
<h3>{{ id ? 'ویرایش مشتری' : 'افزودن مشتری' }}</h3>
|
||||||
label="نام خانوادگی"
|
<p>مشخصات مشتری تک</p>
|
||||||
:validate-status="err.lname ? 'error' : ''"
|
</div>
|
||||||
:help="err.lname"
|
</div>
|
||||||
>
|
<div class="pf-grid-2">
|
||||||
<a-input v-model:value="form.lname" />
|
<a-form-item label="نام " required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
||||||
</a-form-item>
|
<a-input v-model:value="form.fname" />
|
||||||
|
</a-form-item>
|
||||||
<a-form-item
|
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
|
||||||
label="شماره تلفن همراه"
|
<a-input v-model:value="form.lname" />
|
||||||
:validate-status="err.phone ? 'error' : ''"
|
</a-form-item>
|
||||||
:help="err.phone"
|
<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-input v-model:value="form.phone" />
|
</a-form-item>
|
||||||
</a-form-item>
|
<DateInput
|
||||||
|
:model-value="form.birth_day || undefined"
|
||||||
<a-form-item label="شهر">
|
label="تاریخ تولد"
|
||||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
|
@update:model-value="(v) => (form.birth_day = v ?? '')"
|
||||||
</a-form-item>
|
/>
|
||||||
<DateInput
|
<a-form-item label="شهر" class="pf-span-all mb-0">
|
||||||
:model-value="form.birth_day || undefined"
|
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
|
||||||
label="تاریخ تولد"
|
</a-form-item>
|
||||||
@update:model-value="(v) => (form.birth_day = v ?? '')"
|
</div>
|
||||||
/>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-button type="primary" class="form-submit" @click="handleSubmit">ثبت اطلاعات</a-button>
|
<div class="pf-actions">
|
||||||
</a-card>
|
<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-form>
|
||||||
</a-spin>
|
</a-spin>
|
||||||
</template>
|
</template>
|
||||||
@@ -51,6 +58,13 @@
|
|||||||
import { onMounted, reactive, ref } from 'vue';
|
import { onMounted, reactive, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
import api from '../../../api/client';
|
import api from '../../../api/client';
|
||||||
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
||||||
import DateInput from '../../../components/input/DateInput.vue';
|
import DateInput from '../../../components/input/DateInput.vue';
|
||||||
@@ -83,9 +97,6 @@ async function handleSubmit() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...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(),
|
name: `${form.fname} ${form.lname}`.trim(),
|
||||||
mobile: form.phone,
|
mobile: form.phone,
|
||||||
};
|
};
|
||||||
@@ -114,9 +125,6 @@ onMounted(async () => {
|
|||||||
if (id) {
|
if (id) {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get(`customer/${id}`);
|
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, {
|
Object.assign(form, {
|
||||||
fname: data.fname ?? '',
|
fname: data.fname ?? '',
|
||||||
lname: data.lname ?? '',
|
lname: data.lname ?? '',
|
||||||
@@ -131,4 +139,9 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
</script>
|
</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>
|
</div>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
<!-- سینیها -->
|
<!-- سینیها — super admin only -->
|
||||||
<a-tab-pane key="collections" tab="سینی ها">
|
<a-tab-pane v-if="users.isSuperAdmin" key="collections" tab="سینی ها">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<a-input
|
<a-input
|
||||||
v-model:value="newCollection"
|
v-model:value="newCollection"
|
||||||
@@ -341,7 +341,7 @@ async function removeCollection(id: string) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
await Promise.all([loadBrands(), loadCollections()]);
|
await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
|
||||||
const [t, s] = await Promise.all([
|
const [t, s] = await Promise.all([
|
||||||
api.get('const/__list__tags'),
|
api.get('const/__list__tags'),
|
||||||
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
|
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
|
||||||
|
|||||||
@@ -54,8 +54,8 @@
|
|||||||
type="info"
|
type="info"
|
||||||
show-icon
|
show-icon
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
message="در زرین سند موقت همان لحظه روی ماندهی مشتری اثر دارد"
|
message="درایور زرین روی نصب واقعی تست نشده است"
|
||||||
description="برخلاف تهحساب. اگر سند در «ثبت سفارش» ثبت شود، مشتری در زرین پیش از تحویل بدهکار میشود؛ برای زرین «تحویل مسئول فروش» پیشنهاد میشود."
|
description="طبق تصمیم مالک، پیش از استفادهی واقعی یک دورهی آزمایشی با اسناد موقت لازم است (علامت مانده و فیلد هزینهی سنگ را بررسی کنید)."
|
||||||
/>
|
/>
|
||||||
<a-alert
|
<a-alert
|
||||||
v-if="active && overview?.config?.provider && (form.features.balances || form.features.products)"
|
v-if="active && overview?.config?.provider && (form.features.balances || form.features.products)"
|
||||||
|
|||||||
@@ -12,7 +12,12 @@
|
|||||||
<template #add>
|
<template #add>
|
||||||
<div class="list-page-header-row">
|
<div class="list-page-header-row">
|
||||||
<ListPageHeader rid="offers.create" add-label="افزودن تخفیف عمده" @add="openAdd('omde')" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -98,17 +103,17 @@ function openAdd(variant: 'omde' | 'tak') {
|
|||||||
modalOpen.value = true;
|
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>) {
|
function canEdit(record: Record<string, any>) {
|
||||||
return (
|
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)
|
(hasPermission('offers.list.edit') && record.type_customer === 2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function canRemove(record: Record<string, any>) {
|
function canRemove(record: Record<string, any>) {
|
||||||
return (
|
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)
|
(hasPermission('offers.list.remove') && record.type_customer === 2)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import { treeData, type PermissionNode } from './permissionConst';
|
import { treeData, type PermissionNode } from './permissionConst';
|
||||||
|
import { SUPER_ADMIN_RIDS } from '../../utils/superAdmin';
|
||||||
import { useUsersStore } from '../../stores/users';
|
import { useUsersStore } from '../../stores/users';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
import api from '../../api/client';
|
import api from '../../api/client';
|
||||||
@@ -108,12 +109,39 @@ const visibleUserTypes = computed(() => {
|
|||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** legacy drops the whole "custom" branch for anyone outside level 104 */
|
/** Drops every super-admin-only node, with its whole subtree. */
|
||||||
const visibleTree = computed<PermissionNode[]>(() =>
|
function withoutSuperAdminNodes(nodes: PermissionNode[]): PermissionNode[] {
|
||||||
auth.user?.ut === RESERVED_TYPE_ID
|
return nodes
|
||||||
? treeData
|
.filter((x) => !SUPER_ADMIN_RIDS.has(x.key))
|
||||||
: treeData.filter((x) => x.key !== 'custom'),
|
.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() {
|
async function getData() {
|
||||||
try {
|
try {
|
||||||
@@ -127,17 +155,17 @@ async function getData() {
|
|||||||
/** legacy selectChange(): id 84 means "creating a new level", so start empty */
|
/** legacy selectChange(): id 84 means "creating a new level", so start empty */
|
||||||
async function selectChange(id: string | undefined) {
|
async function selectChange(id: string | undefined) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
rid.value = [];
|
setLoadedRids([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (id === NEW_TYPE_ID) {
|
if (id === NEW_TYPE_ID) {
|
||||||
rid.value = [];
|
setLoadedRids([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get(`roles/${id}`);
|
const { data } = await api.get(`roles/${id}`);
|
||||||
// The server returns a flat array of rid strings.
|
// The server returns a flat array of rid strings.
|
||||||
rid.value = data;
|
setLoadedRids(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error('خطا در دریافت اطلاعات');
|
message.error('خطا در دریافت اطلاعات');
|
||||||
}
|
}
|
||||||
@@ -166,7 +194,10 @@ async function handleSubmit() {
|
|||||||
targetUt = String(data.id);
|
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();
|
await getData();
|
||||||
if (newUT.value) {
|
if (newUT.value) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||||
import { AdminBranchesService } from './admin-branches.service';
|
import { AdminBranchesService } from './admin-branches.service';
|
||||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||||
import { RequirePermission } from '../auth/guards';
|
import { RequirePermission, SuperAdminOnly } from '../auth/guards';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Legacy endpoints (BranchController + WorkshopController):
|
* Legacy endpoints (BranchController + WorkshopController):
|
||||||
@@ -12,6 +12,7 @@ import { RequirePermission } from '../auth/guards';
|
|||||||
* GET user/list -> the branch-manager picker
|
* GET user/list -> the branch-manager picker
|
||||||
*/
|
*/
|
||||||
@Controller('admin/branches')
|
@Controller('admin/branches')
|
||||||
|
@SuperAdminOnly()
|
||||||
export class AdminBranchesController {
|
export class AdminBranchesController {
|
||||||
constructor(private readonly branches: AdminBranchesService) {}
|
constructor(private readonly branches: AdminBranchesService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
import { AdminCategoriesService } from './admin-categories.service';
|
import { AdminCategoriesService } from './admin-categories.service';
|
||||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
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):
|
* Legacy endpoints (CategoryController + the collection screens):
|
||||||
@@ -37,6 +37,7 @@ export class AdminCategoriesController {
|
|||||||
// ── collections ("سینی") ─────────────────────────────────────────
|
// ── collections ("سینی") ─────────────────────────────────────────
|
||||||
@Post('collections/list')
|
@Post('collections/list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('collection.list')
|
@RequirePermission('collection.list')
|
||||||
listCollections(@Body() query: TableQueryDto) {
|
listCollections(@Body() query: TableQueryDto) {
|
||||||
return this.categories.listCollections(query);
|
return this.categories.listCollections(query);
|
||||||
@@ -44,12 +45,14 @@ export class AdminCategoriesController {
|
|||||||
|
|
||||||
@Post('collections')
|
@Post('collections')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('collection.create')
|
@RequirePermission('collection.create')
|
||||||
createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) {
|
createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) {
|
||||||
return this.categories.createCollection(body);
|
return this.categories.createCollection(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('collections/:id')
|
@Put('collections/:id')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('collection.list.edit')
|
@RequirePermission('collection.list.edit')
|
||||||
updateCollection(
|
updateCollection(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -59,6 +62,7 @@ export class AdminCategoriesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('collections/:id')
|
@Delete('collections/:id')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('collection.list.edit')
|
@RequirePermission('collection.list.edit')
|
||||||
removeCollection(@Param('id') id: string) {
|
removeCollection(@Param('id') id: string) {
|
||||||
return this.categories.removeCollection(id);
|
return this.categories.removeCollection(id);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||||
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
|
import { AdminCustomersService, type OmdeScope } from './admin-customers.service';
|
||||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
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';
|
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +31,7 @@ export class AdminCustomersController {
|
|||||||
|
|
||||||
@Post('banker/list')
|
@Post('banker/list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('persons.list.client.omde')
|
@RequirePermission('persons.list.client.omde')
|
||||||
listBanker(@Body() query: TableQueryDto) {
|
listBanker(@Body() query: TableQueryDto) {
|
||||||
return this.list(query, 'banker');
|
return this.list(query, 'banker');
|
||||||
@@ -38,6 +39,7 @@ export class AdminCustomersController {
|
|||||||
|
|
||||||
@Post('shopkeeper/list')
|
@Post('shopkeeper/list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('persons.list.client.omde')
|
@RequirePermission('persons.list.client.omde')
|
||||||
listShopkeeper(@Body() query: TableQueryDto) {
|
listShopkeeper(@Body() query: TableQueryDto) {
|
||||||
return this.list(query, 'shopkeeper');
|
return this.list(query, 'shopkeeper');
|
||||||
@@ -60,6 +62,7 @@ export class AdminCustomersController {
|
|||||||
/** Legacy POST single-customer — the retail customer list. */
|
/** Legacy POST single-customer — the retail customer list. */
|
||||||
@Post('single/list')
|
@Post('single/list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly({ allowBranch: true })
|
||||||
@RequirePermission('persons.list.client.single')
|
@RequirePermission('persons.list.client.single')
|
||||||
listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||||
return this.customers.listSingle(query, admin.branchId ?? undefined);
|
return this.customers.listSingle(query, admin.branchId ?? undefined);
|
||||||
@@ -68,6 +71,7 @@ export class AdminCustomersController {
|
|||||||
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
|
/** Legacy `POST single` — singleCustomer.vue, the retail customer form. */
|
||||||
@Post('single')
|
@Post('single')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly({ allowBranch: true })
|
||||||
@RequirePermission('persons.create')
|
@RequirePermission('persons.create')
|
||||||
createSingle(@Body() data: Record<string, unknown>) {
|
createSingle(@Body() data: Record<string, unknown>) {
|
||||||
return this.customers.createSingle(data);
|
return this.customers.createSingle(data);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||||
import { AdminDocumentsService } from './admin-documents.service';
|
import { AdminDocumentsService } from './admin-documents.service';
|
||||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
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';
|
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +46,7 @@ export class AdminDocumentsController {
|
|||||||
|
|
||||||
// ── wallet settings (views/manage/wallet.vue) ────────────────────
|
// ── wallet settings (views/manage/wallet.vue) ────────────────────
|
||||||
@Get('wallet/config')
|
@Get('wallet/config')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('config.wallet')
|
@RequirePermission('config.wallet')
|
||||||
getWalletConfig() {
|
getWalletConfig() {
|
||||||
return this.documents.getSetting('wallet.config');
|
return this.documents.getSetting('wallet.config');
|
||||||
@@ -53,12 +54,14 @@ export class AdminDocumentsController {
|
|||||||
|
|
||||||
@Post('wallet/config')
|
@Post('wallet/config')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('config.wallet')
|
@RequirePermission('config.wallet')
|
||||||
setWalletConfig(@Body() body: Record<string, unknown>) {
|
setWalletConfig(@Body() body: Record<string, unknown>) {
|
||||||
return this.documents.setSetting('wallet.config', body);
|
return this.documents.setSetting('wallet.config', body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('wallet/birthday/config')
|
@Get('wallet/birthday/config')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('config.wallet')
|
@RequirePermission('config.wallet')
|
||||||
getBirthdayConfig() {
|
getBirthdayConfig() {
|
||||||
return this.documents.getSetting('wallet.birthday');
|
return this.documents.getSetting('wallet.birthday');
|
||||||
@@ -66,6 +69,7 @@ export class AdminDocumentsController {
|
|||||||
|
|
||||||
@Post('wallet/birthday/config')
|
@Post('wallet/birthday/config')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('config.wallet')
|
@RequirePermission('config.wallet')
|
||||||
setBirthdayConfig(@Body() body: Record<string, unknown>) {
|
setBirthdayConfig(@Body() body: Record<string, unknown>) {
|
||||||
return this.documents.setSetting('wallet.birthday', body);
|
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 { AdminOrdersService } from './admin-orders.service';
|
||||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||||
import { UpdateOrderStatusDto } from './update-status.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';
|
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. */
|
/** Legacy POST single-order. POST, not GET, because XTable posts its filters. */
|
||||||
@Post('list')
|
@Post('list')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly({ allowBranch: true })
|
||||||
@RequirePermission('order.list')
|
@RequirePermission('order.list')
|
||||||
list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
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). */
|
/** Legacy POST /order from the admin order builder (views/order/create.vue). */
|
||||||
@Post('create')
|
@Post('create')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly({ allowBranch: true })
|
||||||
@RequirePermission('order.create')
|
@RequirePermission('order.create')
|
||||||
createFromAdmin(
|
createFromAdmin(
|
||||||
@Body()
|
@Body()
|
||||||
@@ -82,13 +84,17 @@ export class AdminOrdersController {
|
|||||||
|
|
||||||
/** Legacy GET order/type. */
|
/** Legacy GET order/type. */
|
||||||
@Get('type-options')
|
@Get('type-options')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('order.create')
|
@RequirePermission('order.create')
|
||||||
typeOptions() {
|
typeOptions() {
|
||||||
return this.orders.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')
|
@Get('status-options')
|
||||||
@RequirePermission('order.list')
|
@RequirePermission('order.list', 'order.omde', 'order.tamin')
|
||||||
statusOptions() {
|
statusOptions() {
|
||||||
return this.orders.statusOptions();
|
return this.orders.statusOptions();
|
||||||
}
|
}
|
||||||
@@ -96,13 +102,13 @@ export class AdminOrdersController {
|
|||||||
/** Legacy POST single/order-sts/edit. */
|
/** Legacy POST single/order-sts/edit. */
|
||||||
@Post('status')
|
@Post('status')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePermission('order.list')
|
@RequirePermission('order.list', 'order.omde', 'order.tamin')
|
||||||
setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||||
return this.orders.setStatus(dto, admin.id);
|
return this.orders.setStatus(dto, admin.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermission('order.list')
|
@RequirePermission('order.list', 'order.omde', 'order.tamin')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.orders.findOne(id);
|
return this.orders.findOne(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||||
import { AdminProductsService } from './admin-products.service';
|
import { AdminProductsService } from './admin-products.service';
|
||||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
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):
|
* Legacy endpoints (ProductController):
|
||||||
@@ -51,6 +53,7 @@ export class AdminProductsController {
|
|||||||
*/
|
*/
|
||||||
@Post('variants/create')
|
@Post('variants/create')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('order.create')
|
@RequirePermission('order.create')
|
||||||
createVariant(@Body() body: { product_id: string; title: string; grams: number }) {
|
createVariant(@Body() body: { product_id: string; title: string; grams: number }) {
|
||||||
return this.products.createVariant(body);
|
return this.products.createVariant(body);
|
||||||
@@ -78,6 +81,7 @@ export class AdminProductsController {
|
|||||||
|
|
||||||
|
|
||||||
@Get('collections')
|
@Get('collections')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('product.list')
|
@RequirePermission('product.list')
|
||||||
collections() {
|
collections() {
|
||||||
return this.products.collections();
|
return this.products.collections();
|
||||||
@@ -100,7 +104,9 @@ export class AdminProductsController {
|
|||||||
@Post('create')
|
@Post('create')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@RequirePermission('product.create')
|
@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);
|
return this.products.create(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||||
import { AdminBranchesService } from '../admin-branches/admin-branches.service';
|
import { AdminBranchesService } from '../admin-branches/admin-branches.service';
|
||||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
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:
|
* 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
|
* 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
|
* there is one owner of /admin/workshops, and the logic sits in
|
||||||
* AdminBranchesService alongside the branch code it mirrors.
|
* 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')
|
@Controller('admin/workshops')
|
||||||
export class AdminWorkshopsController {
|
export class AdminWorkshopsController {
|
||||||
@@ -27,24 +30,28 @@ export class AdminWorkshopsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('workshop.create')
|
@RequirePermission('workshop.create')
|
||||||
create(@Body() data: Record<string, unknown>) {
|
create(@Body() data: Record<string, unknown>) {
|
||||||
return this.branches.createWorkshop(data);
|
return this.branches.createWorkshop(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('active/:id')
|
@Get('active/:id')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('workshop.list')
|
@RequirePermission('workshop.list')
|
||||||
toggleActive(@Param('id') id: string) {
|
toggleActive(@Param('id') id: string) {
|
||||||
return this.branches.toggleWorkshopActive(id);
|
return this.branches.toggleWorkshopActive(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('workshop.list')
|
@RequirePermission('workshop.list')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.branches.findWorkshop(id);
|
return this.branches.findWorkshop(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@SuperAdminOnly()
|
||||||
@RequirePermission('workshop.list')
|
@RequirePermission('workshop.list')
|
||||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||||
return this.branches.updateWorkshop(id, data);
|
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 { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||||
import type { LoginDto } from './dto/login.dto';
|
import type { LoginDto } from './dto/login.dto';
|
||||||
import { compareLocalHash } from './password-hash';
|
import { compareLocalHash } from './password-hash';
|
||||||
|
import { SUPER_ADMIN_MOBILE, isSuperAdmin, withSuperAdminRids } from './super-admin';
|
||||||
|
|
||||||
export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash';
|
export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash';
|
||||||
|
|
||||||
// TEMPORARY hardcoded super-admin login (username = password). Remove before going live.
|
// TEMPORARY hardcoded super-admin login (username = password). Remove before going live.
|
||||||
const STATIC_ADMIN_MOBILE = '09128081392';
|
const STATIC_ADMIN_MOBILE = SUPER_ADMIN_MOBILE;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -99,8 +100,11 @@ export class AuthService {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
ut: user.userTypeId,
|
ut: user.userTypeId,
|
||||||
branch_id: user.branchId,
|
branch_id: user.branchId,
|
||||||
|
is_super_admin: isSuperAdmin(user.mobile),
|
||||||
api_token: apiToken,
|
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 } : {}),
|
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -222,8 +226,11 @@ export class AuthService {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
ut: user.userTypeId,
|
ut: user.userTypeId,
|
||||||
branch_id: user.branchId,
|
branch_id: user.branchId,
|
||||||
|
is_super_admin: isSuperAdmin(user.mobile),
|
||||||
api_token: apiToken,
|
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,
|
ut: user.userTypeId,
|
||||||
branch_id: user.branchId,
|
branch_id: user.branchId,
|
||||||
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
|
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 { AuthGuard } from '@nestjs/passport';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||||
|
import { isSuperAdmin } from './super-admin';
|
||||||
|
|
||||||
/** Marks a route as reachable without a token (login, health checks). */
|
/** Marks a route as reachable without a token (login, health checks). */
|
||||||
export const IS_PUBLIC_KEY = 'isPublic';
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
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 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`. */
|
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
|
||||||
export const CurrentAdmin = createParamDecorator(
|
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()
|
@Injectable()
|
||||||
export class PermissionsGuard implements CanActivate {
|
export class PermissionsGuard implements CanActivate {
|
||||||
constructor(private readonly reflector: Reflector) {}
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const requiredRid = this.reflector.getAllAndOverride<string>(REQUIRED_RID_KEY, [
|
const targets = [context.getHandler(), context.getClass()];
|
||||||
context.getHandler(),
|
const superAdminOnly = this.reflector.getAllAndOverride<SuperAdminOnlyOptions | undefined>(
|
||||||
context.getClass(),
|
SUPER_ADMIN_ONLY_KEY,
|
||||||
]);
|
targets,
|
||||||
if (!requiredRid) return true;
|
);
|
||||||
|
const requiredRids = this.reflector.getAllAndOverride<string[] | undefined>(REQUIRED_RID_KEY, targets);
|
||||||
|
if (!superAdminOnly && !requiredRids) return true;
|
||||||
|
|
||||||
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
|
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
|
||||||
if (!user) throw new ForbiddenException();
|
if (!user) throw new ForbiddenException();
|
||||||
|
|
||||||
if (!user.permissions.includes(requiredRid)) {
|
if (superAdminOnly && !isSuperAdmin(user.mobile)) {
|
||||||
throw new ForbiddenException(`Missing permission: ${requiredRid}`);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
import { envOr } from '../../common/env';
|
import { envOr } from '../../common/env';
|
||||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||||
import { expandWithParents } from './acl.constants';
|
import { expandWithParents } from './acl.constants';
|
||||||
|
import { withSuperAdminRids } from './super-admin';
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string;
|
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,
|
// 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.
|
// 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 {
|
return {
|
||||||
id: user.id,
|
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.
|
cart re-sync and the favourites screen are the two worth a manual pass.
|
||||||
|
|
||||||
_No data was deleted in this entry._
|
_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.
|
ثبت شده در `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
|
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
|
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.
|
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]**
|
## A. Admin panel — Dashboard · **[brief]**
|
||||||
|
|
||||||
**Location:** `apps/admin-panel/src/views/Dashboard.vue` (327 lines) — one file
|
**Location:** `apps/admin-panel/src/views/Dashboard.vue` — one file serving both
|
||||||
serving both `/dashboard/ecommerce` and `/dashboard/analytics`.
|
`/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).
|
**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
|
**Data:** a single `GET admin/dashboard/overview` on mount, then client-side sorts
|
||||||
(`reagent` desc by `grams_sell`; `lastOrder` desc by `w`).
|
(`reagent` desc by `grams_sell`; `lastOrder` desc by `w`).
|
||||||
@@ -31,18 +32,22 @@ Contents, in render order:
|
|||||||
4. 2 donuts — وضعیت سفارشات (8-entry colour array) · تفکیک عمده/تک
|
4. 2 donuts — وضعیت سفارشات (8-entry colour array) · تفکیک عمده/تک
|
||||||
5. Horizontal bar + an always-rendered agent table (کارشناس فروش / میزان فروش گرم / تعداد سفارش)
|
5. Horizontal bar + an always-rendered agent table (کارشناس فروش / میزان فروش گرم / تعداد سفارش)
|
||||||
6. Vertical bar «محصولات پرفروش» with an `a-empty` fallback
|
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`
|
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,
|
**Chrome (owner-approved 2026-10-14):** panel-unified look — plain layout background,
|
||||||
no export, no refresh** — that is the intended design, not a gap.
|
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
|
> Why this matters for parity: the reference's two dashboards
|
||||||
> (`DashboardECommerce.vue`, `DashboardAnalytics.vue`) were found in the earlier
|
> (`DashboardECommerce.vue`, `DashboardAnalytics.vue`) were found in the earlier
|
||||||
> port to be **mostly commented-out Vuexy demo markup**, with ApexCharts imported
|
> 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
|
> 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
|
> essentially nothing to port *into* this dashboard. Visual chrome may change only
|
||||||
> record that, and will not touch this file.
|
> 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