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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-14 04:33:24 +03:30
co-authored by Claude Haiku 5.5
parent 55856cac89
commit cf014e1f6c
41 changed files with 3471 additions and 969 deletions
File diff suppressed because it is too large Load Diff
@@ -11,36 +11,185 @@
Permission gates are legacy's, including the third tab's: legacy commented
out its `persons.create.customer.tak` check (AddCustomer.vue:142), so the
retail tab shows for anyone who can open the page.
retail tab shows for anyone who can open the page. The tabs are drawn as
selectable cards; which form shows is unchanged.
-->
<div id="page-user-view">
<div id="user-data">
<a-card class="mb-base page-card">
<a-tabs v-if="hasPermission('persons.create')">
<a-tab-pane v-if="hasPermission('persons.create.client')" key="omde" tab="مشتری عمده">
<BulkCustomer />
</a-tab-pane>
<div id="page-user-view" class="pf-page">
<header class="pf-head">
<a-button class="pf-head__back" shape="circle" @click="handleBack">
<template #icon><ArrowRightOutlined /></template>
</a-button>
<div class="pf-head__text">
<h1>افزودن شخص</h1>
<p>نوع شخص را انتخاب و اطلاعات او را تکمیل کنید.</p>
</div>
</header>
<a-tab-pane v-if="hasPermission('persons.create.seller')" key="reagent" tab="کارشناس فروش">
<ReagentCustomer />
</a-tab-pane>
<template v-if="hasPermission('persons.create') && kinds.length">
<div class="kind-picker" role="tablist">
<button
v-for="k in kinds"
:key="k.key"
type="button"
role="tab"
class="kind-card"
:class="{ 'is-on': activeTab === k.key }"
:aria-selected="activeTab === k.key"
@click="activeTab = k.key"
>
<span class="kind-card__icon"><component :is="k.icon" /></span>
<span class="kind-card__text">
<b>{{ k.title }}</b>
<small>{{ k.hint }}</small>
</span>
<CheckCircleFilled v-if="activeTab === k.key" class="kind-card__check" />
</button>
</div>
<a-tab-pane key="tak" tab="مشتری تک">
<SingleCustomer />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
<KeepAlive>
<component :is="FORMS[activeTab]" v-if="kinds.some((k) => k.key === activeTab)" :key="activeTab" />
</KeepAlive>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, type Component } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
ShopOutlined,
TeamOutlined,
UserOutlined,
ArrowRightOutlined,
CheckCircleFilled,
IdcardOutlined,
} from '@ant-design/icons-vue';
import BulkCustomer from './bulkCustomer.vue';
import ReagentCustomer from './ReagentCustomer.vue';
import SingleCustomer from './singleCustomer.vue';
import { useUsersStore } from '../../../stores/users';
const route = useRoute();
const router = useRouter();
const users = useUsersStore();
const activeTab = ref((route.query.tab as string) || 'omde');
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
// Card picker (design): the same three forms and the same gates as the tabs it
// replaces — عمده: persons.create.client, کارشناس: persons.create.seller,
// تک: super admin only (D30).
interface Kind {
key: 'omde' | 'reagent' | 'tak';
title: string;
hint: string;
icon: Component;
}
const kinds = computed<Kind[]>(() => {
const out: Kind[] = [];
if (hasPermission('persons.create.client')) {
out.push({ key: 'omde', title: 'مشتری عمده', hint: 'مغازه‌دار، بنکدار و خریدار عمده', icon: ShopOutlined });
}
if (hasPermission('persons.create.seller')) {
out.push({ key: 'reagent', title: 'کارشناس فروش', hint: 'کارشناس با کمیسیون و سقف اعتبار', icon: IdcardOutlined });
}
if (users.isSuperAdmin) {
out.push({ key: 'tak', title: 'مشتری تک', hint: 'مشتری خرده‌فروشی حضوری', icon: UserOutlined });
}
return out;
});
const FORMS: Record<string, Component> = { omde: BulkCustomer, reagent: ReagentCustomer, tak: SingleCustomer };
function handleBack() {
if (window.history.length > 1) {
router.back();
} else {
router.push('/customer/omde-list');
}
}
</script>
<style scoped>
b {
font-weight: 500;
}
.kind-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.kind-card {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border: 1.5px solid #eef0f5;
border-radius: 16px;
background: #fff;
text-align: start;
cursor: pointer;
font: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.kind-card:hover {
border-color: #c9d0dd;
}
.kind-card.is-on {
border-color: #1c2d56;
background: rgba(28, 45, 86, 0.03);
box-shadow: 0 0 0 3px rgba(28, 45, 86, 0.08);
}
.kind-card__icon {
width: 42px;
height: 42px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 12px;
font-size: 16px;
color: #8a94a6;
background: #f2f4f7;
transition: color 0.2s ease, background 0.2s ease;
}
.kind-card.is-on .kind-card__icon {
color: #fff;
background: #1c2d56;
}
.kind-card__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.kind-card__text b {
font-size: 14px;
color: #101828;
}
.kind-card__text small {
font-size: 12px;
color: #8a94a6;
}
.kind-card__check {
margin-inline-start: auto;
font-size: 16px;
color: #1c2d56;
}
@media (max-width: 575px) {
.kind-picker {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.kind-card {
padding: 10px 12px;
}
.kind-card__icon {
width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>
@@ -18,93 +18,148 @@
operator can still type in directly.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-4">
<a-form-item label="عکس پروفایل" class="avatar-cell">
<ImageUploadInput
v-model="form.image_url"
purpose="reagent.avatar"
placeholder="عکس"
@ok="onAvatar"
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
/>
</a-form-item>
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور" :validate-status="err.password ? 'error' : ''" :help="err.password">
<a-input-password v-model:value="form.password" />
</a-form-item>
</div>
</a-card>
<a-form layout="vertical" class="pf-form" @submit.prevent="handleSubmit">
<div class="pf-layout">
<!-- ══════════ main column ══════════ -->
<div class="pf-main">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><UserOutlined /></span>
<div>
<h3>اطلاعات پایه</h3>
<p>مشخصات هویتی و اطلاعات ورود کارشناس</p>
</div>
</div>
<div class="pf-grid-2">
<a-form-item label="نام " required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<!--
D17 — both optional in the reference (required: false,
ReagentCustomer.vue:31,95) with no uniqueness and no format rule,
so none is invented here.
-->
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" class="ltr-input" inputmode="numeric" />
</a-form-item>
<a-form-item label="تلفن">
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
required
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
class="mb-0"
>
<a-input v-model:value="form.mobile" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
</a-form-item>
<a-form-item
label="رمز عبور"
:required="!id"
:validate-status="err.password ? 'error' : ''"
:help="err.password"
class="mb-0"
>
<a-input-password v-model:value="form.password" />
</a-form-item>
</div>
</section>
<a-card title="اطلاعات تجاری" class="mb-base">
<div class="grid-4">
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="درصد کمیسیون">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</a-form-item>
<!--
D17 — the sales-expert fields the reference collected here.
«شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228).
-->
<a-form-item label="شهر">
<CityTreeSelect
:value="form.city_id"
:cities="cities"
placeholder="شهر را انتخاب نمایید"
@change="onCityChange"
/>
</a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)">
<a-input-number v-model:value="form.gram_level" class="w-full" />
</a-form-item>
</div>
</a-card>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><ShopOutlined /></span>
<div>
<h3>اطلاعات تجاری</h3>
<p>کد و مشخصات فروشگاه</p>
</div>
</div>
<div class="pf-grid-3">
<a-form-item label="کد مشتری" class="mb-0">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="نام مغازه" class="mb-0">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ" class="mb-0">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
</div>
</section>
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-4">
<!--
D17 — both optional in the reference (required: false,
ReagentCustomer.vue:31,95) with no uniqueness and no format rule,
so none is invented here.
-->
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item label="تلفن">
<a-input v-model:value="form.phone" />
</a-form-item>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><EnvironmentOutlined /></span>
<div>
<h3>آدرس</h3>
<p>انتخاب شهر، درصد کمیسیون را بر اساس همان شهر پر می‌کند</p>
</div>
</div>
<!-- D17 — «شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228). -->
<a-form-item label="شهر">
<CityTreeSelect
:value="form.city_id"
:cities="cities"
placeholder="شهر را انتخاب نمایید"
@change="onCityChange"
/>
</a-form-item>
<a-form-item label="آدرس" class="mb-0">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
</section>
</div>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</a-card>
<a-button type="primary" class="form-submit" @click="handleSubmit">ثبت اطلاعات</a-button>
<!-- ══════════ side column ══════════ -->
<aside class="pf-side">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><CameraOutlined /></span>
<div>
<h3>عکس پروفایل</h3>
<p>تصویر نمایش داده‌شده برای کارشناس</p>
</div>
</div>
<div class="avatar-slot">
<ImageUploadInput
v-model="form.image_url"
purpose="reagent.avatar"
placeholder="عکس"
@ok="onAvatar"
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
/>
</div>
</section>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><PercentageOutlined /></span>
<div>
<h3>کمیسیون و اعتبار</h3>
<p>درصد کمیسیون و سقف اعتبار</p>
</div>
</div>
<a-form-item label="درصد کمیسیون">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" addon-after="٪" />
</a-form-item>
<a-form-item label="سقف اعتبار" class="mb-0">
<a-input-number v-model:value="form.gram_level" class="w-full" addon-after="گرم" />
</a-form-item>
</section>
</aside>
</div>
<div class="pf-actions">
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستاره‌دار الزامی هستند.</span>
<a-button @click="router.go(-1)">انصراف</a-button>
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmit">
<template #icon><CheckOutlined /></template>
ثبت اطلاعات
</a-button>
</div>
</a-form>
</a-spin>
</template>
@@ -113,6 +168,17 @@
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
TeamOutlined,
ShopOutlined,
EnvironmentOutlined,
CheckOutlined,
ArrowRightOutlined,
CameraOutlined,
InfoCircleOutlined,
PercentageOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import api from '../../../api/client';
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
@@ -133,25 +199,18 @@ const form = reactive<Record<string, any>>({
pasazh_name: '',
address: '',
percent: undefined,
// D17 — the sales-expert fields the reference collected on this form and the
// port had dropped. They live on SalesExpertProfile now.
ncode: '',
phone: '',
city_id: undefined,
gram_level: undefined,
// legacy kept the FK in `image_id`; `image_url` only drives the thumbnail.
image_id: '',
image_url: null,
});
/** Cities for the «شهر» select and the commission autofill. */
const cities = ref<Array<{ id: string; title: string; parent?: string | null; percent?: number | null }>>([]);
const err = reactive<Record<string, string>>({});
/** Snapshot of the server's record, captured after the edit form is populated. */
const initial = reactive<Record<string, any>>({});
/** The subset of `form` that differs from `initial`, minus `omit` keys. */
function diffFromInitial(omit: string[] = []): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(form)) {
@@ -161,23 +220,10 @@ function diffFromInitial(omit: string[] = []): Record<string, any> {
return out;
}
/** Legacy `uploadIndexImage` — MyUpload emitted the whole response object. */
function onAvatar(file: Record<string, unknown>) {
form.image_id = (file.id as string) ?? '';
}
/**
* Legacy `getPercent(value)` — henza_admin ReagentCustomer.vue:215-228.
*
* Selecting a city fills the commission percentage:
* 1. use `percent` from the already-loaded city row, if it is set;
* 2. otherwise GET cities/get-percent-aux/{id} and use that;
* 3. write the result into the form.
*
* Ported verbatim, including the falsy test on step 1 — legacy used
* `if (!percent)`, so a city percent of 0 also falls through to the aux
* lookup. Replicated rather than "fixed" (D4).
*/
async function onCityChange(value: string | undefined) {
form.city_id = value;
if (!value) return;
@@ -185,9 +231,6 @@ async function onCityChange(value: string | undefined) {
let percent: number | null | undefined = cities.value.find((c) => c.id === value)?.percent;
if (!percent) {
try {
// The endpoint returns a BARE scalar, as the reference does
// (CitiesController::getPercentAux -> _getP, and the target's
// admin-misc.service.ts:125 returns `c.percentAux` directly).
const { data } = await api.get(`cities/get-percent-aux/${value}`);
percent = data as number | null;
} catch (e) {
@@ -197,6 +240,7 @@ async function onCityChange(value: string | undefined) {
}
form.percent = percent === null || percent === undefined ? undefined : Number(percent);
}
const REQUIRED = 'لطفا مقدار را وارد کنید';
async function handleSubmit() {
@@ -209,16 +253,10 @@ async function handleSubmit() {
try {
loading.value = true;
// `image_url` is presentation only — the server stores the FK (`image_id`).
const { image_url: _unusedAvatarUrl, ...allFields } = form;
// On EDIT send only touched fields, and never re-send a blank password
// (blank means "leave unchanged" on the admin-users endpoint).
const fields = id ? diffFromInitial(['image_url', 'password']) : allFields;
if (id && form.password) fields.password = form.password;
const payload: Record<string, any> = { ...fields, role: 'SALES_EXPERT' };
// D0 — fname/lname are real columns and already travel in `fields`. `name`
// goes only when a name part actually changed; sending it unconditionally
// would re-submit the whole name on every save and defeat the diff-send.
if (!id || 'fname' in fields || 'lname' in fields) {
payload.name = `${form.fname} ${form.lname}`.trim();
}
@@ -238,7 +276,6 @@ async function handleSubmit() {
}
onMounted(async () => {
// D17 — the «شهر» select and its commission autofill need the city list.
try {
const { data } = await api.post('cities/list', { page: 1, limit: 500 });
cities.value = (data.rows ?? []).map((c: any) => ({
@@ -260,8 +297,6 @@ onMounted(async () => {
mobile: data.mobile ?? '',
address: data.address ?? '',
percent: data.percent === null ? undefined : Number(data.percent),
// D17 — from SalesExpertProfile. Numbers are coerced so diffFromInitial
// compares like with like and does not re-send an unchanged field.
ncode: data.ncode ?? '',
phone: data.phone ?? '',
city_id: data.city_id ?? undefined,
@@ -269,14 +304,12 @@ onMounted(async () => {
data.gram_level === null || data.gram_level === undefined
? undefined
: Number(data.gram_level),
// guarded, unlike legacy — an agent with no avatar must not throw
image_id: data.image?.id ?? '',
image_url: data.image?.image ?? null,
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
});
// Baseline for the dirty-diff in handleSubmit.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
} catch (e) {
console.log(e);
@@ -286,19 +319,9 @@ onMounted(async () => {
</script>
<style scoped>
/* .grid-4 now comes from the global utilities.less (dedup) */
/* Legacy put the avatar in the first grid cell spanning two rows
(md:row-span-2), with the uploader centred inside it. */
.avatar-cell {
grid-row: span 2;
.avatar-slot {
display: flex;
justify-content: center;
}
/* single-column grid on phones: a 2-row span would only leave a gap */
@media (max-width: 575px) {
.avatar-cell {
grid-row: auto;
}
padding: 4px 0;
}
</style>
@@ -136,118 +136,163 @@
</BaseFormModal>
<!-- route-based fallback for /customer/add and /bulkCustomer/edit/:id -->
<a-form v-else layout="vertical" :model="form" @submit.prevent="handleSubmitOmde">
<a-form v-else layout="vertical" :model="form" class="pf-form" @submit.prevent="handleSubmitOmde">
<a-spin :spinning="loading">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-4">
<a-form-item label="نام" :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="تلفن ثابت">
<a-input v-model:value="form.phone" />
</a-form-item>
</div>
</a-card>
<div class="pf-layout">
<!-- ══════════ main column ══════════ -->
<div class="pf-main">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><UserOutlined /></span>
<div>
<h3>اطلاعات پایه</h3>
<p>مشخصات هویتی و اطلاعات ورود مشتری</p>
</div>
</div>
<div class="pf-grid-2">
<a-form-item label="نام" required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" class="ltr-input" inputmode="numeric" />
</a-form-item>
<DateInput
:model-value="form.birth_date || undefined"
label="تاریخ تولد"
@update:model-value="(v) => (form.birth_date = v ?? '')"
/>
<a-form-item
label="شماره تلفن همراه"
required
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="تلفن ثابت" class="mb-0">
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" />
</a-form-item>
</div>
</section>
<a-card title="اطلاعات تجاری" class="mb-base">
<div class="grid-4">
<a-form-item label="نوع کاربری">
<a-select
v-model:value="form.type"
show-search
:filter-option="filterUserType"
style="width: 100%; direction: rtl !important"
>
<a-select-option v-for="item in userTypes" :key="item.id" :value="item.id">
{{ item.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<DateInput
:model-value="form.birth_date || undefined"
label="تاریخ تولد"
@update:model-value="(v) => (form.birth_date = v ?? '')"
/>
<a-form-item label="کارشناس فروش">
<a-select v-model:value="form.reagent_id" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id">
{{ r.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
<a-input-number v-model:value="form.percent" class="w-full" />
</a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)">
<a-input-number v-model:value="form.gram_level" class="w-full" />
</a-form-item>
</div>
</a-card>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><ShopOutlined /></span>
<div>
<h3>اطلاعات تجاری</h3>
<p>نوع کاربری و مشخصات فروشگاه</p>
</div>
</div>
<div class="pf-grid-2">
<a-form-item label="نوع کاربری">
<a-select
v-model:value="form.type"
show-search
placeholder="انتخاب کنید"
:filter-option="filterUserType"
style="width: 100%; direction: rtl !important"
>
<a-select-option v-for="item in userTypes" :key="item.id" :value="item.id">
{{ item.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="نام مغازه" class="mb-0">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ" class="mb-0">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
</div>
</section>
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-2">
<a-form-item label="شهر">
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><EnvironmentOutlined /></span>
<div>
<h3>آدرس</h3>
<p>انتخاب شهر، درصد را بر اساس همان شهر پر می‌کند</p>
</div>
</div>
<a-form-item label="شهر">
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
</a-form-item>
<a-form-item label="آدرس" class="mb-0">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 3, maxRows: 5 }" />
</a-form-item>
</section>
</div>
</a-card>
<a-card title="مدارک" class="mb-base">
<div class="grid-3">
<a-form-item label="عکس کارت ملی">
<ImageUploadInput
v-model="form.national_id_image"
purpose="customer.nationalId"
placeholder="عکس"
/>
</a-form-item>
<a-form-item label="عکس جواز کسب">
<ImageUploadInput
v-model="form.business_license"
purpose="customer.businessLicense"
placeholder="عکس"
/>
</a-form-item>
<a-form-item label="عکس سردر مغازه">
<ImageUploadInput
v-model="form.shop_image"
purpose="customer.shop"
placeholder="عکس"
/>
</a-form-item>
</div>
</a-card>
<!-- ══════════ side column ══════════ -->
<aside class="pf-side">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><PercentageOutlined /></span>
<div>
<h3>کارشناس و اعتبار</h3>
<p>کارشناس فروش، درصد و سقف اعتبار</p>
</div>
</div>
<a-form-item label="کارشناس فروش">
<a-select
v-model:value="form.reagent_id"
allow-clear
show-search
option-filter-prop="label"
placeholder="انتخاب کنید"
>
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id" :label="r.name">
{{ r.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="درصد" required :validate-status="err.percent ? 'error' : ''" :help="err.percent">
<a-input-number v-model:value="form.percent" class="w-full" addon-after="٪" />
</a-form-item>
<a-form-item label="سقف اعتبار" class="mb-0">
<a-input-number v-model:value="form.gram_level" class="w-full" addon-after="گرم" />
</a-form-item>
</section>
<div>
<a-button type="primary" class="mr-4 form-submit" @click="handleSubmitOmde">ثبت اطلاعات</a-button>
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><SafetyCertificateOutlined /></span>
<div>
<h3>مدارک</h3>
<p>تصویر مدارک هویتی و فروشگاه</p>
</div>
</div>
<div class="pf-uploads">
<a-form-item label="کارت ملی" class="mb-0">
<ImageUploadInput v-model="form.national_id_image" purpose="customer.nationalId" placeholder="عکس" />
</a-form-item>
<a-form-item label="جواز کسب" class="mb-0">
<ImageUploadInput v-model="form.business_license" purpose="customer.businessLicense" placeholder="عکس" />
</a-form-item>
<a-form-item label="سردر مغازه" class="mb-0">
<ImageUploadInput v-model="form.shop_image" purpose="customer.shop" placeholder="عکس" />
</a-form-item>
</div>
</section>
</aside>
</div>
<div class="pf-actions">
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستاره‌دار الزامی هستند.</span>
<a-button @click="router.go(-1)">انصراف</a-button>
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmitOmde">
<template #icon><CheckOutlined /></template>
ثبت اطلاعات
</a-button>
</div>
</a-spin>
</a-form>
@@ -257,6 +302,17 @@
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
UserOutlined,
ShopOutlined,
EnvironmentOutlined,
FileImageOutlined,
CheckOutlined,
ArrowRightOutlined,
InfoCircleOutlined,
PercentageOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons-vue';
import BaseFormModal from '../../../components/common/BaseFormModal.vue';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
import api from '../../../api/client';
@@ -266,18 +322,6 @@ import DateInput from '../../../components/input/DateInput.vue';
const props = defineProps<{
open?: boolean;
id?: string;
/**
* Set by callers that render this inside a modal (OmdeCustomerList); the
* route-based pages (/customer/add, /bulkCustomer/edit/:id) leave it off
* and get the full-page form instead.
*
* This exists as its own flag because `open` alone could not carry the
* distinction: Vue applies Boolean casting to a prop declared Boolean, so
* an absent `open` arrives as `false`, never `undefined`. Deriving the mode
* from `props.open !== undefined` was therefore always true, and the two
* route pages rendered a CLOSED modal — a blank page showing only the tab
* strip.
*/
asModal?: boolean;
}>();
const emit = defineEmits<{
@@ -308,34 +352,15 @@ const form = reactive<Record<string, any>>({
mcode: '',
birth_date: '',
address: '',
// legacy defaulted to no selection; 2 (مغازه دار اپلیکیشن) is what the
// backend falls back to when the field is absent.
type: undefined,
reagent_id: undefined,
// D9 — the reference defaults a new customer's making-charge offset to 3
// (AdminController.php:338) and makes the field required (:354).
percent: 3,
gram_level: undefined,
// The three document photos legacy collected via MyUpload. Stored as plain
// URLs (schema: nationalIdImageUrl / businessLicenseUrl / shopImageUrl),
// unlike the sales-agent avatar which is an FK.
national_id_image: '',
business_license: '',
shop_image: '',
});
/**
* Legacy bulkCustomer.vue:239-244 offered a 4th option here ("کارشناس فروش",
* id 1) because legacy stored a sales agent as a Customer row too. The
* redesigned system models a sales agent as an AdminUser (role SALES_EXPERT)
* instead — created from "افزودن مدیر" or the "کارشناس فروش" tab next to this
* one (ReagentCustomer.vue), never from this form. Selecting it here used to
* create a Customer row that no screen correctly read back (see the bug
* report that led to this fix), so it is removed; the server also now
* rejects type=1 on this endpoint as defense-in-depth. The server's canonical
* map (Customer::$TYPES) also has 5 => سرپرست کارگاه, which this form never
* offered; that omission is preserved.
*/
const userTypes = [
{ id: 2, title: 'مغازه دار اپلیکیشن' },
{ id: 3, title: 'مغازه دار حضوری' },
@@ -347,14 +372,8 @@ function filterUserType(input: string, option: any) {
}
const err = reactive<Record<string, string>>({});
/**
* Snapshot of the record as the server returned it, captured after the edit
* form is populated. Empty on create, where every field is intentionally new.
*/
const initial = reactive<Record<string, any>>({});
/** The subset of `form` that differs from `initial`. */
function diffFromInitial(): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(form)) {
@@ -363,11 +382,6 @@ function diffFromInitial(): Record<string, any> {
return out;
}
/**
* Legacy getPercent (henza_admin bulkCustomer.vue:260-272): picking a city fills
* «درصد» with the city's own percent, else its commission percent — which, as
* legacy's _getP, a city without one takes from its parent.
*/
async function onCityChange(value: string | undefined) {
if (!value) return;
let percent: unknown = cities.value.find((c) => c.id === value)?.percent;
@@ -385,13 +399,9 @@ async function onCityChange(value: string | undefined) {
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
// legacy required: fname, lname, mobile — message 'لطفا مقدار را وارد کنید'
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید';
// D9 — `percent` is required on create in the reference
// (AdminController.php:354). 0 is a legitimate value, so test for
// "not supplied", not for falsiness.
if (form.percent === undefined || form.percent === null || (form.percent as unknown) === '') {
err.percent = 'لطفا مقدار را وارد کنید';
}
@@ -402,21 +412,12 @@ async function handleSubmitOmde() {
if (!validate()) return;
try {
loading.value = true;
// Legacy composed fname+lname separately; the schema has one fullName, so
// they are joined here and split back on read.
// On EDIT send only the fields the operator actually touched. Spreading the
// whole form means any field the edit path failed to repopulate is sent as
// '' and overwrites good data — see the note on Object.assign below.
const changed = id ? diffFromInitial() : { ...form };
const payload: Record<string, unknown> = {
...changed,
id: id ?? null,
national_code: form.ncode,
};
// D0 — fname/lname are real columns and travel in `changed` on their own.
// `name` is only sent when a name part actually changed; sending it
// unconditionally would re-submit the whole name on every save and defeat
// the diff-send the edit path relies on.
if (!id || 'fname' in changed || 'lname' in changed) {
payload.name = `${form.fname} ${form.lname}`.trim();
}
@@ -433,7 +434,6 @@ async function handleSubmitOmde() {
router.go(-1);
}
} catch (e: any) {
// legacy surfaced the server's mobile-uniqueness error specifically
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
@@ -450,7 +450,7 @@ onMounted(async () => {
}
try {
const { data } = await api.post('cities/list');
cities.value = data.rows;
cities.value = data.rows ?? [];
} catch (e) {
console.log(e);
}
@@ -464,30 +464,19 @@ onMounted(async () => {
mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined,
address: data.address ?? '',
// D9 — the API sends a Decimal, which serialises as a string. Coerce
// it, or a-input-number shows it fine but diffFromInitial() compares
// "3" with 3 and re-sends an unchanged field on every save.
percent: data.percent === null || data.percent === undefined ? undefined : Number(data.percent),
gram_level: data.gram_level ?? undefined,
reagent_id: data.reagent?.id ?? undefined,
// These five were previously OMITTED here. Because handleSubmitOmde
// spread the whole form and the server applies any key that is not
// `undefined`, saving an existing customer overwrote each of them with
// the empty string the reactive initializer left behind — silent data
// loss on every wholesale-customer edit.
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
birth_date: data.birth_date ?? '',
phone: data.phone ?? '',
type: data.type ?? undefined,
// Same reason as the five above: omitted here, they would be sent back
// as '' and wipe the stored photos on every edit.
national_id_image: data.national_id_image ?? '',
business_license: data.business_license ?? '',
shop_image: data.shop_image ?? '',
});
// Baseline for the dirty-diff in handleSubmitOmde.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
} catch (e) {
console.log(e);
@@ -496,4 +485,4 @@ onMounted(async () => {
});
</script>
<!-- .grid-2/.grid-4 now come from the global utilities.less (dedup) -->
@@ -8,41 +8,48 @@
the customer endpoints.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="id ? 'ویرایش مشتری' : 'افزودن مشتری'" class="mb-base">
<div class="grid-3">
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.phone ? 'error' : ''"
:help="err.phone"
>
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="شهر">
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
</a-form-item>
<DateInput
:model-value="form.birth_day || undefined"
label="تاریخ تولد"
@update:model-value="(v) => (form.birth_day = v ?? '')"
/>
<a-form layout="vertical" class="pf-form" @submit.prevent="handleSubmit">
<div class="pf-layout pf-layout--single">
<div class="pf-main">
<section class="pf-section">
<div class="pf-section__head">
<span class="pf-section__icon"><UserOutlined /></span>
<div>
<h3>{{ id ? 'ویرایش مشتری' : 'افزودن مشتری' }}</h3>
<p>مشخصات مشتری تک</p>
</div>
</div>
<div class="pf-grid-2">
<a-form-item label="نام " required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="شماره تلفن همراه" required :validate-status="err.phone ? 'error' : ''" :help="err.phone">
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
</a-form-item>
<DateInput
:model-value="form.birth_day || undefined"
label="تاریخ تولد"
@update:model-value="(v) => (form.birth_day = v ?? '')"
/>
<a-form-item label="شهر" class="pf-span-all mb-0">
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
</a-form-item>
</div>
</section>
</div>
</div>
<a-button type="primary" class="form-submit" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
<div class="pf-actions">
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستاره‌دار الزامی هستند.</span>
<a-button @click="router.go(-1)">انصراف</a-button>
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmit">
<template #icon><CheckOutlined /></template>
ثبت اطلاعات
</a-button>
</div>
</a-form>
</a-spin>
</template>
@@ -51,6 +58,13 @@
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
UserOutlined,
EnvironmentOutlined,
CheckOutlined,
ArrowRightOutlined,
InfoCircleOutlined,
} from '@ant-design/icons-vue';
import api from '../../../api/client';
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
import DateInput from '../../../components/input/DateInput.vue';
@@ -83,9 +97,6 @@ async function handleSubmit() {
loading.value = true;
const payload = {
...form,
// D0 — fname and lname are real columns now, so they go as-is (they are
// already in `form`). `name` is still sent for the legacy readers that
// expect a whole name; the server derives it from the parts anyway.
name: `${form.fname} ${form.lname}`.trim(),
mobile: form.phone,
};
@@ -114,9 +125,6 @@ onMounted(async () => {
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
// D0 — the API returns the real parts now. The old split(' ') fallback
// is gone: it guessed at the boundary and put two-token first names in
// the wrong box.
Object.assign(form, {
fname: data.fname ?? '',
lname: data.lname ?? '',
@@ -131,4 +139,9 @@ onMounted(async () => {
});
</script>
<!-- .grid-3 now comes from the global utilities.less (dedup) -->
<style scoped>
/* the form sits under the full-width person-type cards; keep it the same width */
.pf-layout--single {
max-width: none;
}
</style>
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -116,8 +116,8 @@
</div>
</a-tab-pane>
<!-- سینی‌ها -->
<a-tab-pane key="collections" tab="سینی ها">
<!-- سینی‌ها — super admin only -->
<a-tab-pane v-if="users.isSuperAdmin" key="collections" tab="سینی ها">
<div class="row">
<a-input
v-model:value="newCollection"
@@ -341,7 +341,7 @@ async function removeCollection(id: string) {
onMounted(async () => {
try {
loading.value = true;
await Promise.all([loadBrands(), loadCollections()]);
await Promise.all([loadBrands(), users.isSuperAdmin ? loadCollections() : Promise.resolve()]);
const [t, s] = await Promise.all([
api.get('const/__list__tags'),
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
@@ -54,8 +54,8 @@
type="info"
show-icon
class="mb-4"
message="در زرین سند موقت همان لحظه روی مانده‌ی مشتری اثر دارد"
description="برخلاف ته‌حساب. اگر سند در «ثبت سفارش» ثبت شود، مشتری در زرین پیش از تحویل بدهکار می‌شود؛ برای زرین «تحویل مسئول فروش» پیشنهاد می‌شود."
message="درایور زرین روی نصب واقعی تست نشده است"
description="طبق تصمیم مالک، پیش از استفاده‌ی واقعی یک دوره‌ی آزمایشی با اسناد موقت لازم است (علامت مانده و فیلد هزینه‌ی سنگ را بررسی کنید)."
/>
<a-alert
v-if="active && overview?.config?.provider && (form.features.balances || form.features.products)"
@@ -12,7 +12,12 @@
<template #add>
<div class="list-page-header-row">
<ListPageHeader rid="offers.create" add-label="افزودن تخفیف عمده" @add="openAdd('omde')" />
<ListPageHeader rid="offers.create.tak" add-label="افزودن تخفیف تک" @add="openAdd('tak')" />
<ListPageHeader
v-if="users.isSuperAdmin"
rid="offers.create.tak"
add-label="افزودن تخفیف تک"
@add="openAdd('tak')"
/>
</div>
</template>
@@ -98,17 +103,17 @@ function openAdd(variant: 'omde' | 'tak') {
modalOpen.value = true;
}
/** legacy: tak rows need the .tak.* rid, omde rows the plain one */
/** legacy: tak rows need the .tak.* rid, omde rows the plain one; tak rows are super admin only */
function canEdit(record: Record<string, any>) {
return (
(hasPermission('offers.list.tak.edit') && record.type_customer === 1) ||
(users.isSuperAdmin && hasPermission('offers.list.tak.edit') && record.type_customer === 1) ||
(hasPermission('offers.list.edit') && record.type_customer === 2)
);
}
function canRemove(record: Record<string, any>) {
return (
(hasPermission('offers.list.tak.remove') && record.type_customer === 1) ||
(users.isSuperAdmin && hasPermission('offers.list.tak.remove') && record.type_customer === 1) ||
(hasPermission('offers.list.remove') && record.type_customer === 2)
);
}
+41 -10
View File
@@ -74,6 +74,7 @@
import { computed, onMounted, reactive, ref } from 'vue';
import { message } from 'ant-design-vue';
import { treeData, type PermissionNode } from './permissionConst';
import { SUPER_ADMIN_RIDS } from '../../utils/superAdmin';
import { useUsersStore } from '../../stores/users';
import { useAuthStore } from '../../stores/auth';
import api from '../../api/client';
@@ -108,12 +109,39 @@ const visibleUserTypes = computed(() => {
return list;
});
/** legacy drops the whole "custom" branch for anyone outside level 104 */
const visibleTree = computed<PermissionNode[]>(() =>
auth.user?.ut === RESERVED_TYPE_ID
? treeData
: treeData.filter((x) => x.key !== 'custom'),
);
/** Drops every super-admin-only node, with its whole subtree. */
function withoutSuperAdminNodes(nodes: PermissionNode[]): PermissionNode[] {
return nodes
.filter((x) => !SUPER_ADMIN_RIDS.has(x.key))
.map((x) => (x.children ? { ...x, children: withoutSuperAdminNodes(x.children) } : x));
}
/**
* legacy drops the whole "custom" branch for anyone outside level 104; the
* super-admin-only rids are shown to the super admin alone.
*/
const visibleTree = computed<PermissionNode[]>(() => {
const tree =
auth.user?.ut === RESERVED_TYPE_ID ? treeData : treeData.filter((x) => x.key !== 'custom');
return users.isSuperAdmin ? tree : withoutSuperAdminNodes(tree);
});
/**
* Super-admin-only rids the selected level already holds. Not on the tree for
* anyone else, so they are sent back untouched on save instead of being
* revoked by an admin who cannot see them.
*/
const heldHiddenRids = ref<string[]>([]);
function setLoadedRids(loaded: string[]) {
if (users.isSuperAdmin) {
rid.value = loaded;
heldHiddenRids.value = [];
return;
}
rid.value = loaded.filter((x) => !SUPER_ADMIN_RIDS.has(x));
heldHiddenRids.value = loaded.filter((x) => SUPER_ADMIN_RIDS.has(x));
}
async function getData() {
try {
@@ -127,17 +155,17 @@ async function getData() {
/** legacy selectChange(): id 84 means "creating a new level", so start empty */
async function selectChange(id: string | undefined) {
if (!id) {
rid.value = [];
setLoadedRids([]);
return;
}
if (id === NEW_TYPE_ID) {
rid.value = [];
setLoadedRids([]);
return;
}
try {
const { data } = await api.get(`roles/${id}`);
// The server returns a flat array of rid strings.
rid.value = data;
setLoadedRids(data);
} catch (e) {
message.error('خطا در دریافت اطلاعات');
}
@@ -166,7 +194,10 @@ async function handleSubmit() {
targetUt = String(data.id);
}
await api.post('roles/create', { ut: targetUt, roles: rid.value });
await api.post('roles/create', {
ut: targetUt,
roles: [...new Set([...rid.value, ...heldHiddenRids.value])],
});
await getData();
if (newUT.value) {