admin user and ui panel

This commit is contained in:
s.esmaeili
2026-09-21 08:32:31 +03:30
parent 79226c9f13
commit ef1d4638cc
341 changed files with 2270 additions and 657 deletions
+8 -2
View File
@@ -65,8 +65,14 @@ const EXACT: Record<string, string> = {
// ── single (retail) customers + comments (Batch 5) ────────────────
'single-customer': 'admin/customers-omde/single/list',
// legacy views/apps/customer/singleCustomer.vue posts the retail form here
single: 'admin/customers-omde/create',
// legacy views/apps/customer/singleCustomer.vue posts the retail form here.
// FIXED: this used to alias to the same admin/customers-omde/create as the
// wholesale form, which has no notion of "retail" and defaults kind to
// APP_SHOPKEEPER — every "تک" signup was silently created as a wholesale
// app-shopkeeper row, which is why "لیست مشتریان تک" never showed genuinely
// retail data even after that list gained a kind filter. Routed at its own
// endpoint now, which sets kind=RETAIL explicitly.
single: 'admin/customers-omde/single',
'customer/create': 'admin/customers-omde/create',
'query/reagent': 'admin/customers-omde/query/reagent',
'wallet/charge': 'admin/customers-omde/wallet/charge',
+54 -1
View File
@@ -39,8 +39,61 @@
font-weight: 400;
}
// IRANSans — same font family user-app-v2 ships (assets/fonts/iransans/*,
// copied byte-for-byte from apps/user-app-v2/src/assets/styles/fonts/iransans).
// Installed as the panel's primary typeface; 'p-yek' (above) stays loaded as
// the fallback in the body rule below rather than being removed, in case
// anything still depends on its metrics.
@font-face {
font-family: 'iransans';
font-style: normal;
font-weight: 200;
src: url('../fonts/iransans/IRANSansWeb_FaNum_UltraLight.woff2') format('woff2'),
url('../fonts/iransans/IRANSansWeb_FaNum_UltraLight.woff') format('woff');
}
@font-face {
font-family: 'iransans';
font-style: normal;
font-weight: 300;
src: url('../fonts/iransans/IRANSansWeb_FaNum_Light.woff2') format('woff2'),
url('../fonts/iransans/IRANSansWeb_FaNum_Light.woff') format('woff');
}
@font-face {
font-family: 'iransans';
font-style: normal;
font-weight: normal;
src: url('../fonts/iransans/IRANSansWeb_FaNum.woff2') format('woff2'),
url('../fonts/iransans/IRANSansWeb_FaNum.woff') format('woff');
}
@font-face {
font-family: 'iransans';
font-style: normal;
font-weight: 500;
src: url('../fonts/iransans/IRANSansWeb_FaNum_Medium.woff2') format('woff2'),
url('../fonts/iransans/IRANSansWeb_FaNum_Medium.woff') format('woff');
}
@font-face {
font-family: 'iransans';
font-style: normal;
font-weight: bold;
src: url('../fonts/iransans/IRANSansWeb_FaNum_Bold.woff2') format('woff2'),
url('../fonts/iransans/IRANSansWeb_FaNum_Bold.woff') format('woff');
}
@font-face {
font-family: 'iransans';
font-style: normal;
font-weight: 900;
src: url('../fonts/iransans/IRANSansWeb_FaNum_Black.woff2') format('woff2'),
url('../fonts/iransans/IRANSansWeb_FaNum_Black.woff') format('woff');
}
body, * {
font-family: p-yek, sans-serif, tahoma;
font-family: iransans, p-yek, sans-serif, tahoma;
letter-spacing: 0px !important;
font-size: inherit;
line-height: inherit;
@@ -21,11 +21,11 @@
z-index: 2500000;
}
/* Deliberate: keep the tab strip itself LTR-ordered while each tab's content
stays RTL, matching legacy's reading order for horizontal tab rows. */
.ant-tabs-nav {
direction: ltr;
}
.ant-tabs-nav .ant-tabs-tab {
direction: rtl;
}
/* REMOVED: an `.ant-tabs-nav { direction: ltr }` override used to live here,
forcing the tab strip itself to lay out left-to-right while each tab's
content stayed RTL. That fought ADV4's own RTL handling (ConfigProvider's
`direction="rtl"` in App.vue) instead of matching it: the first-defined tab
pane (e.g. "برند ها" in manage/Const.vue) rendered at the visual LEFT edge
instead of the RIGHT edge a Farsi reader expects tab order to start from.
ADV4 already reorders tabs correctly for RTL on its own — this override was
silently undoing that, so it is simply deleted rather than fixed. */
@@ -24,7 +24,7 @@
</div>
<div class="nav-right">
<a-dropdown>
<a-dropdown trigger="click" placement="bottomRight" :overlay-class-name="'user-dropdown-overlay'">
<span class="user-chip">
<a-avatar size="small" style="background-color: #1c2d56">
{{ initial }}
@@ -32,13 +32,39 @@
<span class="user-name">{{ displayName }}</span>
</span>
<template #overlay>
<a-menu>
<a-menu-item key="my-data" @click="router.push('/my-data')">
اطلاعات من
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" @click="onLogout">خروج</a-menu-item>
</a-menu>
<div class="user-card">
<div class="user-card__head">
<a-avatar size="large" style="background-color: #1c2d56">
{{ initial }}
</a-avatar>
<div class="user-card__id">
<span class="user-card__name">{{ displayName }}</span>
<span class="user-card__mobile">{{ auth.user?.mobile }}</span>
</div>
</div>
<a-divider class="user-card__divider" />
<a-menu class="user-card__menu">
<a-menu-item key="my-data" @click="router.push('/my-data')">
<UserOutlined />
اطلاعات من
</a-menu-item>
<a-menu-item v-if="canSeePermissions" key="permission" @click="router.push('/permission')">
<SafetyCertificateOutlined />
دسترسی ها
</a-menu-item>
</a-menu>
<a-divider class="user-card__divider" />
<a-menu class="user-card__menu">
<a-menu-item key="logout" @click="onLogout">
<LogoutOutlined />
خروج
</a-menu-item>
</a-menu>
</div>
</template>
</a-dropdown>
</div>
@@ -48,8 +74,15 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue';
import {
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SafetyCertificateOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import { useAuthStore } from '../../stores/auth';
import { useUsersStore } from '../../stores/users';
interface Crumb {
title: string;
@@ -63,11 +96,13 @@ defineEmits<{ (e: 'toggle-sidebar'): void }>();
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const users = useUsersStore();
const pageTitle = computed(() => (route.meta?.pageTitle as string) ?? '');
const breadcrumb = computed(() => route.meta?.breadcrumb as Crumb[] | undefined);
const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'کاربر');
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
const canSeePermissions = computed(() => Boolean(users.hasPermission('user.permission')));
function onLogout() {
auth.logout();
@@ -105,3 +140,84 @@ function onLogout() {
cursor: pointer;
}
</style>
<!--
Unscoped: ant-design-vue's <a-dropdown> teleports its #overlay to <body>, so
a `scoped` block (which relies on a component-instance data attribute) never
matches it. Reach is contained instead by :overlay-class-name="'user-dropdown-overlay'"
above, so every selector below is namespaced under that one class.
-->
<style>
.user-dropdown-overlay .ant-dropdown-menu {
padding: 0;
box-shadow: none;
}
.user-dropdown-overlay .user-card {
width: 220px;
padding: 12px;
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 28px -8px rgba(28, 45, 86, 0.28);
}
.user-dropdown-overlay .user-card__head {
display: flex;
align-items: center;
gap: 10px;
padding: 2px 4px 8px;
}
.user-dropdown-overlay .user-card__id {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-dropdown-overlay .user-card__name {
font-size: 14px;
font-weight: 600;
color: #1c2d56;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-dropdown-overlay .user-card__mobile {
font-size: 12px;
color: #8a909c;
direction: ltr;
text-align: right;
}
.user-dropdown-overlay .user-card__divider {
margin: 4px 0;
}
.user-dropdown-overlay .user-card__menu.ant-menu {
border-inline-end: none;
background: transparent;
}
.user-dropdown-overlay .user-card__menu .ant-menu-item {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
line-height: 34px;
margin: 0;
padding-inline: 6px !important;
border-radius: 6px;
width: 100%;
}
.user-dropdown-overlay .user-card__menu .ant-menu-item:hover {
background: #f4f5f6;
color: #1c2d56;
}
.user-dropdown-overlay .user-card__menu .anticon {
font-size: 15px;
color: #8a909c;
}
</style>
+39 -11
View File
@@ -12,10 +12,19 @@
// A "header" node is a section divider carrying `items`, not `submenu`.
//
// Legacy quirks preserved deliberately (see docs/ADMIN-PORT-PLAN.md):
// - two nodes have their permissionId COMMENTED OUT (banker-list, shopkeeper-list),
// so they are visible to everyone
// - "newsletters.newsletters" and "wallet.list" never match any granted rid (#7, #8)
// - several nodes repeat the same permissionId or reuse another view's `name`
//
// Two further legacy quirks were fixed rather than preserved, since both were
// bugs (not intentional behaviour) and each has since caused real confusion:
// - banker-list / shopkeeper-list had their permissionId commented out, so
// the items showed for everyone even though the backing endpoints
// (admin/customers-omde/banker|shopkeeper/list) require
// persons.list.client.omde and 403'd on an unauthorized click. Filled in.
// - "newsletters.newsletters" never matched any rid in ALL_RIDS (the real
// one is "newsletters"), so the item was invisible to every user,
// including super-admin. Corrected to "newsletters".
// "wallet.list" is a real, seeded rid (see acl.constants.ts) and was never
// actually broken — the old note above was itself mistaken.
import { isBranch } from '../utils';
export interface NavMenuNode {
@@ -57,9 +66,16 @@ export const adminMenu: NavMenuNode[] = [
i18n: 'اشخاص',
permissionId:"persons",
submenu: [
// 'اضافه کردن شخص' removed: adding a wholesale customer is now the
// add button on the list page itself (OmdeList), so the nav entry
// duplicated it. The /customer/add route still works.
// Restored per request: the add button on OmdeList only covers
// wholesale customers, so removing this entry left no nav path to
// the general "add person" form.
{
url: '/customer/add',
name: 'AddCustomer',
slug: 'add-customer',
i18n: 'اضافه کردن شخص',
permissionId:"persons.create"
},
{
url: '/customer/reagent-list',
name: 'List',
@@ -99,8 +115,13 @@ export const adminMenu: NavMenuNode[] = [
url: '/customer/banker-list',
name: 'List',
slug: 'banker-list',
i18n: 'لیست بنکداران'
// permissionId:"persons.list.client."
i18n: 'لیست بنکداران',
// Legacy left this rid commented out, so the item showed for
// everyone regardless of permission — a bug, not a feature: the
// API route it points at (admin/customers-omde/banker/list) is
// gated on exactly this rid, so an unauthorized click 403'd.
// Filled in to match what the server actually enforces.
permissionId:"persons.list.client.omde"
},
{
// legacy navMenuItems.js:90 spelled this with a capital S while the route
@@ -109,8 +130,10 @@ export const adminMenu: NavMenuNode[] = [
url: '/customer/shopkeeper-list',
name: 'List',
slug: 'shopkeeper-list',
i18n: 'لیست مغازه داران حضوری'
// permissionId:"persons.list.client."
i18n: 'لیست مغازه داران حضوری',
// Same fix as banker-list above — the backing endpoint
// (admin/customers-omde/shopkeeper/list) requires this rid.
permissionId:"persons.list.client.omde"
},
]
},
@@ -398,7 +421,12 @@ export const adminMenu: NavMenuNode[] = [
name: 'newsletters',
icon: 'FileTextIcon',
i18n: 'خبرنامه',
permissionId:"newsletters.newsletters",
// Legacy typo'd this as "newsletters.newsletters", a rid that never
// exists in ALL_RIDS — so the item was permanently invisible to
// everyone, including super-admin. The route's own guard
// (POST newsletter/list, admin-misc.controller.ts) requires the
// plain "newsletters" rid; matched here.
permissionId:"newsletters",
},
]
},
+25 -1
View File
@@ -26,7 +26,7 @@ export const adminRoute: RouteRecordRaw[] = [
{
path: '/',
name: 'dashboard',
redirect: '/my-data'
redirect: '/dashboard/ecommerce'
},
{
// Legacy had two dashboards ("آمار تک فروشی" at /dashboard/analytics and
@@ -470,6 +470,30 @@ export const adminRoute: RouteRecordRaw[] = [
no_scroll: true
}
},
{
// Add uses the same form as edit, with no :id — see ReagentCustomer.vue.
path: '/reagent/add',
name: 'reagent-add',
component: () => import('../views/apps/customer/EditReagent.vue'),
meta: {
rule: 'editor',
role: 'persons.create',
parent: 'product',
no_scroll: true
}
},
{
// A sales agent's own detail screen — see AgentDetail.vue's header comment.
path: '/reagent/view/:id',
name: 'reagent-view',
component: () => import('../views/customer/reagentList/AgentDetail.vue'),
meta: {
rule: 'editor',
role: 'persons.list.sellers.view',
parent: 'product',
no_scroll: true
}
},
{
path: '/bulkCustomer/edit/:id',
name: 'bulkCustomer-edit',
+3
View File
@@ -117,6 +117,9 @@ router.beforeEach(async (to) => {
if (token) {
const roles = await getPermission();
if (roles && to.meta && to.meta.role && !roles.includes(to.meta.role as string)) {
if (to.name === 'dashboard-ecommerce' || to.name === 'dashboard-analytics') {
return { name: 'my-data' };
}
return { name: 'page-not-authorized' };
}
return true;
@@ -2,10 +2,16 @@
<!--
Ported from legacy henza_admin/src/views/apps/customer/AddCustomer.vue.
Legacy's file is 278 lines but almost all of it is COMMENTED OUT: the live
markup is a single tab wrapping <bulk-customer/>. The "مشتری تک" tab and the
whole inline form beneath it are commented out in the original and stay
absent here rather than being revived.
Three tabs, matching legacy's live markup one for one. An earlier revision
of this port kept only the first: legacy's file is 278 lines and most of it
IS commented out, but what's commented is an old inline "مشتری تک" form
that ReagentCustomer/singleCustomer later replaced — not the tabs
themselves. Dropping them left the page with a lone tab strip and, for
anyone without the عمده permission, nothing at all.
Permission gates are legacy's, including the third tab's: legacy commented
out its `persons.create.customer.tak` check (AddCustomer.vue:142), so the
retail tab shows for anyone who can open the page.
-->
<div id="page-user-view">
<div id="user-data">
@@ -14,6 +20,14 @@
<a-tab-pane v-if="hasPermission('persons.create.client')" key="omde" tab="مشتری عمده">
<BulkCustomer />
</a-tab-pane>
<a-tab-pane v-if="hasPermission('persons.create.seller')" key="reagent" tab="کارشناس فروش">
<ReagentCustomer />
</a-tab-pane>
<a-tab-pane key="tak" tab="مشتری تک">
<SingleCustomer />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
@@ -22,6 +36,8 @@
<script setup lang="ts">
import BulkCustomer from './bulkCustomer.vue';
import ReagentCustomer from './ReagentCustomer.vue';
import SingleCustomer from './singleCustomer.vue';
import { useUsersStore } from '../../../stores/users';
const users = useUsersStore();
@@ -1,17 +1,21 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/ReagentCustomer.vue —
the sales-expert ("کارشناس فروش") form. Structurally the wholesale customer
form plus a نوع کاربری selector and a commission percentage.
the sales-expert ("کارشناس فروش") form. Legacy stored a sales agent as a
Customer row (type=1) in the same `customers` table as every other
wholesale kind, so this form used to share the wholesale customer's
fields and post to the Customer endpoints.
The avatar uploader (عکس پروفایل) is live: legacy's MyUpload posted to
`file/create` and kept the returned `id` in `image_id`, reading it back as a
nested `image: { id, image }`. Both halves of that contract are preserved.
LEGACY BUG, not reproduced: legacy's load path dereferenced `data.image.id`
unguarded (ReagentCustomer.vue:280), so opening an agent with no avatar threw
— swallowed by its own try/catch, leaving the form half-filled. The read is
guarded here.
FIXED: a sales agent is now an AdminUser (role=SALES_EXPERT) — see
admin-users.service.ts and admin-customers.service.ts's listReagents().
Posting to the Customer endpoints (as this form used to) created a
Customer row that listReagents() never reads and every OTHER Customer
list (including "لیست مشتریان تک", which had no kind filter at all)
happily returned instead. This form now posts to admin/users, and the
fields that only ever existed on Customer (کد ملی، نوع کاربری، شهر، سقف
اعتبار) are dropped — AdminUser has no matching column for them; city
was only ever used here to prefill the commission percentage, which the
operator can still type in directly.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
@@ -20,7 +24,7 @@
<a-form-item label="عکس پروفایل" class="avatar-cell">
<ImageUploadInput
v-model="form.image_url"
purpose="customer.avatar"
purpose="reagent.avatar"
placeholder="عکس"
@ok="onAvatar"
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
@@ -36,9 +40,6 @@
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
@@ -46,16 +47,9 @@
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-form-item label="رمز عبور" :validate-status="err.password ? 'error' : ''" :help="err.password">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="نوع کاربری">
<a-select v-model:value="form.type">
<a-select-option v-for="t in userTypes" :key="t.id" :value="t.id">
{{ t.title }}
</a-select-option>
</a-select>
</a-form-item>
</div>
</a-card>
@@ -67,41 +61,19 @@
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="تلفن">
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="درصد روی محصول">
<a-form-item label="درصد کمیسیون">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</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">
<div class="grid-2">
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="onCityChange"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</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" @click="handleSubmit">ثبت اطلاعات</a-button>
@@ -121,24 +93,17 @@ const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const cities = ref<{ id: string; title: string }[]>([]);
const userTypes = ref<{ id: number; title: string }[]>([]);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
ncode: '',
password: '',
mobile: '',
type: undefined,
mcode: '',
store_name: '',
phone: '',
pasazh_name: '',
city_id: undefined,
address: '',
percent: undefined,
gram_level: undefined,
// legacy kept the FK in `image_id`; `image_url` only drives the thumbnail.
image_id: '',
image_url: null,
@@ -164,47 +129,31 @@ function onAvatar(file: Record<string, unknown>) {
}
const REQUIRED = 'لطفا مقدار را وارد کنید';
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
/**
* legacy: picking a city pre-fills the agent's commission from that city's
* percent_aux (the sales-expert rate, as opposed to the wholesale rate).
*/
async function onCityChange(cityId: string) {
if (!cityId) return;
try {
const { data } = await api.get(`cities/get-percent-aux/${cityId}`);
if (data !== null && data !== undefined) form.percent = Number(data);
} catch (e) {
console.log(e);
}
}
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.fname) err.fname = REQUIRED;
if (!form.lname) err.lname = REQUIRED;
if (!form.mobile) err.mobile = REQUIRED;
if (!id && !form.password) err.password = REQUIRED;
if (Object.keys(err).length) return;
try {
loading.value = true;
// `image_url` is presentation only — the server stores the FK (`image_id`).
const { image_url: _unusedAvatarUrl, ...allFields } = form;
// On EDIT send only touched fields — see bulkCustomer.vue for why.
const fields = id ? diffFromInitial(['image_url']) : allFields;
// On EDIT send only touched fields, and never re-send a blank password
// (blank means "leave unchanged" on the admin-users endpoint).
const fields = id ? diffFromInitial(['image_url', 'password']) : allFields;
if (id && form.password) fields.password = form.password;
const payload = {
...fields,
id: id ?? null,
name: `${form.fname} ${form.lname}`.trim(),
national_code: form.ncode,
role: 'SALES_EXPERT',
};
if (id) {
await api.put(`customer/${id}`, payload);
await api.put(`users/${id}`, payload);
} else {
await api.post('customer/create', payload);
await api.post('users', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.go(-1);
@@ -217,36 +166,21 @@ async function handleSubmit() {
}
onMounted(async () => {
try {
const [c, t] = await Promise.all([api.post('cities/list'), api.get('const/__list__ut')]);
cities.value = c.data.rows ?? [];
userTypes.value = t.data ?? [];
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
const { data } = await api.get(`admin/users/${id}`);
Object.assign(form, {
fname: data.fname ?? '',
lname: data.lname ?? '',
ncode: data.national_code ?? '',
mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined,
address: data.address ?? '',
percent: data.percent === null ? undefined : Number(data.percent),
gram_level: data.gram_level === null ? undefined : Number(data.gram_level),
// guarded, unlike legacy — an agent with no avatar must not throw
image_id: data.image?.id ?? '',
image_url: data.image?.image ?? null,
// Previously omitted — same silent-overwrite bug as bulkCustomer.vue:
// the form spread these back as '' and wiped good data on every save.
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
phone: data.phone ?? '',
type: data.type ?? undefined,
});
// Baseline for the dirty-diff in handleSubmit.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
@@ -258,7 +192,7 @@ onMounted(async () => {
</script>
<style scoped>
/* .grid-2/.grid-4 now come from the global utilities.less (dedup) */
/* .grid-4 now comes from the global utilities.less (dedup) */
/* Legacy put the avatar in the first grid cell spanning two rows
(md:row-span-2), with the uploader centred inside it. */
@@ -115,6 +115,28 @@
</a-form-item>
</div>
</a-card>
<a-card title="مدارک" class="mb-base">
<div class="grid-3">
<a-form-item label="عکس کارت ملی">
<ImageUploadInput
v-model="form.national_id_image"
purpose="customer.nationalId"
placeholder="عکس"
/>
</a-form-item>
<a-form-item label="عکس جواز کسب">
<ImageUploadInput
v-model="form.business_license"
purpose="customer.businessLicense"
placeholder="عکس"
/>
</a-form-item>
<a-form-item label="عکس سردر مغازه">
<ImageUploadInput v-model="form.shop_image" purpose="customer.shop" placeholder="عکس" />
</a-form-item>
</div>
</a-card>
</BaseFormModal>
<!-- route-based fallback for /customer/add and /bulkCustomer/edit/:id -->
@@ -207,6 +229,32 @@
</div>
</a-card>
<a-card title="مدارک" class="mb-base">
<div class="grid-3">
<a-form-item label="عکس کارت ملی">
<ImageUploadInput
v-model="form.national_id_image"
purpose="customer.nationalId"
placeholder="عکس"
/>
</a-form-item>
<a-form-item label="عکس جواز کسب">
<ImageUploadInput
v-model="form.business_license"
purpose="customer.businessLicense"
placeholder="عکس"
/>
</a-form-item>
<a-form-item label="عکس سردر مغازه">
<ImageUploadInput
v-model="form.shop_image"
purpose="customer.shop"
placeholder="عکس"
/>
</a-form-item>
</div>
</a-card>
<div>
<a-button type="primary" class="mr-4" @click="handleSubmitOmde">ثبت اطلاعات</a-button>
</div>
@@ -219,18 +267,32 @@ import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import BaseFormModal from '../../../components/common/BaseFormModal.vue';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
import api from '../../../api/client';
const props = defineProps<{
open?: boolean;
id?: string;
/**
* Set by callers that render this inside a modal (OmdeCustomerList); the
* route-based pages (/customer/add, /bulkCustomer/edit/:id) leave it off
* and get the full-page form instead.
*
* This exists as its own flag because `open` alone could not carry the
* distinction: Vue applies Boolean casting to a prop declared Boolean, so
* an absent `open` arrives as `false`, never `undefined`. Deriving the mode
* from `props.open !== undefined` was therefore always true, and the two
* route pages rendered a CLOSED modal — a blank page showing only the tab
* strip.
*/
asModal?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'success'): void;
}>();
const asModal = props.open !== undefined;
const asModal = props.asModal === true;
const route = useRoute();
const router = useRouter();
@@ -258,15 +320,27 @@ const form = reactive<Record<string, any>>({
reagent_id: undefined,
percent: 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: '',
business_license: '',
shop_image: '',
});
/**
* Legacy bulkCustomer.vue:239-244, verbatim. The server's canonical map
* (Customer::$TYPES) also has 5 => سرپرست کارگاه, which this form never offered;
* that omission is preserved.
* Legacy bulkCustomer.vue:239-244 offered a 4th option here ("کارشناس فروش",
* id 1) because legacy stored a sales agent as a Customer row too. The
* redesigned system models a sales agent as an AdminUser (role SALES_EXPERT)
* instead — created from "افزودن مدیر" or the "کارشناس فروش" tab next to this
* one (ReagentCustomer.vue), never from this form. Selecting it here used to
* create a Customer row that no screen correctly read back (see the bug
* report that led to this fix), so it is removed; the server also now
* rejects type=1 on this endpoint as defense-in-depth. The server's canonical
* map (Customer::$TYPES) also has 5 => سرپرست کارگاه, which this form never
* offered; that omission is preserved.
*/
const userTypes = [
{ id: 1, title: 'کارشناس فروش' },
{ id: 2, title: 'مغازه دار اپلیکیشن' },
{ id: 3, title: 'مغازه دار حضوری' },
{ id: 4, title: 'بنکدار' },
@@ -379,6 +453,11 @@ onMounted(async () => {
mcode: data.mcode ?? '',
phone: data.phone ?? '',
type: data.type ?? undefined,
// Same reason as the five above: omitted here, they would be sent back
// as '' and wipe the stored photos on every edit.
national_id_image: data.national_id_image ?? '',
business_license: data.business_license ?? '',
shop_image: data.shop_image ?? '',
});
// Baseline for the dirty-diff in handleSubmitOmde.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
@@ -170,7 +170,11 @@
row-key="__key"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'op'">
<template v-if="column.dataIndex === 'image'">
<img v-if="record.image_url" :src="record.image_url" class="variant-thumb" alt="" />
<span v-else class="variant-thumb variant-thumb--empty">—</span>
</template>
<template v-else-if="column.dataIndex === 'op'">
<a-tooltip v-if="hasPermission('product.list.variants.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="showModal(record, index)" />
</a-tooltip>
@@ -213,6 +217,45 @@
<a-form-item label="تعداد">
<a-input-number v-model:value="variantForm.qty" class="w-full" />
</a-form-item>
<!--
تصویر تنوع — picked from THIS product's already-saved gallery, never
uploaded again: the variant points at the same ProductImage row the
gallery shows, so one photo can back several weights.
On create the gallery is empty because the uploaded files have no
ProductImage rows (and no ids) until the product is saved once, so the
picker explains that instead of offering nothing.
-->
<a-form-item label="تصویر تنوع">
<div v-if="gallery.length" class="variant-picker">
<button
type="button"
class="variant-picker__item"
:class="{ 'is-on': !variantForm.image_id }"
@click="pickVariantImage(null)"
>
<span class="variant-picker__none">بدون تصویر</span>
</button>
<button
v-for="img in gallery"
:key="img.id"
type="button"
class="variant-picker__item"
:class="{ 'is-on': variantForm.image_id === img.id }"
@click="pickVariantImage(img)"
>
<img :src="img.url" alt="" />
</button>
</div>
<a-alert
v-else
type="info"
show-icon
:message="id ? 'برای این محصول تصویری ثبت نشده است.' : 'ابتدا محصول را ذخیره کنید تا بتوانید تصویر تنوع را انتخاب کنید.'"
/>
</a-form-item>
<a-form-item label="فعال">
<a-switch v-model:checked="variantForm.is_active" />
</a-form-item>
@@ -269,6 +312,17 @@ const err = reactive<Record<string, string>>({});
const gallerySite = ref<string[]>([]);
const galleryMobile = ref<string[]>([]);
// ── the product's saved gallery, WITH row ids — the source for the variant
// image picker. Populated only when editing, because on create the uploaded
// files have no ProductImage rows (and therefore no ids) until the first save.
interface GalleryImage {
id: string;
url: string;
role: string;
sortOrder: number;
}
const gallery = ref<GalleryImage[]>([]);
// ── variants (edited in memory, saved with the product)
interface VariantRow {
__key: string;
@@ -279,6 +333,11 @@ interface VariantRow {
qty?: number;
is_active: boolean;
is_pre_order: boolean;
/// The picked gallery image. `image_id` is what is posted; `image_url` is
/// carried alongside purely so the table can draw a thumbnail without
/// looking it up.
image_id?: string | null;
image_url?: string | null;
}
const variants = ref<VariantRow[]>([]);
@@ -292,9 +351,17 @@ const variantForm = reactive<VariantRow>({
qty: 0,
is_active: true,
is_pre_order: false,
image_id: null,
image_url: null,
});
function pickVariantImage(img: GalleryImage | null) {
variantForm.image_id = img ? img.id : null;
variantForm.image_url = img ? img.url : null;
}
const variantColumns = [
{ title: 'تصویر', dataIndex: 'image' },
{ title: 'عنوان', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams' },
{ title: 'اجرت درصدی', dataIndex: 'percentage_pay' },
@@ -319,6 +386,8 @@ function showModal(record?: VariantRow, index?: number) {
qty: 0,
is_active: true,
is_pre_order: false,
image_id: null,
image_url: null,
});
}
variantModal.value = true;
@@ -361,6 +430,7 @@ async function handleSubmit() {
qty: v.qty,
is_active: v.is_active,
is_pre_order: v.is_pre_order,
image_id: v.image_id ?? null,
})),
};
if (id) {
@@ -414,6 +484,7 @@ onMounted(async () => {
});
gallerySite.value = data.images_gallery ?? [];
galleryMobile.value = data.images_mobile ?? [];
gallery.value = data.gallery ?? [];
variants.value = (data.variants_all ?? []).map((v: any, i: number) => ({
__key: v.id ?? `v-${i}`,
id: v.id,
@@ -423,6 +494,8 @@ onMounted(async () => {
qty: v.qty,
is_active: v.is_active,
is_pre_order: v.is_pre_order,
image_id: v.image_id ?? null,
image_url: v.image?.url ?? null,
}));
} catch (e) {
console.log(e);
@@ -442,6 +515,57 @@ onMounted(async () => {
flex-wrap: wrap;
gap: 8px;
}
/* ── variant image: table thumbnail + modal picker ── */
.variant-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
display: inline-block;
}
.variant-thumb--empty {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed #d9d9d9;
color: #bfbfbf;
}
.variant-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.variant-picker__item {
width: 64px;
height: 64px;
padding: 2px;
border: 2px solid #d9d9d9;
border-radius: 6px;
background: #fff;
cursor: pointer;
overflow: hidden;
}
.variant-picker__item img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
display: block;
}
.variant-picker__item.is-on {
border-color: #1890ff;
}
.variant-picker__none {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 11px;
color: #8c8c8c;
line-height: 1.3;
}
.flex-1 {
flex: 1;
}
+2 -2
View File
@@ -104,8 +104,8 @@ async function loginJWT() {
// inactive in this port; the field is omitted rather than sent as null.
await auth.login({ phone: mobile.value, password: password.value });
// legacy did a hard `window.location.href = '/admin/my-data'`; a router push
// reaches the same route without discarding the freshly-populated store.
await router.push('/my-data');
// reaches the equivalent route without discarding the freshly-populated store.
await router.push('/dashboard/ecommerce');
} catch (e) {
console.log(e);
message.error('خطا در ورود');
@@ -213,6 +213,7 @@
<BulkCustomer
v-if="showAddButton"
:key="editingId ?? 'new'"
as-modal
:open="modalOpen"
:id="editingId"
@update:open="modalOpen = $event"
@@ -0,0 +1,126 @@
<template>
<!--
The sales agent's own detail screen ("مشاهده اطلاعات" on ReagentList.vue).
A sales agent is an AdminUser now (role=SALES_EXPERT), not a Customer —
see admin-users.service.ts and admin-customers.service.ts's
listReagents(). Reusing CustomerDetail.vue here would 404 (it reads
customer/{id}, which no longer exists for an agent's id) and its "سند های
مالی" tab has no equivalent for an agent, who carries no debt of their
own. This shows the agent's profile plus the customers they referred and
those customers' orders instead.
-->
<a-spin :spinning="loading">
<a-card title="مشاهده اطلاعات کارشناس فروش">
<a-descriptions bordered size="small" :column="3">
<a-descriptions-item label="نام">{{ agent?.name ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="موبایل">{{ agent?.mobile ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="شعبه">{{ agent?.branch?.name ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="درصد کمیسیون">{{ agent?.percent ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="وضعیت">
<a-tag :color="agent?.active === 1 ? 'green' : 'red'">
{{ agent?.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="میزان فروش (گرم)">
{{ agent?.grams_sell ?? 0 }}
</a-descriptions-item>
</a-descriptions>
<div class="stats mt-4">
<a-tag color="blue">مشتریان معرفی‌شده: {{ agent?.referred_customers?.length ?? 0 }}</a-tag>
<a-tag color="green">سفارش‌های ثبت‌شده: {{ agent?.orders?.length ?? 0 }}</a-tag>
</div>
</a-card>
<a-tabs class="mt-4" default-active-key="referred">
<a-tab-pane key="referred" tab="مشتریان معرفی‌شده">
<a-table
size="small"
:data-source="agent?.referred_customers"
:columns="referredColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'active'">
<a-tag :color="record.active === 1 ? 'green' : 'red'">
{{ record.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'city'">
{{ record.city?.title ?? '-' }}
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="orders" tab="سفارش‌های مشتریان">
<a-table
size="small"
:data-source="agent?.orders"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { jdate } from '../../../utils/filters';
import api from '../../../api/client';
const route = useRoute();
const id = route.params.id as string;
const loading = ref(false);
const agent = ref<Record<string, any> | undefined>(undefined);
const referredColumns = [
{ title: 'نام', dataIndex: 'name' },
{ title: 'موبایل', dataIndex: 'mobile' },
{ title: 'شهر', dataIndex: 'city' },
{ title: 'نوع', dataIndex: 'type_str' },
{ title: 'وضعیت', dataIndex: 'active' },
{ title: 'تاریخ ثبت', dataIndex: 'caj' },
];
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'وضعیت', dataIndex: 'status' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
onMounted(async () => {
try {
loading.value = true;
const { data } = await api.get(`admin/customers-omde/reagent/${id}`);
agent.value = data;
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.stats {
display: flex;
gap: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -2,12 +2,31 @@
<!--
Ported from legacy henza_admin/src/views/customer/reagentList/ReagentList.vue.
Unlike the five wholesale lists this one uses inline TooltipBtn icons rather
than a dropdown menu. Farsi copy and permission gates preserved verbatim,
including legacy's typo in the "فعال سازی سفارش سفارش" tooltip.
than a dropdown menu. Farsi copy and permission gates preserved verbatim.
FIXED: a sales agent is an AdminUser now (role SALES_EXPERT), not a
Customer — see admin-users.service.ts / listReagents()'s header comment.
"مشاهده اطلاعات"/"مشاهده سفارش"/"سند های مالی" used to route to
/bulkCustomer/view/{id}, which reads customer/{id} and 404s for an
AdminUser id; both now open AgentDetail.vue instead. توقف/فعال‌سازی
"سفارش" (the `sts` switch) had no AdminUser equivalent — legacy's
Customer.sts was an order-placement suspension flag an AdminUser simply
doesn't have — so that action is dropped; only the account active/inactive
switch remains, now posted to admin/users instead of the Customer
endpoints it used to hit (which would 404/target the wrong row entirely).
-->
<div id="page-user-list">
<a-card title="لیست کارشناسان فروش">
<XTable ref="tbl" :model="model">
<template #add>
<ListPageHeader
v-if="hasPermission('persons.create.seller')"
rid="persons.create.seller"
add-label="افزودن کارشناس فروش"
@add="toAdd"
/>
</template>
<template #city="{ record }">
<div v-if="record.city && record.city.title">{{ record.city.title }}</div>
<div v-else>-</div>
@@ -41,41 +60,6 @@
title="مشاهده اطلاعات"
@click="toView(record.id)"
/>
<TooltipBtn
v-if="hasPermission('persons.list.sellers.view.orders')"
icon-class="w-5 h-5 text-primary"
class="ml-3"
icon="ListIcon"
title="مشاهده سفارش"
@click="viewOrder(record)"
/>
<TooltipBtn
v-if="hasPermission('persons.list.sellers.view.documents')"
icon-class="w-5 h-5 text-success"
class="ml-3"
icon="FileTextIcon"
title="سند های مالی"
@click="viewCash(record)"
/>
<TooltipBtn
v-if="record.sts == 1 && hasPermission('persons.list.sellers.unable.order')"
icon-class="w-5 h-5 text-danger"
class="ml-3"
:icon="getStsIcon(record)"
title="توقف سفارش"
pop-title="آیا مطمئن هستید؟"
@click="toggleSts(record)"
/>
<TooltipBtn
v-if="record.sts !== 1 && hasPermission('persons.list.sellers.able.order')"
icon-class="w-5 h-5 text-success"
class="ml-3"
:icon="getStsIcon(record)"
title="فعال سازی سفارش سفارش"
pop-title="آیا مطمئن هستید؟"
@click="toggleSts(record)"
/>
<TooltipBtn
v-if="record.active == 1 && hasPermission('persons.list.sellers.unable')"
@@ -108,6 +92,7 @@ import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import XTable from '../../../components/x-table/XTable.vue';
import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
import ListPageHeader from '../../../components/common/ListPageHeader.vue';
import { buildReagentListModel } from './ReagentListModel';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
@@ -119,13 +104,9 @@ const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildReagentListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
function getStsIcon(row: Record<string, any>) {
return row.sts === 1 ? 'CheckIcon' : 'XIcon';
}
async function toggleActive(record: Record<string, any>) {
try {
await api.post('customer/toggle-active', { id: record.id });
await api.put(`users/${record.id}`, { is_active: record.active !== 1 });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
@@ -133,27 +114,14 @@ async function toggleActive(record: Record<string, any>) {
}
}
async function toggleSts(record: Record<string, any>) {
try {
await api.post('customer/toggle-sts', { id: record.id, sts: record.sts });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
function toAdd() {
router.push('/reagent/add');
}
function toEdit(id: string) {
router.push(`/reagent/edit/${id}`);
}
function toView(id: string) {
router.push(`/bulkCustomer/view/${id}`);
}
function viewOrder(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=orders`);
}
function viewCash(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=documents`);
router.push(`/reagent/view/${id}`);
}
</script>
+15 -5
View File
@@ -94,8 +94,13 @@
<a-button class="mt-4" type="primary" @click="saveTags">ذخیره برچسب ها</a-button>
</a-tab-pane>
<!-- اسلایدر موبایل -->
<a-tab-pane key="slider" tab="اسلایدر موبایل">
<!-- اسلایدر موبایل — دسترسی جدا از بقیه‌ی این صفحه، چون آپلود بنر عمومی
سایت حساس‌تر از ویرایش برند/برچسب است (custom.config.statics.slider) -->
<a-tab-pane
v-if="hasPermission('custom.config.statics.slider')"
key="slider"
tab="اسلایدر موبایل"
>
<div v-for="(s, index) in sliders" :key="index" class="slide">
<h6 class="mobile-c">اسلاید {{ index + 1 }}</h6>
<div class="row">
@@ -275,9 +280,14 @@ async function saveTags() {
}
// ── slider
// Gated server-side by its own `custom.config.statics.slider` permission (not
// the broad `config.statics` that guards the rest of this screen) — see
// admin-misc.controller.ts. Uses the dedicated `const/slider` route rather
// than the generic `const/:key`, which the server only accepts under that
// specific permission.
async function saveSliders() {
try {
await api.put('const/mobile_main_slider', { val: sliders.value });
await api.put('const/slider', { val: sliders.value });
message.success('اسلایدر ذخیره شد');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ذخیره');
@@ -334,10 +344,10 @@ onMounted(async () => {
await Promise.all([loadBrands(), loadCollections()]);
const [t, s] = await Promise.all([
api.get('const/__list__tags'),
api.get('const/mobile_main_slider'),
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
]);
tags.value = Array.isArray(t.data) ? t.data : [];
sliders.value = Array.isArray(s.data) ? s.data : [];
sliders.value = Array.isArray(s?.data) ? s.data : [];
} catch (e) {
console.log(e);
} finally {
+31 -2
View File
@@ -51,6 +51,14 @@
</a-select>
</a-form-item>
<a-form-item label="نقش" :validate-status="err.role ? 'error' : ''" :help="err.role">
<a-select v-model:value="form.role" placeholder="انتخاب کنید">
<a-select-option v-for="r in roleOptions" :key="r.value" :value="r.value">
{{ r.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="رمز عبور"
:validate-status="err.password ? 'error' : ''"
@@ -104,6 +112,14 @@
</a-select>
</a-form-item>
<a-form-item label="نقش" :validate-status="err.role ? 'error' : ''" :help="err.role">
<a-select v-model:value="form.role" placeholder="انتخاب کنید">
<a-select-option v-for="r in roleOptions" :key="r.value" :value="r.value">
{{ r.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="رمز عبور"
:validate-status="err.password ? 'error' : ''"
@@ -153,12 +169,14 @@ const router = useRouter();
const id = asModal ? props.id : (route.params.id as string | undefined);
const loading = ref(false);
const userTypes = ref<{ id: number; title: string }[]>([]);
const roleOptions = ref<{ value: string; title: string }[]>([]);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
mobile: '',
ut: undefined,
role: undefined,
password: '',
password_confirmation: '',
});
@@ -172,6 +190,9 @@ function validate(): boolean {
if (!form.lname) err.lname = REQUIRED;
if (!form.mobile) err.mobile = REQUIRED;
if (form.ut === undefined || form.ut === null) err.ut = REQUIRED;
// Required on create; on edit an admin already has a role, so leaving the
// field alone must not force re-selection.
if (!id && !form.role) err.role = REQUIRED;
// On create a password is mandatory; on edit, blank means "leave unchanged".
if (!id && !form.password) err.password = REQUIRED;
@@ -190,6 +211,9 @@ async function handleSubmit() {
mobile: form.mobile,
ut: form.ut,
};
// On edit, only send role if the operator actually changed it — an admin
// without this field touched must keep their existing role.
if (!id || form.role !== undefined) payload.role = form.role;
if (form.password) payload.password = form.password;
if (id) {
@@ -213,8 +237,12 @@ async function handleSubmit() {
onMounted(async () => {
try {
const { data } = await api.get('const/__list__ut');
userTypes.value = data;
const [{ data: types }, { data: roles }] = await Promise.all([
api.get('const/__list__ut'),
api.get('admin/users/role-options'),
]);
userTypes.value = types;
roleOptions.value = roles;
} catch (e) {
console.log(e);
}
@@ -229,6 +257,7 @@ onMounted(async () => {
lname: parts.slice(1).join(' '),
mobile: data.mobile ?? '',
ut: data.ut ?? undefined,
role: data.role ?? undefined,
password: '',
password_confirmation: '',
});