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