parity(admin): D17 — SalesExpertProfile, AdminUser name parts, agent grid fields
Restores the sales-expert fields that were lost when a sales expert became
an AdminUser instead of a type=1 Customer row.
New SalesExpertProfile holds cityId, gramLevel, phone and nationalCode. A
separate table rather than columns on AdminUser, because these mean
something only for role = SALES_EXPERT. gramLevel is DECIMAL(14,4), matched
to Customer.goldCreditLimitGrams so the same credit limit has identical
precision on both tables. phone and nationalCode copy the reference exactly:
nullable, NOT unique, no format rule — createCustomer validates only mobile
and percent, and both fields are required:false on the reference form.
Inventing a uniqueness constraint would reject data the reference accepts.
reagent_id is deliberately NOT ported (D17). Every use of it in the
reference reads a CUSTOMER's reagent_id, which the target already models as
Customer.referredByAgentId. On a sales expert's own form it was a side
effect of the shared customers form: filling it removed that expert from
the selectable-agent list, because queryReagent filters on
whereNull('reagent_id'). The target's equivalent filters on role and active
only, so no agent can be dropped — strictly better than the reference.
AdminUser gains firstName/lastName with the same additive migration,
backfill and sync trigger as Customer, for all admins rather than only
sales experts. This closes D0 requirement 7, and every remaining
`fname: fullName, lname: ''` was fixed — six further sites across
admin-users, admin-comments, admin-documents, admin-orders and
admin-preorders.
listReagents now returns real city_id, city, gram_level, phone and ncode,
and left-joins so an expert without a profile still lists with empty cells
rather than vanishing. The two grid filters map through the relation. The
service upserts a profile on create and on a role change to SALES_EXPERT,
and the migration backfilled one for every existing expert.
ReagentCustomer.vue gains the four fields plus the city -> commission
autofill, ported verbatim including the reference's falsy test so a city
percent of 0 also falls through to the aux lookup (replicated, not fixed).
Tests: new sales-expert round trip 33/33, covering create with every field,
an edit form with each field populated separately, a byte-identical no-op
save, single-field edits, both grid filters with negative cases, the
left-join case and the referrer dropdown. «محمد علی» + «احمدی» survives
intact. Plus jest 32/32, auth-smoke 53/53, customer trigger 9/9, typecheck
clean on api and admin-panel. Migration dry-run twice in a rolled-back
transaction before applying; drift is zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3af31948e
commit
2194b3d4d9
@@ -67,10 +67,41 @@
|
||||
<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="شهر">
|
||||
<a-select
|
||||
:value="form.city_id"
|
||||
show-search
|
||||
allow-clear
|
||||
option-filter-prop="label"
|
||||
placeholder="شهر را انتخاب نمایید"
|
||||
:options="cities.map((c) => ({ value: c.id, label: c.title }))"
|
||||
@change="onCityChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="سقف اعتبار (برحسب گرم)">
|
||||
<a-input-number v-model:value="form.gram_level" class="w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="اطلاعات تکمیلی" class="mb-base">
|
||||
<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>
|
||||
</div>
|
||||
<a-form-item label="آدرس">
|
||||
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
</a-form-item>
|
||||
@@ -104,10 +135,19 @@ 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; percent?: number | null }>>([]);
|
||||
const err = reactive<Record<string, string>>({});
|
||||
|
||||
/** Snapshot of the server's record, captured after the edit form is populated. */
|
||||
@@ -127,6 +167,38 @@ function diffFromInitial(omit: string[] = []): Record<string, any> {
|
||||
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) {
|
||||
form.city_id = value;
|
||||
if (!value) return;
|
||||
|
||||
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) {
|
||||
console.log(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.percent = percent === null || percent === undefined ? undefined : Number(percent);
|
||||
}
|
||||
const REQUIRED = 'لطفا مقدار را وارد کنید';
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -145,11 +217,13 @@ async function handleSubmit() {
|
||||
// (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,
|
||||
name: `${form.fname} ${form.lname}`.trim(),
|
||||
role: 'SALES_EXPERT',
|
||||
};
|
||||
const payload: Record<string, any> = { ...fields, role: 'SALES_EXPERT' };
|
||||
// D0 — fname/lname are real columns and already travel in `fields`. `name`
|
||||
// goes only when a name part actually changed; sending it unconditionally
|
||||
// would re-submit the whole name on every save and defeat the diff-send.
|
||||
if (!id || 'fname' in fields || 'lname' in fields) {
|
||||
payload.name = `${form.fname} ${form.lname}`.trim();
|
||||
}
|
||||
if (id) {
|
||||
await api.put(`users/${id}`, payload);
|
||||
} else {
|
||||
@@ -166,6 +240,18 @@ 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) => ({
|
||||
id: c.id,
|
||||
title: c.title ?? c.name,
|
||||
percent: c.percent === null || c.percent === undefined ? null : Number(c.percent),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
if (id) {
|
||||
try {
|
||||
const { data } = await api.get(`admin/users/${id}`);
|
||||
@@ -175,6 +261,15 @@ 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,
|
||||
gram_level:
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user