diff --git a/apps/admin-panel/src/App.vue b/apps/admin-panel/src/App.vue index d0fa724..8628286 100644 --- a/apps/admin-panel/src/App.vue +++ b/apps/admin-panel/src/App.vue @@ -13,12 +13,13 @@ variables that ant itself ever consumed are carried over — `secondary-color` and `third-color` were never real ant variables (confirmed dead in the old LESS build) and stay only as the CSS custom properties in utilities.less. + + ant derives its light "primary background" steps from colorPrimary; for a + navy this dark the generated steps are mid-greys (#8d9296, #81858a), which + painted every selected option / tree node / picker cell grey. Those steps + are pinned to light navy tints instead (see `themeTokens`). --> - +
@@ -40,6 +41,17 @@ import { useLayoutStore } from './stores/layout'; import AppUpdateGate from './components/common/AppUpdateGate.vue'; import { userName } from './utils'; +const themeTokens = { + colorPrimary: '#1c2d56', + colorLink: '#1DA57A', + borderRadius: 6, + controlHeight: 36, + colorPrimaryBg: '#eef1f8', + colorPrimaryBgHover: '#e1e6f2', + colorPrimaryBorder: '#c5cde0', + colorPrimaryBorderHover: '#9ea9c6', +}; + const layout = useLayoutStore(); const vueAppClasses = ref([]); diff --git a/apps/admin-panel/src/assets/styles/rtl-overrides.css b/apps/admin-panel/src/assets/styles/rtl-overrides.css index 883ee68..e3e1da0 100644 --- a/apps/admin-panel/src/assets/styles/rtl-overrides.css +++ b/apps/admin-panel/src/assets/styles/rtl-overrides.css @@ -21,6 +21,73 @@ z-index: 2500000; } +/* + * Global light selected/active states — soft primary tint, never grey/dark. + * Wins over ant CSS-in-JS when specificity fails on nested menus / portals. + * Keep in sync with App.vue `antTheme` tokens. + */ +:root { + --selected-bg: rgba(28, 45, 86, 0.08); + --selected-bg-hover: rgba(28, 45, 86, 0.12); + --hover-bg: rgba(28, 45, 86, 0.04); +} + +.ant-menu-light .ant-menu-item-selected, +.ant-menu-light .ant-menu-item-selected:hover, +.ant-menu-inline .ant-menu-item-selected, +.ant-menu-inline .ant-menu-item-selected:hover, +.ant-menu-submenu .ant-menu-item-selected, +.ant-menu-submenu .ant-menu-item-selected:hover, +.vertical-nav-menu .ant-menu-item-selected, +.vertical-nav-menu .ant-menu-item-selected:hover, +.vertical-nav-menu .ant-menu-sub .ant-menu-item-selected, +.vertical-nav-menu .ant-menu-sub .ant-menu-item-selected:hover { + color: var(--primary-color, #1c2d56) !important; + background: var(--selected-bg) !important; + font-weight: 600; +} + +.ant-menu-light .ant-menu-item-selected .anticon, +.ant-menu-inline .ant-menu-item-selected .anticon, +.vertical-nav-menu .ant-menu-item-selected .anticon { + color: var(--primary-color, #1c2d56) !important; +} + +.ant-menu-light .ant-menu-item-selected::after, +.ant-menu-inline .ant-menu-item-selected::after, +.vertical-nav-menu .ant-menu-item-selected::after { + display: none !important; + border: none !important; +} + +/* Select / Cascader / TreeSelect / Transfer / Mentions / Dropdown */ +.ant-select-item-option-selected:not(.ant-select-item-option-disabled), +.ant-select-item-option-active:not(.ant-select-item-option-disabled) { + color: var(--primary-color, #1c2d56) !important; +} + +.ant-select-item-option-selected:not(.ant-select-item-option-disabled) { + background: var(--selected-bg) !important; + font-weight: 600; +} + +.ant-select-item-option-active:not(.ant-select-item-option-disabled):not( + .ant-select-item-option-selected + ) { + background: var(--hover-bg) !important; +} + +.ant-cascader-menu-item-active:not(.ant-cascader-menu-item-disabled), +.ant-tree-treenode-selected .ant-tree-node-content-wrapper, +.ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover, +.ant-dropdown-menu-item-selected, +.ant-dropdown-menu-submenu-title-selected, +.ant-transfer-list-content-item-checked, +.ant-mentions-dropdown-menu-item-active { + color: var(--primary-color, #1c2d56) !important; + background: var(--selected-bg) !important; +} + /* REMOVED: an `.ant-tabs-nav { direction: ltr }` override used to live here, forcing the tab strip itself to lay out left-to-right while each tab's content stayed RTL. That fought ADV4's own RTL handling (ConfigProvider's diff --git a/apps/admin-panel/src/assets/styles/utilities.less b/apps/admin-panel/src/assets/styles/utilities.less index 9b4a159..0b3c205 100644 --- a/apps/admin-panel/src/assets/styles/utilities.less +++ b/apps/admin-panel/src/assets/styles/utilities.less @@ -102,11 +102,6 @@ body #app .ant-card { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.03); } -// ── Sidebar menu selection colour ──────────────────────────────────── -body #app .ant-menu-item-selected { - background-color: #f1f1f1; -} - // ── Full-height list pages ────────────────────────────────────────── // Main.vue stretches #content-area/.content-area__content to fill the // viewport below the navbar; the routed page's own root needs to pick that diff --git a/apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue b/apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue index 67f772e..3be2612 100644 --- a/apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue +++ b/apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue @@ -1,22 +1,17 @@ + - +
diff --git a/apps/admin-panel/src/views/apps/customer/AddCustomer.vue b/apps/admin-panel/src/views/apps/customer/AddCustomer.vue index d984e0b..60e1e65 100644 --- a/apps/admin-panel/src/views/apps/customer/AddCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/AddCustomer.vue @@ -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. --> -
-
- - - - - +
+
+ + + +
+

افزودن شخص

+

نوع شخص را انتخاب و اطلاعات او را تکمیل کنید.

+
+
- - - +
+ diff --git a/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue b/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue index c6d6ca4..52afcc3 100644 --- a/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue @@ -18,93 +18,148 @@ operator can still type in directly. --> - - -
- - - - - - - - - - - - - - - -
-
+ +
+ +
+
+
+ +
+

اطلاعات پایه

+

مشخصات هویتی و اطلاعات ورود کارشناس

+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
- -
- - - - - - - - - - - - - - - - - - - -
-
+
+
+ +
+

اطلاعات تجاری

+

کد و مشخصات فروشگاه

+
+
+
+ + + + + + + + + +
+
- -
- - - - - - - +
+
+ +
+

آدرس

+

انتخاب شهر، درصد کمیسیون را بر اساس همان شهر پر می‌کند

+
+
+ + + + + + + +
- - - -
- ثبت اطلاعات + + +
+ +
+ فیلدهای ستاره‌دار الزامی هستند. + انصراف + + + ثبت اطلاعات + +
@@ -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>({ 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>([]); const err = reactive>({}); - -/** Snapshot of the server's record, captured after the edit form is populated. */ const initial = reactive>({}); -/** The subset of `form` that differs from `initial`, minus `omit` keys. */ function diffFromInitial(omit: string[] = []): Record { const out: Record = {}; for (const [k, v] of Object.entries(form)) { @@ -161,23 +220,10 @@ function diffFromInitial(omit: string[] = []): Record { return out; } -/** Legacy `uploadIndexImage` — MyUpload emitted the whole response object. */ function onAvatar(file: Record) { 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 = { ...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 () => { diff --git a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue index 6c8a6e6..1e05cdd 100644 --- a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue @@ -136,118 +136,163 @@ - + - -
- - - - - - - - - - - - - - - - - - -
-
+
+ +
+
+
+ +
+

اطلاعات پایه

+

مشخصات هویتی و اطلاعات ورود مشتری

+
+
+
+ + + + + + + + + + + + + + + + + + + +
+
- -
- - - - {{ item.title }} - - - - - - - - - - - - - - - - - {{ r.name }} - - - - - - - - - -
-
+
+
+ +
+

اطلاعات تجاری

+

نوع کاربری و مشخصات فروشگاه

+
+
+
+ + + + {{ item.title }} + + + + + + + + + + + + +
+
- -
- - - - - - +
+
+ +
+

آدرس

+

انتخاب شهر، درصد را بر اساس همان شهر پر می‌کند

+
+
+ + + + + + +
-
- -
- - - - - - - - - -
-
+ + +
+ +
+ فیلدهای ستاره‌دار الزامی هستند. + انصراف + + + ثبت اطلاعات +
@@ -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>({ 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>({}); - -/** - * 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>({}); -/** The subset of `form` that differs from `initial`. */ function diffFromInitial(): Record { const out: Record = {}; for (const [k, v] of Object.entries(form)) { @@ -363,11 +382,6 @@ function diffFromInitial(): Record { 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 = { ...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 () => { }); - + diff --git a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue index bf2cd9c..bd4aa17 100644 --- a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue +++ b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue @@ -8,41 +8,48 @@ the customer endpoints. --> - - -
- - - - - - - - - - - - - - - - + +
+
+
+
+ +
+

{{ id ? 'ویرایش مشتری' : 'افزودن مشتری' }}

+

مشخصات مشتری تک

+
+
+
+ + + + + + + + + + + + + +
+
+
- ثبت اطلاعات - +
+ فیلدهای ستاره‌دار الزامی هستند. + انصراف + + + ثبت اطلاعات + +
@@ -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 () => { }); - + diff --git a/apps/admin-panel/src/views/apps/product/AddProduct.vue b/apps/admin-panel/src/views/apps/product/AddProduct.vue index 9277f3c..b5242b3 100644 --- a/apps/admin-panel/src/views/apps/product/AddProduct.vue +++ b/apps/admin-panel/src/views/apps/product/AddProduct.vue @@ -29,212 +29,313 @@ column on ProductImage and is dropped, same as the other schema-less fields noted above. --> -
- - - -
- - - - - - -
- - -
-
- -
- -
-
- - -
-
- -
- -
-
-
- - -
- - - - - - - - - - - - سایت - گوشی - سایت و گوشی - - - - - - - {{ c.title }} - - - - - - - - {{ b.title }} - - - - - - - - - {{ c.title }} - - - -
- -
- - - -
- -
- - - -
-
- - - - - - - - - - -
- ثبت اطلاعات +
+ + +
+ + + +
+

{{ id ? 'ویرایش محصول' : 'افزودن محصول' }}

+

اطلاعات، تنوع‌ها و تصاویر محصول را تکمیل کنید.

- - + + {{ form.is_active ? 'فعال' : 'غیر فعال' }} + +
+ + +
+ +
+
+
+ +
+

اطلاعات پایه

+

نام، کد و توضیحات محصول

+
+
+ + + + + + + + +
+ + + + + + + سایت + گوشی + سایت و گوشی + + +
+ + + + +
+ + +
+
+ +
+

تنوع ها

+

وزن، اجرت و موجودی هر مدل از محصول

+
+ + + افزودن تنوع + +
+ + + + + افزودن اولین تنوع + + + +
+ + + +
+
+ +
+
+ +
+

گالری تصاویر

+

تصاویر جداگانه برای نمایش در سایت و اپلیکیشن موبایل

+
+
+ + + + + +
+ + + + +
+
+ + + +
+ + +
+ + + تغییرات تنوع‌ها و تصاویر پس از «ثبت اطلاعات» ذخیره می‌شوند. + + انصراف + + + ثبت اطلاعات + +
+
+
- - - - - - - - - - - - - - - + + - +
+ +
- - - - - - +
+ + + +
+ + + + + + + + + +
+ + + فعال + پیش سفارش + + +
+

تصویر بارگذاری‌شده به گالری سایت محصول هم اضافه می‌شود.

@@ -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) { 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) { 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) ); } diff --git a/apps/admin-panel/src/views/user/Permission.vue b/apps/admin-panel/src/views/user/Permission.vue index 5f36f61..de08205 100644 --- a/apps/admin-panel/src/views/user/Permission.vue +++ b/apps/admin-panel/src/views/user/Permission.vue @@ -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(() => - 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(() => { + 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([]); + +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) { diff --git a/apps/api/src/modules/admin-branches/admin-branches.controller.ts b/apps/api/src/modules/admin-branches/admin-branches.controller.ts index 3890829..b8d7df1 100644 --- a/apps/api/src/modules/admin-branches/admin-branches.controller.ts +++ b/apps/api/src/modules/admin-branches/admin-branches.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; import { AdminBranchesService } from './admin-branches.service'; import { TableQueryDto } from '../admin-table/table-query.dto'; -import { RequirePermission } from '../auth/guards'; +import { RequirePermission, SuperAdminOnly } from '../auth/guards'; /** * Legacy endpoints (BranchController + WorkshopController): @@ -12,6 +12,7 @@ import { RequirePermission } from '../auth/guards'; * GET user/list -> the branch-manager picker */ @Controller('admin/branches') +@SuperAdminOnly() export class AdminBranchesController { constructor(private readonly branches: AdminBranchesService) {} diff --git a/apps/api/src/modules/admin-categories/admin-categories.controller.ts b/apps/api/src/modules/admin-categories/admin-categories.controller.ts index d70beb9..ce75f2a 100644 --- a/apps/api/src/modules/admin-categories/admin-categories.controller.ts +++ b/apps/api/src/modules/admin-categories/admin-categories.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common'; import { AdminCategoriesService } from './admin-categories.service'; import { TableQueryDto } from '../admin-table/table-query.dto'; -import { RequirePermission } from '../auth/guards'; +import { RequirePermission, SuperAdminOnly } from '../auth/guards'; /** * Legacy endpoints (CategoryController + the collection screens): @@ -37,6 +37,7 @@ export class AdminCategoriesController { // ── collections ("سینی") ───────────────────────────────────────── @Post('collections/list') @HttpCode(200) + @SuperAdminOnly() @RequirePermission('collection.list') listCollections(@Body() query: TableQueryDto) { return this.categories.listCollections(query); @@ -44,12 +45,14 @@ export class AdminCategoriesController { @Post('collections') @HttpCode(200) + @SuperAdminOnly() @RequirePermission('collection.create') createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) { return this.categories.createCollection(body); } @Put('collections/:id') + @SuperAdminOnly() @RequirePermission('collection.list.edit') updateCollection( @Param('id') id: string, @@ -59,6 +62,7 @@ export class AdminCategoriesController { } @Delete('collections/:id') + @SuperAdminOnly() @RequirePermission('collection.list.edit') removeCollection(@Param('id') id: string) { return this.categories.removeCollection(id); diff --git a/apps/api/src/modules/admin-customers/admin-customers.controller.ts b/apps/api/src/modules/admin-customers/admin-customers.controller.ts index b7a35de..e1341ad 100644 --- a/apps/api/src/modules/admin-customers/admin-customers.controller.ts +++ b/apps/api/src/modules/admin-customers/admin-customers.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; import { AdminCustomersService, type OmdeScope } from './admin-customers.service'; import { TableQueryDto } from '../admin-table/table-query.dto'; -import { CurrentAdmin, RequirePermission } from '../auth/guards'; +import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards'; import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; /** @@ -31,6 +31,7 @@ export class AdminCustomersController { @Post('banker/list') @HttpCode(200) + @SuperAdminOnly() @RequirePermission('persons.list.client.omde') listBanker(@Body() query: TableQueryDto) { return this.list(query, 'banker'); @@ -38,6 +39,7 @@ export class AdminCustomersController { @Post('shopkeeper/list') @HttpCode(200) + @SuperAdminOnly() @RequirePermission('persons.list.client.omde') listShopkeeper(@Body() query: TableQueryDto) { return this.list(query, 'shopkeeper'); @@ -60,6 +62,7 @@ export class AdminCustomersController { /** Legacy POST single-customer — the retail customer list. */ @Post('single/list') @HttpCode(200) + @SuperAdminOnly({ allowBranch: true }) @RequirePermission('persons.list.client.single') listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) { return this.customers.listSingle(query, admin.branchId ?? undefined); @@ -68,6 +71,7 @@ export class AdminCustomersController { /** Legacy `POST single` — singleCustomer.vue, the retail customer form. */ @Post('single') @HttpCode(200) + @SuperAdminOnly({ allowBranch: true }) @RequirePermission('persons.create') createSingle(@Body() data: Record) { return this.customers.createSingle(data); diff --git a/apps/api/src/modules/admin-documents/admin-documents.controller.ts b/apps/api/src/modules/admin-documents/admin-documents.controller.ts index 9a683e1..a8f48fd 100644 --- a/apps/api/src/modules/admin-documents/admin-documents.controller.ts +++ b/apps/api/src/modules/admin-documents/admin-documents.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; import { AdminDocumentsService } from './admin-documents.service'; import { TableQueryDto } from '../admin-table/table-query.dto'; -import { CurrentAdmin, RequirePermission } from '../auth/guards'; +import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards'; import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; /** @@ -46,6 +46,7 @@ export class AdminDocumentsController { // ── wallet settings (views/manage/wallet.vue) ──────────────────── @Get('wallet/config') + @SuperAdminOnly() @RequirePermission('config.wallet') getWalletConfig() { return this.documents.getSetting('wallet.config'); @@ -53,12 +54,14 @@ export class AdminDocumentsController { @Post('wallet/config') @HttpCode(200) + @SuperAdminOnly() @RequirePermission('config.wallet') setWalletConfig(@Body() body: Record) { return this.documents.setSetting('wallet.config', body); } @Get('wallet/birthday/config') + @SuperAdminOnly() @RequirePermission('config.wallet') getBirthdayConfig() { return this.documents.getSetting('wallet.birthday'); @@ -66,6 +69,7 @@ export class AdminDocumentsController { @Post('wallet/birthday/config') @HttpCode(200) + @SuperAdminOnly() @RequirePermission('config.wallet') setBirthdayConfig(@Body() body: Record) { return this.documents.setSetting('wallet.birthday', body); diff --git a/apps/api/src/modules/admin-orders/admin-orders.controller.ts b/apps/api/src/modules/admin-orders/admin-orders.controller.ts index bfabb88..aa377cc 100644 --- a/apps/api/src/modules/admin-orders/admin-orders.controller.ts +++ b/apps/api/src/modules/admin-orders/admin-orders.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common'; import { AdminOrdersService } from './admin-orders.service'; import { TableQueryDto } from '../admin-table/table-query.dto'; import { UpdateOrderStatusDto } from './update-status.dto'; -import { CurrentAdmin, RequirePermission } from '../auth/guards'; +import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards'; import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; /** @@ -20,6 +20,7 @@ export class AdminOrdersController { /** Legacy POST single-order. POST, not GET, because XTable posts its filters. */ @Post('list') @HttpCode(200) + @SuperAdminOnly({ allowBranch: true }) @RequirePermission('order.list') list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) { /** @@ -66,6 +67,7 @@ export class AdminOrdersController { /** Legacy POST /order from the admin order builder (views/order/create.vue). */ @Post('create') @HttpCode(200) + @SuperAdminOnly({ allowBranch: true }) @RequirePermission('order.create') createFromAdmin( @Body() @@ -82,13 +84,17 @@ export class AdminOrdersController { /** Legacy GET order/type. */ @Get('type-options') + @SuperAdminOnly() @RequirePermission('order.create') typeOptions() { return this.orders.typeOptions(); } + // The three routes below also serve the wholesale and sourcing lists, whose + // staff hold order.omde / order.tamin rather than the super-admin-only + // order.list. @Get('status-options') - @RequirePermission('order.list') + @RequirePermission('order.list', 'order.omde', 'order.tamin') statusOptions() { return this.orders.statusOptions(); } @@ -96,13 +102,13 @@ export class AdminOrdersController { /** Legacy POST single/order-sts/edit. */ @Post('status') @HttpCode(200) - @RequirePermission('order.list') + @RequirePermission('order.list', 'order.omde', 'order.tamin') setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) { return this.orders.setStatus(dto, admin.id); } @Get(':id') - @RequirePermission('order.list') + @RequirePermission('order.list', 'order.omde', 'order.tamin') findOne(@Param('id') id: string) { return this.orders.findOne(id); } diff --git a/apps/api/src/modules/admin-products/admin-products.controller.ts b/apps/api/src/modules/admin-products/admin-products.controller.ts index 2342fc7..e085e38 100644 --- a/apps/api/src/modules/admin-products/admin-products.controller.ts +++ b/apps/api/src/modules/admin-products/admin-products.controller.ts @@ -1,7 +1,9 @@ import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; import { AdminProductsService } from './admin-products.service'; import { TableQueryDto } from '../admin-table/table-query.dto'; -import { RequirePermission } from '../auth/guards'; +import { CurrentAdmin, RequirePermission, SuperAdminOnly } from '../auth/guards'; +import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; +import { isSuperAdmin } from '../auth/super-admin'; /** * Legacy endpoints (ProductController): @@ -51,6 +53,7 @@ export class AdminProductsController { */ @Post('variants/create') @HttpCode(200) + @SuperAdminOnly() @RequirePermission('order.create') createVariant(@Body() body: { product_id: string; title: string; grams: number }) { return this.products.createVariant(body); @@ -78,6 +81,7 @@ export class AdminProductsController { @Get('collections') + @SuperAdminOnly() @RequirePermission('product.list') collections() { return this.products.collections(); @@ -100,7 +104,9 @@ export class AdminProductsController { @Post('create') @HttpCode(200) @RequirePermission('product.create') - create(@Body() data: Record) { + create(@Body() data: Record, @CurrentAdmin() admin: AuthenticatedAdmin) { + // Trays are super-admin-only; anyone else's product is created without them. + if (!isSuperAdmin(admin.mobile)) delete data.ciny_ids; return this.products.create(data); } diff --git a/apps/api/src/modules/admin-workshops/admin-workshops.controller.ts b/apps/api/src/modules/admin-workshops/admin-workshops.controller.ts index 0a0e784..1ab20ff 100644 --- a/apps/api/src/modules/admin-workshops/admin-workshops.controller.ts +++ b/apps/api/src/modules/admin-workshops/admin-workshops.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; import { AdminBranchesService } from '../admin-branches/admin-branches.service'; import { TableQueryDto } from '../admin-table/table-query.dto'; -import { RequirePermission } from '../auth/guards'; +import { RequirePermission, SuperAdminOnly } from '../auth/guards'; /** * Workshops — legacy views/workshop/*, served by WorkshopController: @@ -14,6 +14,9 @@ import { RequirePermission } from '../auth/guards'; * screen's workshop picker); the CRUD arrived with Batch 11. Both live here so * there is one owner of /admin/workshops, and the logic sits in * AdminBranchesService alongside the branch code it mirrors. + * + * Managing workshops is super-admin-only; the list stays open because the + * made-to-order screen picks a workshop from it. */ @Controller('admin/workshops') export class AdminWorkshopsController { @@ -27,24 +30,28 @@ export class AdminWorkshopsController { @Post() @HttpCode(200) + @SuperAdminOnly() @RequirePermission('workshop.create') create(@Body() data: Record) { return this.branches.createWorkshop(data); } @Get('active/:id') + @SuperAdminOnly() @RequirePermission('workshop.list') toggleActive(@Param('id') id: string) { return this.branches.toggleWorkshopActive(id); } @Get(':id') + @SuperAdminOnly() @RequirePermission('workshop.list') findOne(@Param('id') id: string) { return this.branches.findWorkshop(id); } @Put(':id') + @SuperAdminOnly() @RequirePermission('workshop.list') update(@Param('id') id: string, @Body() data: Record) { return this.branches.updateWorkshop(id, data); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index f84bbb0..f7205e7 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -14,11 +14,12 @@ import { AdminSessionService } from '../sessions/admin-session.service'; import { envList, envOr, envOrNull, pickExactUri } from '../../common/env'; import type { LoginDto } from './dto/login.dto'; import { compareLocalHash } from './password-hash'; +import { SUPER_ADMIN_MOBILE, isSuperAdmin, withSuperAdminRids } from './super-admin'; export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash'; // TEMPORARY hardcoded super-admin login (username = password). Remove before going live. -const STATIC_ADMIN_MOBILE = '09128081392'; +const STATIC_ADMIN_MOBILE = SUPER_ADMIN_MOBILE; @Injectable() export class AuthService { @@ -99,8 +100,11 @@ export class AuthService { role: user.role, ut: user.userTypeId, branch_id: user.branchId, + is_super_admin: isSuperAdmin(user.mobile), api_token: apiToken, - roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })), + roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map( + (rid) => ({ rid }), + ), ...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}), }; } @@ -222,8 +226,11 @@ export class AuthService { role: user.role, ut: user.userTypeId, branch_id: user.branchId, + is_super_admin: isSuperAdmin(user.mobile), api_token: apiToken, - roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })), + roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map( + (rid) => ({ rid }), + ), }; } @@ -345,7 +352,8 @@ export class AuthService { ut: user.userTypeId, branch_id: user.branchId, user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null, - permission: (user.userType?.permissions ?? []).map((p) => p.rid), + is_super_admin: isSuperAdmin(user.mobile), + permission: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)), }; } } diff --git a/apps/api/src/modules/auth/guards.spec.ts b/apps/api/src/modules/auth/guards.spec.ts new file mode 100644 index 0000000..430115d --- /dev/null +++ b/apps/api/src/modules/auth/guards.spec.ts @@ -0,0 +1,85 @@ +import { ForbiddenException, type ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PermissionsGuard, RequirePermission, SuperAdminOnly } from './guards'; +import type { AuthenticatedAdmin } from './jwt.strategy'; +import { SUPER_ADMIN_MOBILE, withSuperAdminRids } from './super-admin'; + +class Routes { + @SuperAdminOnly() + @RequirePermission('config.wallet') + superOnly() {} + + @SuperAdminOnly({ allowBranch: true }) + @RequirePermission('order.list') + superOrBranch() {} + + @RequirePermission('order.list', 'order.omde', 'order.tamin') + anyOf() {} + + open() {} +} + +function admin(over: Partial = {}): AuthenticatedAdmin { + return { + id: 'a1', + fullName: 'x', + mobile: '09120000000', + role: 'SUPER_ADMIN', + userTypeId: 85, + branchId: null, + permissions: ['config.wallet', 'order.list', 'order.omde'], + ...over, + }; +} + +function run(handler: keyof Routes, user: AuthenticatedAdmin) { + const ctx = { + getHandler: () => Routes.prototype[handler], + getClass: () => Routes, + switchToHttp: () => ({ getRequest: () => ({ user }) }), + } as unknown as ExecutionContext; + return new PermissionsGuard(new Reflector()).canActivate(ctx); +} + +describe('PermissionsGuard — super-admin-only routes', () => { + it('admits the super admin', () => { + expect(run('superOnly', admin({ mobile: SUPER_ADMIN_MOBILE }))).toBe(true); + }); + + it('refuses any other admin, even one holding the rid and the SUPER_ADMIN role', () => { + expect(() => run('superOnly', admin())).toThrow(ForbiddenException); + }); + + it('refuses a branch admin unless the route allows branches', () => { + expect(() => run('superOnly', admin({ branchId: 'b1' }))).toThrow(ForbiddenException); + expect(run('superOrBranch', admin({ branchId: 'b1' }))).toBe(true); + expect(() => run('superOrBranch', admin())).toThrow(ForbiddenException); + }); + + it('still requires the rid from a branch admin', () => { + expect(() => run('superOrBranch', admin({ branchId: 'b1', permissions: [] }))).toThrow( + ForbiddenException, + ); + }); + + it('leaves routes without the decorator alone', () => { + expect(run('open', admin({ permissions: [] }))).toBe(true); + }); +}); + +describe('PermissionsGuard — several rids', () => { + it('admits a holder of any one of them', () => { + expect(run('anyOf', admin({ permissions: ['order.tamin'] }))).toBe(true); + }); + + it('refuses a holder of none', () => { + expect(() => run('anyOf', admin({ permissions: ['product.list'] }))).toThrow(ForbiddenException); + }); +}); + +describe('withSuperAdminRids', () => { + it('adds the super-admin rids for the super admin only', () => { + expect(withSuperAdminRids(SUPER_ADMIN_MOBILE, [])).toContain('config.wallet'); + expect(withSuperAdminRids('09120000000', ['product.list'])).toEqual(['product.list']); + }); +}); diff --git a/apps/api/src/modules/auth/guards.ts b/apps/api/src/modules/auth/guards.ts index 28280e5..9df87b7 100644 --- a/apps/api/src/modules/auth/guards.ts +++ b/apps/api/src/modules/auth/guards.ts @@ -9,14 +9,31 @@ import { import { AuthGuard } from '@nestjs/passport'; import { Reflector } from '@nestjs/core'; import type { AuthenticatedAdmin } from './jwt.strategy'; +import { isSuperAdmin } from './super-admin'; /** Marks a route as reachable without a token (login, health checks). */ export const IS_PUBLIC_KEY = 'isPublic'; export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); -/** Requires the caller to hold this rid (ancestors count — see expandWithParents). */ +/** + * Requires the caller to hold this rid (ancestors count — see expandWithParents). + * With several rids, holding any one of them is enough. + */ export const REQUIRED_RID_KEY = 'requiredRid'; -export const RequirePermission = (rid: string) => SetMetadata(REQUIRED_RID_KEY, rid); +export const RequirePermission = (...rids: [string, ...string[]]) => + SetMetadata(REQUIRED_RID_KEY, rids); + +/** + * Restricts a route (or a whole controller) to the super admin — see + * super-admin.ts. `allowBranch` also admits an admin bound to a branch, because + * the branch panel serves its staff from the same retail endpoints. + */ +export const SUPER_ADMIN_ONLY_KEY = 'superAdminOnly'; +export interface SuperAdminOnlyOptions { + allowBranch?: boolean; +} +export const SuperAdminOnly = (options: SuperAdminOnlyOptions = {}) => + SetMetadata(SUPER_ADMIN_ONLY_KEY, options); /** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */ export const CurrentAdmin = createParamDecorator( @@ -48,23 +65,32 @@ export class JwtAuthGuard extends AuthGuard('jwt') { } } -/** Enforces @RequirePermission(rid) against the expanded permission list. */ +/** Enforces @SuperAdminOnly() and @RequirePermission(rid) against the expanded permission list. */ @Injectable() export class PermissionsGuard implements CanActivate { constructor(private readonly reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRid = this.reflector.getAllAndOverride(REQUIRED_RID_KEY, [ - context.getHandler(), - context.getClass(), - ]); - if (!requiredRid) return true; + const targets = [context.getHandler(), context.getClass()]; + const superAdminOnly = this.reflector.getAllAndOverride( + SUPER_ADMIN_ONLY_KEY, + targets, + ); + const requiredRids = this.reflector.getAllAndOverride(REQUIRED_RID_KEY, targets); + if (!superAdminOnly && !requiredRids) return true; const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user; if (!user) throw new ForbiddenException(); - if (!user.permissions.includes(requiredRid)) { - throw new ForbiddenException(`Missing permission: ${requiredRid}`); + if (superAdminOnly && !isSuperAdmin(user.mobile)) { + if (!(superAdminOnly.allowBranch && user.branchId)) { + throw new ForbiddenException('این بخش فقط در دسترس مدیر ارشد سامانه است'); + } + } + if (!requiredRids) return true; + + if (!requiredRids.some((rid) => user.permissions.includes(rid))) { + throw new ForbiddenException(`Missing permission: ${requiredRids.join(' | ')}`); } return true; } diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts index e328948..73b4022 100644 --- a/apps/api/src/modules/auth/jwt.strategy.ts +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service'; import { envOr } from '../../common/env'; import { AdminSessionService } from '../sessions/admin-session.service'; import { expandWithParents } from './acl.constants'; +import { withSuperAdminRids } from './super-admin'; export interface JwtPayload { sub: string; @@ -89,7 +90,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { // Permissions are re-read on every request rather than baked into the token, // so a permission change takes effect immediately instead of at next login. - const rids = user.userType?.permissions.map((p) => p.rid) ?? []; + const rids = withSuperAdminRids(user.mobile, user.userType?.permissions.map((p) => p.rid) ?? []); return { id: user.id, diff --git a/apps/api/src/modules/auth/super-admin.ts b/apps/api/src/modules/auth/super-admin.ts new file mode 100644 index 0000000..08cf543 --- /dev/null +++ b/apps/api/src/modules/auth/super-admin.ts @@ -0,0 +1,48 @@ +/** + * The one account allowed to see the super-admin-only features (retail + * customers and orders, bankers, walk-in shopkeepers, branches and workshops, + * retail promotions, wallet management, trays). Matched on mobile, not on + * AdminRole.SUPER_ADMIN: that role can be assigned to any admin from the + * users form, this account cannot. + */ +export const SUPER_ADMIN_MOBILE = '09128081392'; + +/** + * Rids of those features. The admin panel no longer offers them on the + * permission screen (except to the super admin), so the super admin is granted + * them here whatever its user type holds. + */ +export const SUPER_ADMIN_RIDS = [ + 'persons.create.single', + 'persons.list.client.single', + 'persons.list.client.single.view', + 'persons.list.client.single.edit', + 'branch', + 'branch.create', + 'branch.list', + 'workshop', + 'workshop.create', + 'workshop.list', + 'order.create', + 'order.list', + 'order.list.view', + 'order.list.sts', + 'order.list.sts.view', + 'order.list.sts.remove', + 'order.list.sts.submit', + 'order.list.view.edit', + 'order.list.pdf', + 'offers.create.tak', + 'offers.list.tak.edit', + 'offers.list.tak.remove', + 'config.wallet', +] as const; + +export function isSuperAdmin(mobile: string | null | undefined): boolean { + return mobile?.trim() === SUPER_ADMIN_MOBILE; +} + +/** The raw rids an admin holds, plus SUPER_ADMIN_RIDS for the super admin. */ +export function withSuperAdminRids(mobile: string | null | undefined, rids: string[]): string[] { + return isSuperAdmin(mobile) ? [...new Set([...rids, ...SUPER_ADMIN_RIDS])] : rids; +} diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md index 50ce12f..510b8da 100644 --- a/docs/parity/CHANGELOG.md +++ b/docs/parity/CHANGELOG.md @@ -1343,3 +1343,95 @@ apps depend on and the method had no test at all. ⚠️ Nothing click-tested: t cart re-sync and the favourites screen are the two worth a manual pass. _No data was deleted in this entry._ + +--- + +## 2026-10-14 — Owner request (not parity) — add/edit product UI/UX redesign + +- **What:** redesigned the Add/Edit Product page (`AddProduct.vue` / `EditProduct.vue`) + with a modern e-commerce dashboard architecture. Introduced a 2-column layout + (content/variants in primary 1fr column; publication status and media uploaders + in dedicated 340px sidebar); added a top page header with breadcrumb navigation, + accounting-sync indicators, and dual fast-save actions; redesigned the variants + table with badge columns, inline actions, empty states, and clean weight/wage + formatting; streamlined the variant modal; unified the gallery slots with clean + site/mobile segmented tabs and compact upload grids; strictly preserved the + PROTECTED §B variant gallery image picker (64x64 grid with `.is-on` selection). + All schemas, fields, accounting locks (D27), permissions, and payloads preserved. +- **Why:** owner requested professional, efficient, high-end SaaS UI/UX overhaul + for Add Product. +- **Reference:** n/a (visual layer overhaul; all fields, validations, and logic preserved) +- **Target:** `apps/admin-panel/src/views/apps/product/AddProduct.vue` +- **Matrix row:** n/a + +_No schema change, no data deleted._ + +--- + +## 2026-10-14 — Owner request (not parity) — add/edit customer UI/UX redesign + +- **What:** redesigned the Add Person (`/customer/add`) page and its wholesale + (`bulkCustomer.vue`), sales expert (`ReagentCustomer.vue`), and retail + (`singleCustomer.vue`) forms. Replaced nested double/triple card boxes with + semantic `.form-section` panels featuring clean icon badges, titles, and + descriptions; improved input grid rhythm (3-column responsive layout with + clear required asterisks and placeholders); redesigned document upload blocks + as dedicated dash-bordered upload cards; upgraded the sales-expert avatar + slot; added a top page header with back navigation; unified the action bar + with loading indicators and explicit cancel/back buttons. Both full-page and + modal forms (`asModal`) upgraded. +- **Why:** owner requested professional SaaS-grade UI/UX overhaul starting with + the Add Person forms. +- **Reference:** n/a (visual layer overhaul; all fields, validations, and logic preserved) +- **Target:** `apps/admin-panel/src/views/apps/customer/AddCustomer.vue`, + `apps/admin-panel/src/views/apps/customer/bulkCustomer.vue`, + `apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue`, + `apps/admin-panel/src/views/apps/customer/singleCustomer.vue` +- **Matrix row:** n/a + +_No schema change, no data deleted._ + +--- + +## 2026-10-14 — Owner request (not parity) — admin dashboard visual unify + +- **What:** unified dashboard chrome with the rest of the admin panel — removed + radial wash / Mentzer badge / gold period chips / multi-shadow cards / KPI lift + hover and coloured side bars; muted period text; flat navy KPI icons; equal + sales-summary cells. Same data, block order, charts, sorts, and API. +- **Why:** owner asked for a principled restyle; PROTECTED §A chrome updated with + approval. +- **Reference:** n/a (target-only dashboard; reference dashboards were demo husks) +- **Target:** `apps/admin-panel/src/views/Dashboard.vue`, + `apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue`, + `docs/parity/PROTECTED.md` §A, + `docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md` +- **Matrix row:** n/a + +_No schema change, no data deleted._ + +--- + +## 2026-10-14 — Owner request (not parity) — super-admin-only admin features (D30) + +- **What:** retail customers, bankers, walk-in shopkeepers, branches and + workshops, the «مشتری تک» tab, trays («سینی ها», product field + Const tab), + retail orders (create + list), retail promotions (add/edit/delete) and wallet + management are reserved to the 09128081392 account — hidden in the menu and + pages, blocked by route, 403 from the API, and removed from the permission tree + for everyone else. +- **Why:** owner ruling D30. Deliberately not reference behaviour. +- **Reference:** n/a +- **Target:** API — `auth/super-admin.ts` (new), `auth/guards.ts` + (`@SuperAdminOnly`, multi-rid `@RequirePermission`), `auth/jwt.strategy.ts`, + `auth/auth.service.ts` (`is_super_admin`), and the branches, workshops, + customers, orders, documents, categories and products controllers. Panel — + `utils/superAdmin.ts` (new), `stores/users.ts`, `stores/auth.ts`, + `router/index.ts`, `router/admin.ts` (16 routes), `layouts/navMenuItems.ts`, + `NavMenuNode.vue`, `VerticalNavMenu.vue`, `AddCustomer.vue`, `AddProduct.vue`, + `Const.vue`, `promotion_list.vue`, `Permission.vue`. +- **Tests:** `auth/guards.spec.ts` (new, 8 cases). api 342/342 across 31 + suites; api `tsc` and admin-panel `vue-tsc` clean. ⚠️ Not click-tested. +- **Matrix row:** n/a + +_No schema change, no data deleted._ diff --git a/docs/parity/DECISIONS.md b/docs/parity/DECISIONS.md index 97c3a55..18f1cff 100644 --- a/docs/parity/DECISIONS.md +++ b/docs/parity/DECISIONS.md @@ -1513,3 +1513,40 @@ act-as را پاک می‌کند و او را از سیستم بیرون نمی (در مرجع این مسئله وجود نداشت چون کل نشست با توکن عوض می‌شد.) ثبت شده در `ISSUES.md` به‌عنوان EXP-01. + +## D30 — بخش‌های «فقط سوپر ادمین» در پنل مدیریت (۲۰۲۶/۱۰/۱۴) + +**خواسته مالک:** این بخش‌ها فقط به سوپر ادمین (۰۹۱۲۸۰۸۱۳۹۲) نشان داده شوند و در +سطح دسترسی‌ها هم نباشند: لیست مشتریان تک، لیست بنکداران، لیست مغازه‌داران حضوری، +شعب و کارگاه، تب «مشتری تک» در افزودن شخص، فیلد «سینی ها» در فرم محصول، ثبت +سفارش تک و لیست سفارش‌های تک، «افزودن تخفیف تک»، و «مدیریت کیف پول». + +**پاسخ‌های مالک:** +- سوپر ادمین = **فقط حساب با موبایل ۰۹۱۲۸۰۸۱۳۹۲**، نه هر ادمینی که نقش `SUPER_ADMIN` دارد. +- اعمال **کامل**: مخفی در پنل + بستن آدرس مستقیم صفحه + ۴۰۳ از API. +- «سینی ها» در **هر دو جا**: فیلد فرم محصول و تب «مدیریت ثابت ها». +- تخفیف تک: **افزودن + ویرایش + حذف**، و هر سه از سطح دسترسی‌ها حذف. + +**سازوکار.** `@SuperAdminOnly()` در `apps/api/src/modules/auth/guards.ts`، که +`PermissionsGuard` سراسری آن را اعمال می‌کند؛ شماره و فهرست rid ها در +`auth/super-admin.ts`. به سوپر ادمین rid های این بخش‌ها همیشه داده می‌شود +(`withSuperAdminRids`) تا با ذخیره‌ی دوباره‌ی سطح دسترسی‌اش از دست نروند. در پنل: +`users.isSuperAdmin` (از `is_super_admin` در login و `users/show`)، `superAdminOnly` +روی منو و route، و حذف این rid ها از درخت صفحه دسترسی‌ها برای غیر سوپر ادمین. + +**تصمیم‌های جانبی (برای نشکستن چیزهای دیگر):** +- **پنل شعبه** همان endpoint های مشتری تک و سفارش تک را صدا می‌زند؛ این endpoint ها + برای ادمینِ دارای `branchId` هم باز ماند (`allowBranch`). بدون این، کارکنان شعبه + قفل می‌شدند. پیامد: ادمینی که شعبه دارد از API به این چهار endpoint دسترسی دارد. +- **سوپر ادمین در درخت دسترسی‌ها همه را می‌بیند** تا بتواند این rid ها را به سطح + دسترسی کارکنان شعبه بدهد. دیگران آن‌ها را نمی‌بینند، و rid هایی که سطح از قبل + دارد هنگام ذخیره توسط آن‌ها **حفظ** می‌شود (حذف بی‌صدا نمی‌شود). +- `admin/orders/:id`، `status` و `status-options` بین سفارش تک، عمده و تامین مشترک‌اند + و `order.list` می‌خواستند؛ حالا هر کدام از `order.list` / `order.omde` / `order.tamin` + کافی است (`RequirePermission` چند rid می‌پذیرد). +- لیست کارگاه‌ها (`admin/workshops/list`) **باز ماند**: انتخاب کارگاه در «سفارش های + ساختنی» از آن است. بقیه‌ی endpoint های کارگاه و همه‌ی شعب بسته شدند. +- **تخفیف تک فقط در پنل بسته شد، نه در API**: API تک و عمده را از هم تشخیص نمی‌دهد + (PROMO-TAK-01). +- بنکداران/مغازه‌داران و سینی‌ها rid مشترک با بخش‌های دیگر دارند (`persons.list.client.omde`، + `collection.*`)؛ پس از درخت حذف نشدند و با `@SuperAdminOnly` / `isSuperAdmin` بسته شدند. diff --git a/docs/parity/ISSUES.md b/docs/parity/ISSUES.md index f5f2e28..b15711b 100644 --- a/docs/parity/ISSUES.md +++ b/docs/parity/ISSUES.md @@ -291,3 +291,11 @@ wrong and both are right here. Its one real gap was that nothing ever went stale — no TTL, and `force` used only by pull-to-refresh — which READ-01/02 fix at the point that matters: opening the screen. The policy is now written at the top of `queryCache.ts` so the next person does not re-introduce a TTL. + +## Super-admin-only features — found while building D30 (2026-10-14) + +| ID | Severity | Where | Description | Status | +|---|---|---|---|---| +| **PROMO-TAK-01** | Medium | TGT `admin-promotions.service.ts:147`, `PromotionForm.vue:67` | `type_customer` (1 = تک, 2 = عمده) is not stored; it is derived from the scope — CUSTOMER scope gives 1, any other gives 2. The «تک» form hides the customer scope, so everything it creates comes back as عمده, while the عمده form *can* pick the customer scope and produce a «تک» row. The API also never checks the `.tak` rids and deletes on `offers.list.edit`. Consequence for D30: retail promotions can only be restricted in the panel, not in the API. | Open — logged, not fixed | +| **CINY-01** | Low | TGT `admin-products.service.ts` `update()` | `create()` saves `ciny_ids` (trays), `update()` ignores them, so changing a product's trays on edit is silently lost. | Open — logged, not fixed | + diff --git a/docs/parity/PROTECTED.md b/docs/parity/PROTECTED.md index 5776d7b..f960d7e 100644 --- a/docs/parity/PROTECTED.md +++ b/docs/parity/PROTECTED.md @@ -16,8 +16,9 @@ discovered in Phase 0, proposed for protection · **[?]** = needs your ruling. ## A. Admin panel — Dashboard · **[brief]** -**Location:** `apps/admin-panel/src/views/Dashboard.vue` (327 lines) — one file -serving both `/dashboard/ecommerce` and `/dashboard/analytics`. +**Location:** `apps/admin-panel/src/views/Dashboard.vue` — one file serving both +`/dashboard/ecommerce` and `/dashboard/analytics`. KPI chrome lives in +`components/statistics-cards/StatisticsCardLine.vue`. **Gate:** rid `dashboard.view`; lacking it redirects to `my-data` (not a 403 page). **Data:** a single `GET admin/dashboard/overview` on mount, then client-side sorts (`reagent` desc by `grams_sell`; `lastOrder` desc by `w`). @@ -31,18 +32,22 @@ Contents, in render order: 4. 2 donuts — وضعیت سفارشات (8-entry colour array) · تفکیک عمده/تک 5. Horizontal bar + an always-rendered agent table (کارشناس فروش / میزان فروش گرم / تعداد سفارش) 6. Vertical bar «محصولات پرفروش» with an `a-empty` fallback -7. `a-descriptions` «میزان فروش» — 3 columns incl. a computed average +7. Sales summary «میزان فروش» — 3 equal cells incl. a computed average 8. Table «سفارشات هفته جاری» — `pageSize` 10, `sts_str` as a coloured `a-tag`, `caj` via `jdate` -Fixed palette `#1c2d56 / #28c76f / #ff9f43 / #ea5455`. **No filters, no date range, -no export, no refresh** — that is the intended design, not a gap. +**Chrome (owner-approved 2026-10-14):** panel-unified look — plain layout background, +simple page header (no brand badge), bordered ant cards, muted period labels (not gold +pills), flat KPI tiles (soft navy icon well, no lift hover / side accent bar), equal +sales-summary cells. Chart series palette stays `#1c2d56 / #28c76f / #ff9f43 / #ea5455`. +**No filters, no date range, no export, no refresh** — that is the intended design, not a gap. +Spec: `docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md`. > Why this matters for parity: the reference's two dashboards > (`DashboardECommerce.vue`, `DashboardAnalytics.vue`) were found in the earlier > port to be **mostly commented-out Vuexy demo markup**, with ApexCharts imported > but never used in live markup (`docs/CHANGELOG-PORT-HISTORY.md`). So there is -> essentially nothing to port *into* this dashboard. Phase 1 will confirm and -> record that, and will not touch this file. +> essentially nothing to port *into* this dashboard. Visual chrome may change only +> with explicit owner approval (this section). --- diff --git a/docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md b/docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md new file mode 100644 index 0000000..52c3d75 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-admin-dashboard-visual-unify-design.md @@ -0,0 +1,43 @@ +# Admin dashboard visual unify — design spec + +**Date:** 2026-10-14 +**Status:** Approved (owner) +**Scope:** Visual chrome only on the admin dashboard + +## Goal + +Make the admin dashboard look like the rest of the admin panel: calm, bordered cards, brand navy, even spacing. Remove marketing-style gradients, gold chips, lift hover, and multi-colour KPI accents that made the page feel scattered. + +## Decisions + +| Decision | Choice | +|---|---| +| Content / API | Unchanged — same fields, sorts, charts, `GET dashboard/overview` | +| Approach | Unify with panel chrome (not a new IA, not polish-only) | +| Filters / refresh / export | Out of scope (PROTECTED §A) | + +## Visual rules + +1. **Page background** — no radial gold/navy wash; no negative margin on the dashboard container. +2. **Header** — title «نمای کلی» + muted subtitle «آمار ۳۰ روز گذشته · به‌روز از سرور»; remove «منتزر» badge. +3. **Chart/table cards** — light border / default ant Card look; drop heavy multi-layer shadows. +4. **Period labels** — muted text next to the title (not gold pills). +5. **KPI cards** — flat; soft grey icon well + `#1c2d56` icon/value; no side accent bar; no translateY hover. +6. **Sales summary** — three equal cells; no gold accent on the average. +7. **Spacing** — ~16px gap between blocks. +8. **Chart series colours** — keep `#1c2d56 / #28c76f / #ff9f43 / #ea5455` and −45° x-label rotation. + +## Files + +- `apps/admin-panel/src/views/Dashboard.vue` +- `apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue` +- `docs/parity/PROTECTED.md` §A (document chrome change) +- `docs/parity/CHANGELOG.md` + +## States + +Loading (`a-spin`), empty product chart (`a-empty`), tables, and fetch error handling stay as today. + +## Done when + +Same stats and charts render; page reads as part of the admin shell (no gradient / gold chip / card lift); desktop and mobile grids still work.