admin user and ui panel
This commit is contained in:
@@ -2,10 +2,16 @@
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/apps/customer/AddCustomer.vue.
|
||||
|
||||
Legacy's file is 278 lines but almost all of it is COMMENTED OUT: the live
|
||||
markup is a single tab wrapping <bulk-customer/>. The "مشتری تک" tab and the
|
||||
whole inline form beneath it are commented out in the original and stay
|
||||
absent here rather than being revived.
|
||||
Three tabs, matching legacy's live markup one for one. An earlier revision
|
||||
of this port kept only the first: legacy's file is 278 lines and most of it
|
||||
IS commented out, but what's commented is an old inline "مشتری تک" form
|
||||
that ReagentCustomer/singleCustomer later replaced — not the tabs
|
||||
themselves. Dropping them left the page with a lone tab strip and, for
|
||||
anyone without the عمده permission, nothing at all.
|
||||
|
||||
Permission gates are legacy's, including the third tab's: legacy commented
|
||||
out its `persons.create.customer.tak` check (AddCustomer.vue:142), so the
|
||||
retail tab shows for anyone who can open the page.
|
||||
-->
|
||||
<div id="page-user-view">
|
||||
<div id="user-data">
|
||||
@@ -14,6 +20,14 @@
|
||||
<a-tab-pane v-if="hasPermission('persons.create.client')" key="omde" tab="مشتری عمده">
|
||||
<BulkCustomer />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane v-if="hasPermission('persons.create.seller')" key="reagent" tab="کارشناس فروش">
|
||||
<ReagentCustomer />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tak" tab="مشتری تک">
|
||||
<SingleCustomer />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</div>
|
||||
@@ -22,6 +36,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import BulkCustomer from './bulkCustomer.vue';
|
||||
import ReagentCustomer from './ReagentCustomer.vue';
|
||||
import SingleCustomer from './singleCustomer.vue';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
|
||||
const users = useUsersStore();
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/apps/customer/ReagentCustomer.vue —
|
||||
the sales-expert ("کارشناس فروش") form. Structurally the wholesale customer
|
||||
form plus a نوع کاربری selector and a commission percentage.
|
||||
the sales-expert ("کارشناس فروش") form. Legacy stored a sales agent as a
|
||||
Customer row (type=1) in the same `customers` table as every other
|
||||
wholesale kind, so this form used to share the wholesale customer's
|
||||
fields and post to the Customer endpoints.
|
||||
|
||||
The avatar uploader (عکس پروفایل) is live: legacy's MyUpload posted to
|
||||
`file/create` and kept the returned `id` in `image_id`, reading it back as a
|
||||
nested `image: { id, image }`. Both halves of that contract are preserved.
|
||||
|
||||
LEGACY BUG, not reproduced: legacy's load path dereferenced `data.image.id`
|
||||
unguarded (ReagentCustomer.vue:280), so opening an agent with no avatar threw
|
||||
— swallowed by its own try/catch, leaving the form half-filled. The read is
|
||||
guarded here.
|
||||
FIXED: a sales agent is now an AdminUser (role=SALES_EXPERT) — see
|
||||
admin-users.service.ts and admin-customers.service.ts's listReagents().
|
||||
Posting to the Customer endpoints (as this form used to) created a
|
||||
Customer row that listReagents() never reads and every OTHER Customer
|
||||
list (including "لیست مشتریان تک", which had no kind filter at all)
|
||||
happily returned instead. This form now posts to admin/users, and the
|
||||
fields that only ever existed on Customer (کد ملی، نوع کاربری، شهر، سقف
|
||||
اعتبار) are dropped — AdminUser has no matching column for them; city
|
||||
was only ever used here to prefill the commission percentage, which the
|
||||
operator can still type in directly.
|
||||
-->
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical" @submit.prevent="handleSubmit">
|
||||
@@ -20,7 +24,7 @@
|
||||
<a-form-item label="عکس پروفایل" class="avatar-cell">
|
||||
<ImageUploadInput
|
||||
v-model="form.image_url"
|
||||
purpose="customer.avatar"
|
||||
purpose="reagent.avatar"
|
||||
placeholder="عکس"
|
||||
@ok="onAvatar"
|
||||
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
|
||||
@@ -36,9 +40,6 @@
|
||||
>
|
||||
<a-input v-model:value="form.lname" />
|
||||
</a-form-item>
|
||||
<a-form-item label="کد ملی">
|
||||
<a-input v-model:value="form.ncode" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="شماره تلفن همراه"
|
||||
:validate-status="err.mobile ? 'error' : ''"
|
||||
@@ -46,16 +47,9 @@
|
||||
>
|
||||
<a-input v-model:value="form.mobile" />
|
||||
</a-form-item>
|
||||
<a-form-item label="رمز عبور">
|
||||
<a-form-item label="رمز عبور" :validate-status="err.password ? 'error' : ''" :help="err.password">
|
||||
<a-input-password v-model:value="form.password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نوع کاربری">
|
||||
<a-select v-model:value="form.type">
|
||||
<a-select-option v-for="t in userTypes" :key="t.id" :value="t.id">
|
||||
{{ t.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
@@ -67,41 +61,19 @@
|
||||
<a-form-item label="نام مغازه">
|
||||
<a-input v-model:value="form.store_name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="تلفن">
|
||||
<a-input v-model:value="form.phone" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام پاساژ">
|
||||
<a-input v-model:value="form.pasazh_name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد روی محصول">
|
||||
<a-form-item label="درصد کمیسیون">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="سقف اعتبار">
|
||||
<a-input-number v-model:value="form.gram_level" class="w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="اطلاعات تکمیلی" class="mb-base">
|
||||
<div class="grid-2">
|
||||
<a-form-item label="شهر">
|
||||
<a-select
|
||||
v-model:value="form.city_id"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="انتخاب کنید"
|
||||
:filter-option="filterOption"
|
||||
@change="onCityChange"
|
||||
>
|
||||
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
|
||||
{{ c.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="آدرس">
|
||||
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item label="آدرس">
|
||||
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
|
||||
@@ -121,24 +93,17 @@ const router = useRouter();
|
||||
|
||||
const id = route.params.id as string | undefined;
|
||||
const loading = ref(false);
|
||||
const cities = ref<{ id: string; title: string }[]>([]);
|
||||
const userTypes = ref<{ id: number; title: string }[]>([]);
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
fname: '',
|
||||
lname: '',
|
||||
ncode: '',
|
||||
password: '',
|
||||
mobile: '',
|
||||
type: undefined,
|
||||
mcode: '',
|
||||
store_name: '',
|
||||
phone: '',
|
||||
pasazh_name: '',
|
||||
city_id: undefined,
|
||||
address: '',
|
||||
percent: undefined,
|
||||
gram_level: undefined,
|
||||
// legacy kept the FK in `image_id`; `image_url` only drives the thumbnail.
|
||||
image_id: '',
|
||||
image_url: null,
|
||||
@@ -164,47 +129,31 @@ function onAvatar(file: Record<string, unknown>) {
|
||||
}
|
||||
const REQUIRED = 'لطفا مقدار را وارد کنید';
|
||||
|
||||
function filterOption(input: string, option: any) {
|
||||
return String(option?.children?.[0]?.children ?? '').includes(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* legacy: picking a city pre-fills the agent's commission from that city's
|
||||
* percent_aux (the sales-expert rate, as opposed to the wholesale rate).
|
||||
*/
|
||||
async function onCityChange(cityId: string) {
|
||||
if (!cityId) return;
|
||||
try {
|
||||
const { data } = await api.get(`cities/get-percent-aux/${cityId}`);
|
||||
if (data !== null && data !== undefined) form.percent = Number(data);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
Object.keys(err).forEach((k) => delete err[k]);
|
||||
if (!form.fname) err.fname = REQUIRED;
|
||||
if (!form.lname) err.lname = REQUIRED;
|
||||
if (!form.mobile) err.mobile = REQUIRED;
|
||||
if (!id && !form.password) err.password = REQUIRED;
|
||||
if (Object.keys(err).length) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
// `image_url` is presentation only — the server stores the FK (`image_id`).
|
||||
const { image_url: _unusedAvatarUrl, ...allFields } = form;
|
||||
// On EDIT send only touched fields — see bulkCustomer.vue for why.
|
||||
const fields = id ? diffFromInitial(['image_url']) : allFields;
|
||||
// On EDIT send only touched fields, and never re-send a blank password
|
||||
// (blank means "leave unchanged" on the admin-users endpoint).
|
||||
const fields = id ? diffFromInitial(['image_url', 'password']) : allFields;
|
||||
if (id && form.password) fields.password = form.password;
|
||||
const payload = {
|
||||
...fields,
|
||||
id: id ?? null,
|
||||
name: `${form.fname} ${form.lname}`.trim(),
|
||||
national_code: form.ncode,
|
||||
role: 'SALES_EXPERT',
|
||||
};
|
||||
if (id) {
|
||||
await api.put(`customer/${id}`, payload);
|
||||
await api.put(`users/${id}`, payload);
|
||||
} else {
|
||||
await api.post('customer/create', payload);
|
||||
await api.post('users', payload);
|
||||
}
|
||||
message.info('اطلاعات با موفقیت به ثبت رسید.');
|
||||
router.go(-1);
|
||||
@@ -217,36 +166,21 @@ async function handleSubmit() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [c, t] = await Promise.all([api.post('cities/list'), api.get('const/__list__ut')]);
|
||||
cities.value = c.data.rows ?? [];
|
||||
userTypes.value = t.data ?? [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
if (id) {
|
||||
try {
|
||||
const { data } = await api.get(`customer/${id}`);
|
||||
const { data } = await api.get(`admin/users/${id}`);
|
||||
Object.assign(form, {
|
||||
fname: data.fname ?? '',
|
||||
lname: data.lname ?? '',
|
||||
ncode: data.national_code ?? '',
|
||||
mobile: data.mobile ?? '',
|
||||
city_id: data.city_id ?? undefined,
|
||||
address: data.address ?? '',
|
||||
percent: data.percent === null ? undefined : Number(data.percent),
|
||||
gram_level: data.gram_level === null ? undefined : Number(data.gram_level),
|
||||
// guarded, unlike legacy — an agent with no avatar must not throw
|
||||
image_id: data.image?.id ?? '',
|
||||
image_url: data.image?.image ?? null,
|
||||
// Previously omitted — same silent-overwrite bug as bulkCustomer.vue:
|
||||
// the form spread these back as '' and wiped good data on every save.
|
||||
store_name: data.store_name ?? '',
|
||||
pasazh_name: data.pasazh_name ?? '',
|
||||
mcode: data.mcode ?? '',
|
||||
phone: data.phone ?? '',
|
||||
type: data.type ?? undefined,
|
||||
});
|
||||
// Baseline for the dirty-diff in handleSubmit.
|
||||
Object.assign(initial, JSON.parse(JSON.stringify(form)));
|
||||
@@ -258,7 +192,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* .grid-2/.grid-4 now come from the global utilities.less (dedup) */
|
||||
/* .grid-4 now comes from the global utilities.less (dedup) */
|
||||
|
||||
/* Legacy put the avatar in the first grid cell spanning two rows
|
||||
(md:row-span-2), with the uploader centred inside it. */
|
||||
|
||||
@@ -115,6 +115,28 @@
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="مدارک" class="mb-base">
|
||||
<div class="grid-3">
|
||||
<a-form-item label="عکس کارت ملی">
|
||||
<ImageUploadInput
|
||||
v-model="form.national_id_image"
|
||||
purpose="customer.nationalId"
|
||||
placeholder="عکس"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="عکس جواز کسب">
|
||||
<ImageUploadInput
|
||||
v-model="form.business_license"
|
||||
purpose="customer.businessLicense"
|
||||
placeholder="عکس"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="عکس سردر مغازه">
|
||||
<ImageUploadInput v-model="form.shop_image" purpose="customer.shop" placeholder="عکس" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
</BaseFormModal>
|
||||
|
||||
<!-- route-based fallback for /customer/add and /bulkCustomer/edit/:id -->
|
||||
@@ -207,6 +229,32 @@
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="مدارک" class="mb-base">
|
||||
<div class="grid-3">
|
||||
<a-form-item label="عکس کارت ملی">
|
||||
<ImageUploadInput
|
||||
v-model="form.national_id_image"
|
||||
purpose="customer.nationalId"
|
||||
placeholder="عکس"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="عکس جواز کسب">
|
||||
<ImageUploadInput
|
||||
v-model="form.business_license"
|
||||
purpose="customer.businessLicense"
|
||||
placeholder="عکس"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="عکس سردر مغازه">
|
||||
<ImageUploadInput
|
||||
v-model="form.shop_image"
|
||||
purpose="customer.shop"
|
||||
placeholder="عکس"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<div>
|
||||
<a-button type="primary" class="mr-4" @click="handleSubmitOmde">ثبت اطلاعات</a-button>
|
||||
</div>
|
||||
@@ -219,18 +267,32 @@ import { onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import BaseFormModal from '../../../components/common/BaseFormModal.vue';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
import api from '../../../api/client';
|
||||
|
||||
const props = defineProps<{
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
/**
|
||||
* Set by callers that render this inside a modal (OmdeCustomerList); the
|
||||
* route-based pages (/customer/add, /bulkCustomer/edit/:id) leave it off
|
||||
* and get the full-page form instead.
|
||||
*
|
||||
* This exists as its own flag because `open` alone could not carry the
|
||||
* distinction: Vue applies Boolean casting to a prop declared Boolean, so
|
||||
* an absent `open` arrives as `false`, never `undefined`. Deriving the mode
|
||||
* from `props.open !== undefined` was therefore always true, and the two
|
||||
* route pages rendered a CLOSED modal — a blank page showing only the tab
|
||||
* strip.
|
||||
*/
|
||||
asModal?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const asModal = props.open !== undefined;
|
||||
const asModal = props.asModal === true;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -258,15 +320,27 @@ const form = reactive<Record<string, any>>({
|
||||
reagent_id: undefined,
|
||||
percent: undefined,
|
||||
gram_level: undefined,
|
||||
// The three document photos legacy collected via MyUpload. Stored as plain
|
||||
// URLs (schema: nationalIdImageUrl / businessLicenseUrl / shopImageUrl),
|
||||
// unlike the sales-agent avatar which is an FK.
|
||||
national_id_image: '',
|
||||
business_license: '',
|
||||
shop_image: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Legacy bulkCustomer.vue:239-244, verbatim. The server's canonical map
|
||||
* (Customer::$TYPES) also has 5 => سرپرست کارگاه, which this form never offered;
|
||||
* that omission is preserved.
|
||||
* Legacy bulkCustomer.vue:239-244 offered a 4th option here ("کارشناس فروش",
|
||||
* id 1) because legacy stored a sales agent as a Customer row too. The
|
||||
* redesigned system models a sales agent as an AdminUser (role SALES_EXPERT)
|
||||
* instead — created from "افزودن مدیر" or the "کارشناس فروش" tab next to this
|
||||
* one (ReagentCustomer.vue), never from this form. Selecting it here used to
|
||||
* create a Customer row that no screen correctly read back (see the bug
|
||||
* report that led to this fix), so it is removed; the server also now
|
||||
* rejects type=1 on this endpoint as defense-in-depth. The server's canonical
|
||||
* map (Customer::$TYPES) also has 5 => سرپرست کارگاه, which this form never
|
||||
* offered; that omission is preserved.
|
||||
*/
|
||||
const userTypes = [
|
||||
{ id: 1, title: 'کارشناس فروش' },
|
||||
{ id: 2, title: 'مغازه دار اپلیکیشن' },
|
||||
{ id: 3, title: 'مغازه دار حضوری' },
|
||||
{ id: 4, title: 'بنکدار' },
|
||||
@@ -379,6 +453,11 @@ onMounted(async () => {
|
||||
mcode: data.mcode ?? '',
|
||||
phone: data.phone ?? '',
|
||||
type: data.type ?? undefined,
|
||||
// Same reason as the five above: omitted here, they would be sent back
|
||||
// as '' and wipe the stored photos on every edit.
|
||||
national_id_image: data.national_id_image ?? '',
|
||||
business_license: data.business_license ?? '',
|
||||
shop_image: data.shop_image ?? '',
|
||||
});
|
||||
// Baseline for the dirty-diff in handleSubmitOmde.
|
||||
Object.assign(initial, JSON.parse(JSON.stringify(form)));
|
||||
|
||||
@@ -170,7 +170,11 @@
|
||||
row-key="__key"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.dataIndex === 'op'">
|
||||
<template v-if="column.dataIndex === 'image'">
|
||||
<img v-if="record.image_url" :src="record.image_url" class="variant-thumb" alt="" />
|
||||
<span v-else class="variant-thumb variant-thumb--empty">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'op'">
|
||||
<a-tooltip v-if="hasPermission('product.list.variants.edit')" title="ویرایش">
|
||||
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="showModal(record, index)" />
|
||||
</a-tooltip>
|
||||
@@ -213,6 +217,45 @@
|
||||
<a-form-item label="تعداد">
|
||||
<a-input-number v-model:value="variantForm.qty" class="w-full" />
|
||||
</a-form-item>
|
||||
|
||||
<!--
|
||||
تصویر تنوع — picked from THIS product's already-saved gallery, never
|
||||
uploaded again: the variant points at the same ProductImage row the
|
||||
gallery shows, so one photo can back several weights.
|
||||
|
||||
On create the gallery is empty because the uploaded files have no
|
||||
ProductImage rows (and no ids) until the product is saved once, so the
|
||||
picker explains that instead of offering nothing.
|
||||
-->
|
||||
<a-form-item label="تصویر تنوع">
|
||||
<div v-if="gallery.length" class="variant-picker">
|
||||
<button
|
||||
type="button"
|
||||
class="variant-picker__item"
|
||||
:class="{ 'is-on': !variantForm.image_id }"
|
||||
@click="pickVariantImage(null)"
|
||||
>
|
||||
<span class="variant-picker__none">بدون تصویر</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="img in gallery"
|
||||
:key="img.id"
|
||||
type="button"
|
||||
class="variant-picker__item"
|
||||
:class="{ 'is-on': variantForm.image_id === img.id }"
|
||||
@click="pickVariantImage(img)"
|
||||
>
|
||||
<img :src="img.url" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<a-alert
|
||||
v-else
|
||||
type="info"
|
||||
show-icon
|
||||
:message="id ? 'برای این محصول تصویری ثبت نشده است.' : 'ابتدا محصول را ذخیره کنید تا بتوانید تصویر تنوع را انتخاب کنید.'"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="فعال">
|
||||
<a-switch v-model:checked="variantForm.is_active" />
|
||||
</a-form-item>
|
||||
@@ -269,6 +312,17 @@ const err = reactive<Record<string, string>>({});
|
||||
const gallerySite = ref<string[]>([]);
|
||||
const galleryMobile = ref<string[]>([]);
|
||||
|
||||
// ── the product's saved gallery, WITH row ids — the source for the variant
|
||||
// image picker. Populated only when editing, because on create the uploaded
|
||||
// files have no ProductImage rows (and therefore no ids) until the first save.
|
||||
interface GalleryImage {
|
||||
id: string;
|
||||
url: string;
|
||||
role: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
const gallery = ref<GalleryImage[]>([]);
|
||||
|
||||
// ── variants (edited in memory, saved with the product)
|
||||
interface VariantRow {
|
||||
__key: string;
|
||||
@@ -279,6 +333,11 @@ interface VariantRow {
|
||||
qty?: number;
|
||||
is_active: boolean;
|
||||
is_pre_order: boolean;
|
||||
/// The picked gallery image. `image_id` is what is posted; `image_url` is
|
||||
/// carried alongside purely so the table can draw a thumbnail without
|
||||
/// looking it up.
|
||||
image_id?: string | null;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
const variants = ref<VariantRow[]>([]);
|
||||
@@ -292,9 +351,17 @@ const variantForm = reactive<VariantRow>({
|
||||
qty: 0,
|
||||
is_active: true,
|
||||
is_pre_order: false,
|
||||
image_id: null,
|
||||
image_url: null,
|
||||
});
|
||||
|
||||
function pickVariantImage(img: GalleryImage | null) {
|
||||
variantForm.image_id = img ? img.id : null;
|
||||
variantForm.image_url = img ? img.url : null;
|
||||
}
|
||||
|
||||
const variantColumns = [
|
||||
{ title: 'تصویر', dataIndex: 'image' },
|
||||
{ title: 'عنوان', dataIndex: 'title' },
|
||||
{ title: 'وزن', dataIndex: 'grams' },
|
||||
{ title: 'اجرت درصدی', dataIndex: 'percentage_pay' },
|
||||
@@ -319,6 +386,8 @@ function showModal(record?: VariantRow, index?: number) {
|
||||
qty: 0,
|
||||
is_active: true,
|
||||
is_pre_order: false,
|
||||
image_id: null,
|
||||
image_url: null,
|
||||
});
|
||||
}
|
||||
variantModal.value = true;
|
||||
@@ -361,6 +430,7 @@ async function handleSubmit() {
|
||||
qty: v.qty,
|
||||
is_active: v.is_active,
|
||||
is_pre_order: v.is_pre_order,
|
||||
image_id: v.image_id ?? null,
|
||||
})),
|
||||
};
|
||||
if (id) {
|
||||
@@ -414,6 +484,7 @@ onMounted(async () => {
|
||||
});
|
||||
gallerySite.value = data.images_gallery ?? [];
|
||||
galleryMobile.value = data.images_mobile ?? [];
|
||||
gallery.value = data.gallery ?? [];
|
||||
variants.value = (data.variants_all ?? []).map((v: any, i: number) => ({
|
||||
__key: v.id ?? `v-${i}`,
|
||||
id: v.id,
|
||||
@@ -423,6 +494,8 @@ onMounted(async () => {
|
||||
qty: v.qty,
|
||||
is_active: v.is_active,
|
||||
is_pre_order: v.is_pre_order,
|
||||
image_id: v.image_id ?? null,
|
||||
image_url: v.image?.url ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
@@ -442,6 +515,57 @@ onMounted(async () => {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── variant image: table thumbnail + modal picker ── */
|
||||
.variant-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.variant-thumb--empty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #d9d9d9;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
.variant-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.variant-picker__item {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
padding: 2px;
|
||||
border: 2px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
.variant-picker__item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
}
|
||||
.variant-picker__item.is-on {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
.variant-picker__none {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 11px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ async function loginJWT() {
|
||||
// inactive in this port; the field is omitted rather than sent as null.
|
||||
await auth.login({ phone: mobile.value, password: password.value });
|
||||
// legacy did a hard `window.location.href = '/admin/my-data'`; a router push
|
||||
// reaches the same route without discarding the freshly-populated store.
|
||||
await router.push('/my-data');
|
||||
// reaches the equivalent route without discarding the freshly-populated store.
|
||||
await router.push('/dashboard/ecommerce');
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
message.error('خطا در ورود');
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
<BulkCustomer
|
||||
v-if="showAddButton"
|
||||
:key="editingId ?? 'new'"
|
||||
as-modal
|
||||
:open="modalOpen"
|
||||
:id="editingId"
|
||||
@update:open="modalOpen = $event"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<!--
|
||||
The sales agent's own detail screen ("مشاهده اطلاعات" on ReagentList.vue).
|
||||
|
||||
A sales agent is an AdminUser now (role=SALES_EXPERT), not a Customer —
|
||||
see admin-users.service.ts and admin-customers.service.ts's
|
||||
listReagents(). Reusing CustomerDetail.vue here would 404 (it reads
|
||||
customer/{id}, which no longer exists for an agent's id) and its "سند های
|
||||
مالی" tab has no equivalent for an agent, who carries no debt of their
|
||||
own. This shows the agent's profile plus the customers they referred and
|
||||
those customers' orders instead.
|
||||
-->
|
||||
<a-spin :spinning="loading">
|
||||
<a-card title="مشاهده اطلاعات کارشناس فروش">
|
||||
<a-descriptions bordered size="small" :column="3">
|
||||
<a-descriptions-item label="نام">{{ agent?.name ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="موبایل">{{ agent?.mobile ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="شعبه">{{ agent?.branch?.name ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="درصد کمیسیون">{{ agent?.percent ?? '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="وضعیت">
|
||||
<a-tag :color="agent?.active === 1 ? 'green' : 'red'">
|
||||
{{ agent?.active === 1 ? 'فعال' : 'غیر فعال' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="میزان فروش (گرم)">
|
||||
{{ agent?.grams_sell ?? 0 }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="stats mt-4">
|
||||
<a-tag color="blue">مشتریان معرفیشده: {{ agent?.referred_customers?.length ?? 0 }}</a-tag>
|
||||
<a-tag color="green">سفارشهای ثبتشده: {{ agent?.orders?.length ?? 0 }}</a-tag>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-tabs class="mt-4" default-active-key="referred">
|
||||
<a-tab-pane key="referred" tab="مشتریان معرفیشده">
|
||||
<a-table
|
||||
size="small"
|
||||
:data-source="agent?.referred_customers"
|
||||
:columns="referredColumns"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'active'">
|
||||
<a-tag :color="record.active === 1 ? 'green' : 'red'">
|
||||
{{ record.active === 1 ? 'فعال' : 'غیر فعال' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'city'">
|
||||
{{ record.city?.title ?? '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="orders" tab="سفارشهای مشتریان">
|
||||
<a-table
|
||||
size="small"
|
||||
:data-source="agent?.orders"
|
||||
:columns="orderColumns"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { jdate } from '../../../utils/filters';
|
||||
import api from '../../../api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const id = route.params.id as string;
|
||||
|
||||
const loading = ref(false);
|
||||
const agent = ref<Record<string, any> | undefined>(undefined);
|
||||
|
||||
const referredColumns = [
|
||||
{ title: 'نام', dataIndex: 'name' },
|
||||
{ title: 'موبایل', dataIndex: 'mobile' },
|
||||
{ title: 'شهر', dataIndex: 'city' },
|
||||
{ title: 'نوع', dataIndex: 'type_str' },
|
||||
{ title: 'وضعیت', dataIndex: 'active' },
|
||||
{ title: 'تاریخ ثبت', dataIndex: 'caj' },
|
||||
];
|
||||
|
||||
const orderColumns = [
|
||||
{ title: 'شماره سفارش', dataIndex: 'name' },
|
||||
{ title: 'مشتری', dataIndex: 'customer' },
|
||||
{ title: 'وزن', dataIndex: 'w' },
|
||||
{ title: 'وضعیت', dataIndex: 'status' },
|
||||
{ title: 'تاریخ', dataIndex: 'caj' },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const { data } = await api.get(`admin/customers-omde/reagent/${id}`);
|
||||
agent.value = data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -2,12 +2,31 @@
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/customer/reagentList/ReagentList.vue.
|
||||
Unlike the five wholesale lists this one uses inline TooltipBtn icons rather
|
||||
than a dropdown menu. Farsi copy and permission gates preserved verbatim,
|
||||
including legacy's typo in the "فعال سازی سفارش سفارش" tooltip.
|
||||
than a dropdown menu. Farsi copy and permission gates preserved verbatim.
|
||||
|
||||
FIXED: a sales agent is an AdminUser now (role SALES_EXPERT), not a
|
||||
Customer — see admin-users.service.ts / listReagents()'s header comment.
|
||||
"مشاهده اطلاعات"/"مشاهده سفارش"/"سند های مالی" used to route to
|
||||
/bulkCustomer/view/{id}, which reads customer/{id} and 404s for an
|
||||
AdminUser id; both now open AgentDetail.vue instead. توقف/فعالسازی
|
||||
"سفارش" (the `sts` switch) had no AdminUser equivalent — legacy's
|
||||
Customer.sts was an order-placement suspension flag an AdminUser simply
|
||||
doesn't have — so that action is dropped; only the account active/inactive
|
||||
switch remains, now posted to admin/users instead of the Customer
|
||||
endpoints it used to hit (which would 404/target the wrong row entirely).
|
||||
-->
|
||||
<div id="page-user-list">
|
||||
<a-card title="لیست کارشناسان فروش">
|
||||
<XTable ref="tbl" :model="model">
|
||||
<template #add>
|
||||
<ListPageHeader
|
||||
v-if="hasPermission('persons.create.seller')"
|
||||
rid="persons.create.seller"
|
||||
add-label="افزودن کارشناس فروش"
|
||||
@add="toAdd"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #city="{ record }">
|
||||
<div v-if="record.city && record.city.title">{{ record.city.title }}</div>
|
||||
<div v-else>-</div>
|
||||
@@ -41,41 +60,6 @@
|
||||
title="مشاهده اطلاعات"
|
||||
@click="toView(record.id)"
|
||||
/>
|
||||
<TooltipBtn
|
||||
v-if="hasPermission('persons.list.sellers.view.orders')"
|
||||
icon-class="w-5 h-5 text-primary"
|
||||
class="ml-3"
|
||||
icon="ListIcon"
|
||||
title="مشاهده سفارش"
|
||||
@click="viewOrder(record)"
|
||||
/>
|
||||
<TooltipBtn
|
||||
v-if="hasPermission('persons.list.sellers.view.documents')"
|
||||
icon-class="w-5 h-5 text-success"
|
||||
class="ml-3"
|
||||
icon="FileTextIcon"
|
||||
title="سند های مالی"
|
||||
@click="viewCash(record)"
|
||||
/>
|
||||
|
||||
<TooltipBtn
|
||||
v-if="record.sts == 1 && hasPermission('persons.list.sellers.unable.order')"
|
||||
icon-class="w-5 h-5 text-danger"
|
||||
class="ml-3"
|
||||
:icon="getStsIcon(record)"
|
||||
title="توقف سفارش"
|
||||
pop-title="آیا مطمئن هستید؟"
|
||||
@click="toggleSts(record)"
|
||||
/>
|
||||
<TooltipBtn
|
||||
v-if="record.sts !== 1 && hasPermission('persons.list.sellers.able.order')"
|
||||
icon-class="w-5 h-5 text-success"
|
||||
class="ml-3"
|
||||
:icon="getStsIcon(record)"
|
||||
title="فعال سازی سفارش سفارش"
|
||||
pop-title="آیا مطمئن هستید؟"
|
||||
@click="toggleSts(record)"
|
||||
/>
|
||||
|
||||
<TooltipBtn
|
||||
v-if="record.active == 1 && hasPermission('persons.list.sellers.unable')"
|
||||
@@ -108,6 +92,7 @@ import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import XTable from '../../../components/x-table/XTable.vue';
|
||||
import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
|
||||
import ListPageHeader from '../../../components/common/ListPageHeader.vue';
|
||||
import { buildReagentListModel } from './ReagentListModel';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import api from '../../../api/client';
|
||||
@@ -119,13 +104,9 @@ const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
const model = buildReagentListModel();
|
||||
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
|
||||
function getStsIcon(row: Record<string, any>) {
|
||||
return row.sts === 1 ? 'CheckIcon' : 'XIcon';
|
||||
}
|
||||
|
||||
async function toggleActive(record: Record<string, any>) {
|
||||
try {
|
||||
await api.post('customer/toggle-active', { id: record.id });
|
||||
await api.put(`users/${record.id}`, { is_active: record.active !== 1 });
|
||||
message.info('تغییرات ثبت شد ');
|
||||
await tbl.value?.fetch();
|
||||
} catch (e) {
|
||||
@@ -133,27 +114,14 @@ async function toggleActive(record: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSts(record: Record<string, any>) {
|
||||
try {
|
||||
await api.post('customer/toggle-sts', { id: record.id, sts: record.sts });
|
||||
message.info('تغییرات ثبت شد ');
|
||||
await tbl.value?.fetch();
|
||||
} catch (e) {
|
||||
message.error('خطا در تغییر وضعیت');
|
||||
}
|
||||
function toAdd() {
|
||||
router.push('/reagent/add');
|
||||
}
|
||||
|
||||
function toEdit(id: string) {
|
||||
router.push(`/reagent/edit/${id}`);
|
||||
}
|
||||
function toView(id: string) {
|
||||
router.push(`/bulkCustomer/view/${id}`);
|
||||
}
|
||||
function viewOrder(record: Record<string, any>) {
|
||||
router.push(`/bulkCustomer/view/${record.id}?tab=orders`);
|
||||
}
|
||||
function viewCash(record: Record<string, any>) {
|
||||
router.push(`/bulkCustomer/view/${record.id}?tab=documents`);
|
||||
router.push(`/reagent/view/${id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -94,8 +94,13 @@
|
||||
<a-button class="mt-4" type="primary" @click="saveTags">ذخیره برچسب ها</a-button>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- اسلایدر موبایل -->
|
||||
<a-tab-pane key="slider" tab="اسلایدر موبایل">
|
||||
<!-- اسلایدر موبایل — دسترسی جدا از بقیهی این صفحه، چون آپلود بنر عمومی
|
||||
سایت حساستر از ویرایش برند/برچسب است (custom.config.statics.slider) -->
|
||||
<a-tab-pane
|
||||
v-if="hasPermission('custom.config.statics.slider')"
|
||||
key="slider"
|
||||
tab="اسلایدر موبایل"
|
||||
>
|
||||
<div v-for="(s, index) in sliders" :key="index" class="slide">
|
||||
<h6 class="mobile-c">اسلاید {{ index + 1 }}</h6>
|
||||
<div class="row">
|
||||
@@ -275,9 +280,14 @@ async function saveTags() {
|
||||
}
|
||||
|
||||
// ── slider
|
||||
// Gated server-side by its own `custom.config.statics.slider` permission (not
|
||||
// the broad `config.statics` that guards the rest of this screen) — see
|
||||
// admin-misc.controller.ts. Uses the dedicated `const/slider` route rather
|
||||
// than the generic `const/:key`, which the server only accepts under that
|
||||
// specific permission.
|
||||
async function saveSliders() {
|
||||
try {
|
||||
await api.put('const/mobile_main_slider', { val: sliders.value });
|
||||
await api.put('const/slider', { val: sliders.value });
|
||||
message.success('اسلایدر ذخیره شد');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ذخیره');
|
||||
@@ -334,10 +344,10 @@ onMounted(async () => {
|
||||
await Promise.all([loadBrands(), loadCollections()]);
|
||||
const [t, s] = await Promise.all([
|
||||
api.get('const/__list__tags'),
|
||||
api.get('const/mobile_main_slider'),
|
||||
hasPermission('custom.config.statics.slider') ? api.get('const/slider') : Promise.resolve(null),
|
||||
]);
|
||||
tags.value = Array.isArray(t.data) ? t.data : [];
|
||||
sliders.value = Array.isArray(s.data) ? s.data : [];
|
||||
sliders.value = Array.isArray(s?.data) ? s.data : [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
|
||||
@@ -51,6 +51,14 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="نقش" :validate-status="err.role ? 'error' : ''" :help="err.role">
|
||||
<a-select v-model:value="form.role" placeholder="انتخاب کنید">
|
||||
<a-select-option v-for="r in roleOptions" :key="r.value" :value="r.value">
|
||||
{{ r.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="رمز عبور"
|
||||
:validate-status="err.password ? 'error' : ''"
|
||||
@@ -104,6 +112,14 @@
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="نقش" :validate-status="err.role ? 'error' : ''" :help="err.role">
|
||||
<a-select v-model:value="form.role" placeholder="انتخاب کنید">
|
||||
<a-select-option v-for="r in roleOptions" :key="r.value" :value="r.value">
|
||||
{{ r.title }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="رمز عبور"
|
||||
:validate-status="err.password ? 'error' : ''"
|
||||
@@ -153,12 +169,14 @@ const router = useRouter();
|
||||
const id = asModal ? props.id : (route.params.id as string | undefined);
|
||||
const loading = ref(false);
|
||||
const userTypes = ref<{ id: number; title: string }[]>([]);
|
||||
const roleOptions = ref<{ value: string; title: string }[]>([]);
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
fname: '',
|
||||
lname: '',
|
||||
mobile: '',
|
||||
ut: undefined,
|
||||
role: undefined,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
@@ -172,6 +190,9 @@ function validate(): boolean {
|
||||
if (!form.lname) err.lname = REQUIRED;
|
||||
if (!form.mobile) err.mobile = REQUIRED;
|
||||
if (form.ut === undefined || form.ut === null) err.ut = REQUIRED;
|
||||
// Required on create; on edit an admin already has a role, so leaving the
|
||||
// field alone must not force re-selection.
|
||||
if (!id && !form.role) err.role = REQUIRED;
|
||||
|
||||
// On create a password is mandatory; on edit, blank means "leave unchanged".
|
||||
if (!id && !form.password) err.password = REQUIRED;
|
||||
@@ -190,6 +211,9 @@ async function handleSubmit() {
|
||||
mobile: form.mobile,
|
||||
ut: form.ut,
|
||||
};
|
||||
// On edit, only send role if the operator actually changed it — an admin
|
||||
// without this field touched must keep their existing role.
|
||||
if (!id || form.role !== undefined) payload.role = form.role;
|
||||
if (form.password) payload.password = form.password;
|
||||
|
||||
if (id) {
|
||||
@@ -213,8 +237,12 @@ async function handleSubmit() {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('const/__list__ut');
|
||||
userTypes.value = data;
|
||||
const [{ data: types }, { data: roles }] = await Promise.all([
|
||||
api.get('const/__list__ut'),
|
||||
api.get('admin/users/role-options'),
|
||||
]);
|
||||
userTypes.value = types;
|
||||
roleOptions.value = roles;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
@@ -229,6 +257,7 @@ onMounted(async () => {
|
||||
lname: parts.slice(1).join(' '),
|
||||
mobile: data.mobile ?? '',
|
||||
ut: data.ut ?? undefined,
|
||||
role: data.role ?? undefined,
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user