diff --git a/.gitignore b/.gitignore index ec8fe0a..4d2fe39 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist/ *.log .DS_Store apps/api/generated/ +apps/api/storage/uploads/* +!apps/api/storage/uploads/.gitkeep diff --git a/apps/admin-panel/.gitignore b/apps/admin-panel/.gitignore new file mode 100644 index 0000000..7c3144f --- /dev/null +++ b/apps/admin-panel/.gitignore @@ -0,0 +1,12 @@ +# Build output (legacy vue.config.js outputDir equivalents) +dist/ +branchDist/ +node_modules/ +*.tsbuildinfo + +# Never commit compiled siblings next to sources — the previous scaffold +# had 17 stale .js/.d.ts files checked in alongside its .ts files. +src/**/*.js +src/**/*.d.ts +!src/env.d.ts +!src/types/**/*.d.ts diff --git a/apps/admin-panel/index.html b/apps/admin-panel/index.html index 511af28..dd3f66e 100644 --- a/apps/admin-panel/index.html +++ b/apps/admin-panel/index.html @@ -1,12 +1,15 @@ - + - Gold Bank — Admin Panel + هنزاگلد — پنل مدیریت -
+
+ +
+
diff --git a/apps/admin-panel/package.json b/apps/admin-panel/package.json index 2e9e5ab..2bfb519 100644 --- a/apps/admin-panel/package.json +++ b/apps/admin-panel/package.json @@ -4,17 +4,30 @@ "private": true, "scripts": { "dev": "vite", + "dev:branch": "vite --mode branch", "build": "vue-tsc -b && vite build", + "build:branch": "vue-tsc -b && vite build --mode branch", + "preview": "vite preview", "lint": "eslint \"src/**/*.{ts,vue}\"" }, "dependencies": { + "@ant-design/icons-vue": "^6.1.0", "@gold-bank/shared-types": "workspace:*", + "@vueuse/core": "^9.13.0", + "ant-design-vue": "^3.2.20", + "axios": "^1.7.7", + "lodash": "^4.17.21", + "moment-jalaali": "^0.10.0", + "pinia": "^2.2.0", "vue": "^3.5.0", - "vue-router": "^4.4.0", - "pinia": "^2.2.0" + "vue-router": "^4.4.0" }, "devDependencies": { + "@types/lodash": "^4.17.0", "@vitejs/plugin-vue": "^5.1.0", + "autoprefixer": "^10.4.2", + "less": "^4.1.2", + "postcss": "^8.4.5", "typescript": "^5.5.0", "vite": "^5.4.0", "vue-tsc": "^2.1.0" diff --git a/apps/admin-panel/src/App.vue b/apps/admin-panel/src/App.vue index 6ea327d..ef0e63e 100644 --- a/apps/admin-panel/src/App.vue +++ b/apps/admin-panel/src/App.vue @@ -1,23 +1,98 @@ - - - + +function handleWindowResize() { + layout.updateWindowWidth(window.innerWidth); + // Set --vh property + document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`); +} + +function handleScroll() { + layout.updateWindowScrollY(window.scrollY); +} + +watch(() => layout.theme, (val) => toggleClassInBody(val)); + +// legacy created(): set dir before first paint +document.documentElement.setAttribute('dir', themeConfig.rtl ? 'rtl' : 'ltr'); + +onMounted(() => { + // legacy showed a one-shot welcome toast when a `login` flag was left in localStorage + const rawUser = localStorage.getItem(userName); + const user = rawUser ? JSON.parse(rawUser) : undefined; + const login = localStorage.getItem('login') || undefined; + if (user && login) { + notification.open({ + message: `${user && user.name ? user.name : ' کاربر '} عزیز `, + placement: 'topLeft', + description: 'به هنزاگلد خوش آمدید', + style: { + color: 'var(--primary-color)', + marginLeft: `20px`, + }, + }); + localStorage.removeItem('login'); + } + + toggleClassInBody(themeConfig.theme); + layout.updateWindowWidth(window.innerWidth); + + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); + + window.addEventListener('resize', handleWindowResize); + window.addEventListener('scroll', handleScroll); +}); + +onUnmounted(() => { + window.removeEventListener('resize', handleWindowResize); + window.removeEventListener('scroll', handleScroll); +}); + diff --git a/apps/admin-panel/src/App.vue.d.ts b/apps/admin-panel/src/App.vue.d.ts deleted file mode 100644 index 7711b0c..0000000 --- a/apps/admin-panel/src/App.vue.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; -export default _default; diff --git a/apps/admin-panel/src/App.vue.js b/apps/admin-panel/src/App.vue.js deleted file mode 100644 index 3b80295..0000000 --- a/apps/admin-panel/src/App.vue.js +++ /dev/null @@ -1,39 +0,0 @@ -/// -debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ -const __VLS_ctx = {}; -let __VLS_components; -let __VLS_directives; -// CSS variable injection -// CSS variable injection end -__VLS_asFunctionalElement(__VLS_intrinsicElements.header, __VLS_intrinsicElements.header)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.nav, __VLS_intrinsicElements.nav)({}); -const __VLS_0 = {}.RouterLink; -/** @type {[typeof __VLS_components.RouterLink, typeof __VLS_components.routerLink, typeof __VLS_components.RouterLink, typeof __VLS_components.routerLink, ]} */ ; -// @ts-ignore -const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({ - to: "/", -})); -const __VLS_2 = __VLS_1({ - to: "/", -}, ...__VLS_functionalComponentArgsRest(__VLS_1)); -__VLS_3.slots.default; -var __VLS_3; -__VLS_asFunctionalElement(__VLS_intrinsicElements.main, __VLS_intrinsicElements.main)({}); -const __VLS_4 = {}.RouterView; -/** @type {[typeof __VLS_components.RouterView, typeof __VLS_components.routerView, ]} */ ; -// @ts-ignore -const __VLS_5 = __VLS_asFunctionalComponent(__VLS_4, new __VLS_4({})); -const __VLS_6 = __VLS_5({}, ...__VLS_functionalComponentArgsRest(__VLS_5)); -var __VLS_dollars; -const __VLS_self = (await import('vue')).defineComponent({ - setup() { - return {}; - }, -}); -export default (await import('vue')).defineComponent({ - setup() { - return {}; - }, -}); -; /* PartiallyEnd: #4569/main.vue */ diff --git a/apps/admin-panel/src/api/client.d.ts b/apps/admin-panel/src/api/client.d.ts deleted file mode 100644 index b0433f3..0000000 --- a/apps/admin-panel/src/api/client.d.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types'; -export interface CustomerSummary { - id: string; - fullName: string; - mobile: string; - goldDebtGrams: string; - fiatDebtRial: string; - goldCreditLimitGrams: string; -} -export interface ProductVariantSummary { - id: string; - title: string; - rashWeightGrams: string; - defaultWagePercent: string; -} -export interface ProductSummary { - id: string; - title: string; - variants: ProductVariantSummary[]; -} -export interface OrderDetail { - id: string; - orderNumber: string; - status: string; - totalGoldWeightGrams: string; - wageWeightGrams: string; - flatRialCharge: string; - isSuspendedOverCreditLimit: boolean; - deliveredAt: string | null; - items: Array<{ - id: string; - quantity: number; - computedWeightGrams: string; - }>; - statusEvents: Array<{ - toStatus: string; - note: string | null; - createdAt: string; - }>; -} -export declare const api: { - listCustomers: () => Promise; - getCustomerBalance: (id: string) => Promise; - listProducts: () => Promise; - createOrder: (dto: CreateOrderDto) => Promise; - getOrder: (id: string) => Promise; - updateOrderStatus: (id: string, status: string, note?: string) => Promise; -}; diff --git a/apps/admin-panel/src/api/client.js b/apps/admin-panel/src/api/client.js deleted file mode 100644 index 5b3eb4c..0000000 --- a/apps/admin-panel/src/api/client.js +++ /dev/null @@ -1,20 +0,0 @@ -const BASE = '/api'; -async function request(path, options) { - const res = await fetch(`${BASE}${path}`, { - headers: { 'Content-Type': 'application/json' }, - ...options, - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`API ${path} failed (${res.status}): ${body}`); - } - return res.json(); -} -export const api = { - listCustomers: () => request('/customers'), - getCustomerBalance: (id) => request(`/accounting/customers/${id}/balance`), - listProducts: () => request('/catalog/products'), - createOrder: (dto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }), - getOrder: (id) => request(`/orders/${id}`), - updateOrderStatus: (id, status, note) => request(`/orders/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status, note }) }), -}; diff --git a/apps/admin-panel/src/api/client.ts b/apps/admin-panel/src/api/client.ts index 0d31613..d120924 100644 --- a/apps/admin-panel/src/api/client.ts +++ b/apps/admin-panel/src/api/client.ts @@ -1,61 +1,74 @@ -import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types'; +// Ported from legacy henza_admin/src/axios.js. +// +// Legacy pointed at the live PHP backend: +// baseURL = 'https://henzagold.com/oapi/admin/' +// The new backend is the local NestJS API. Legacy endpoint names are translated +// to clean REST paths by ./endpoints.ts — see the mapping-layer note there. +import axios from 'axios'; +import { tokenName } from '../utils'; +import { mapEndpoint } from './endpoints'; -const BASE = '/api'; +export const baseURL = import.meta.env.VITE_API_BASE_URL ?? '/api/'; -async function request(path: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE}${path}`, { - headers: { 'Content-Type': 'application/json' }, - ...options, - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`API ${path} failed (${res.status}): ${body}`); - } - return res.json(); -} - -export interface CustomerSummary { - id: string; - fullName: string; - mobile: string; - goldDebtGrams: string; - fiatDebtRial: string; - goldCreditLimitGrams: string; -} - -export interface ProductVariantSummary { - id: string; - title: string; - rashWeightGrams: string; - defaultWagePercent: string; -} - -export interface ProductSummary { - id: string; - title: string; - variants: ProductVariantSummary[]; -} - -export interface OrderDetail { - id: string; - orderNumber: string; - status: string; - totalGoldWeightGrams: string; - wageWeightGrams: string; - flatRialCharge: string; - isSuspendedOverCreditLimit: boolean; - deliveredAt: string | null; - items: Array<{ id: string; quantity: number; computedWeightGrams: string }>; - statusEvents: Array<{ toStatus: string; note: string | null; createdAt: string }>; -} - -export const api = { - listCustomers: () => request('/customers'), - getCustomerBalance: (id: string) => request(`/accounting/customers/${id}/balance`), - listProducts: () => request('/catalog/products'), - createOrder: (dto: CreateOrderDto) => - request('/orders', { method: 'POST', body: JSON.stringify(dto) }), - getOrder: (id: string) => request(`/orders/${id}`), - updateOrderStatus: (id: string, status: string, note?: string) => - request(`/orders/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status, note }) }), +const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', }; + +const initialToken = localStorage.getItem(tokenName); +if (initialToken) { + headers['Authorization'] = 'Bearer ' + initialToken; +} + +const ax = axios.create({ + baseURL, + headers, +}); + +/** + * Legacy re-set the auth header on every request so a token acquired after boot + * was picked up without recreating the instance. Kept — plus the endpoint + * rewrite, and without legacy's habit of sending a literal "Bearer null" when + * no token existed. + */ +ax.interceptors.request.use( + (config) => { + if (config.url) { + config.url = mapEndpoint(config.url); + } + const token = localStorage.getItem(tokenName); + if (token) { + config.headers['Authorization'] = 'Bearer ' + token; + } else { + delete config.headers['Authorization']; + } + return config; + }, + (error) => Promise.reject(error), +); + +/** + * NOT IN LEGACY: legacy had no response interceptor at all, so an expired token + * produced a silent failure on every subsequent call with no path back to login. + * A 401 now clears the stale session and redirects once. + * + * The redirect is a hard location assignment rather than a router push, to avoid + * importing the router here (circular: router -> stores -> client -> router). + */ +let redirecting = false; +ax.interceptors.response.use( + (response) => response, + (error) => { + if (error?.response?.status === 401 && !redirecting) { + const onLoginPage = window.location.pathname.endsWith('/auth/login'); + if (!onLoginPage) { + redirecting = true; + localStorage.removeItem(tokenName); + window.location.assign(`${import.meta.env.BASE_URL}auth/login`); + } + } + return Promise.reject(error); + }, +); + +export default ax; diff --git a/apps/admin-panel/src/api/endpoints.ts b/apps/admin-panel/src/api/endpoints.ts new file mode 100644 index 0000000..4a4b234 --- /dev/null +++ b/apps/admin-panel/src/api/endpoints.ts @@ -0,0 +1,267 @@ +/** + * Legacy → new endpoint mapping layer. + * + * This is the "لایه‌ی نگاشت" half of the agreed architecture: the NestJS API + * exposes clean REST, while ported views keep calling the endpoint names they + * were written against. Rather than rewriting ~114 call sites (and risking a + * silent typo in each), views keep their legacy path and this layer rewrites it. + * + * The legacy paths are irregular in ways worth preserving exactly: + * - inconsistent leading slash: 'brand' and '/brand' both appear + * - verbs in the path with the wrong HTTP method: POST 'delete/product/:id' + * - pseudo-namespaced constants: '/const/__list__ut' + * - reads done over POST: POST 'get/variants' + * + * Anything not listed here passes through untouched, so a view calling an + * already-clean path keeps working. Add entries as each batch of views lands. + */ + +/** Strips the leading slash so 'brand' and '/brand' resolve identically. */ +function normalize(url: string): string { + return url.replace(/^\/+/, ''); +} + +/** + * Exact-match rewrites: legacy path -> new path. + * Only endpoints whose backend counterpart exists today are listed; the rest + * arrive with their view batch. + */ +const EXACT: Record = { + // ── file upload ─────────────────────────────────────────────────── + // Legacy posted multipart to 'file/create' from MyUpload.vue, + // SingleUploadInput.vue, UploadInput.vue and PodcastUploadInput.vue. + 'file/create': 'admin/files/create', + + // ── auth (Phase 1) ──────────────────────────────────────────────── + 'admin-login': 'admin/auth/login', + 'users/show': 'admin/auth/me', + + // ── orders (Batch 3) ────────────────────────────────────────────── + // Legacy posted the grid request to an absolute URL ending in 'single-order'. + 'single-order': 'admin/orders/list', + 'single/order-sts/edit': 'admin/orders/status', + 'major/order/table': 'admin/orders/wholesale/list', + 'order-tamin': 'admin/orders/tamin/list', + // Legacy used a DIFFERENT path for the wholesale status edit than the single + // one ('order-sts/edit' vs 'single/order-sts/edit'); both land on the same + // endpoint here because the new schema has a single Order model. + 'order-sts/edit': 'admin/orders/status', + 'delete-order': 'admin/orders/delete', + + // ── custom / made-to-order requests (PreOrder) ──────────────────── + 'get-custom-orders': 'admin/pre-orders/list', + + // ── wholesale customers (Batch 4) ───────────────────────────────── + // Five legacy endpoints, one per near-identical list view. + customer: 'admin/customers-omde/list', + banker: 'admin/customers-omde/banker/list', + shopkeeper: 'admin/customers-omde/shopkeeper/list', + 'major/verify/table': 'admin/customers-omde/non-verified/list', + 'major/archive/table': 'admin/customers-omde/archived/list', + reagent: 'admin/customers-omde/reagent/list', + 'customer/toggle-active': 'admin/customers-omde/toggle-active', + 'customer/toggle-sts': 'admin/customers-omde/toggle-sts', + 'major/verify': 'admin/customers-omde/verify', + + // ── single (retail) customers + comments (Batch 5) ──────────────── + 'single-customer': 'admin/customers-omde/single/list', + // legacy views/apps/customer/singleCustomer.vue posts the retail form here + single: 'admin/customers-omde/create', + 'customer/create': 'admin/customers-omde/create', + 'query/reagent': 'admin/customers-omde/query/reagent', + 'wallet/charge': 'admin/customers-omde/wallet/charge', + 'comment/table': 'admin/comments/list', + 'comment/sts': 'admin/comments/sts', + comment: 'admin/comments', + + // ── admin order builder (views/order/create.vue) ────────────────── + order: 'admin/orders/create', + 'order/type': 'admin/orders/type-options', + 'add/variant': 'admin/products/variants/create', + 'product/variant': 'admin/products/by-ids', + + // ── products (Batch 6) ──────────────────────────────────────────── + products: 'admin/products/list', + 'product/create': 'admin/products/create', + 'get/variants': 'admin/products/variants/list', + 'change/percent/count': 'admin/products/change-percent/count', + 'change/all': 'admin/products/change-percent/apply', + + // ── financial documents + wallet (Batch 8) ──────────────────────── + 'cash/table': 'admin/documents/list', + cash: 'admin/documents', + 'query/customer': 'admin/documents/query/customer', + 'wallet/table': 'admin/documents/wallet/list', + 'wallet/config': 'admin/documents/wallet/config', + 'wallet/birthday/config': 'admin/documents/wallet/birthday/config', + + // ── promotions + exclusions (Batch 9) ───────────────────────────── + 'product/variant/promotion': 'admin/promotions', + 'major/promotion': 'admin/promotions/list', + autopromotion: 'admin/promotions/auto', + ignore: 'admin/promotions/exclusions', + + // ── admin users + permission matrix (Batch 10) ──────────────────── + // NOTE 'users/show' is mapped above to admin/auth/me and, being an exact + // match, wins over the users/{id} pattern below. + users: 'admin/users', + 'users/list': 'admin/users/list', + 'const/__list__ut': 'admin/users/types', + const: 'admin/users/types', + 'roles/create': 'admin/users/roles', + + // ── branch allocations / "فاکتور" (Batch 12) ────────────────────── + 'branch/product': 'admin/invoices', + 'branch/product/table': 'admin/invoices/list', + 'branch/list': 'admin/invoices/branch-list', + + // ── branches + workshops (Batch 11) ─────────────────────────────── + branch: 'admin/branches', + 'branch/table': 'admin/branches/list', + workshop: 'admin/workshops', + 'user/list': 'admin/branches/user-list', + + // ── cities, sms, notify, newsletter, statics, consts (Batch 13) ─── + cities: 'admin/misc/cities', + sms: 'admin/misc/sms', + 'sms/table': 'admin/misc/sms/list', + notify: 'admin/misc/notify', + 'notify/table': 'admin/misc/notify/list', + 'notify/customer': 'admin/misc/notify/customer', + // legacy's own opaque name for the newsletter list + ddf: 'admin/misc/newsletter/list', + 'fashion/table': 'admin/misc/newsletter/list', + 'log/list/customer': 'admin/misc/log/customers', + 'dashboard/link': 'admin/misc/dashboard-links', + 'get-analytics': 'admin/dashboard/omde', + 'get-analytics-tak': 'admin/dashboard/tak', + 'brand/create': 'admin/misc/brands', + // NOTE 'ciny' itself is already mapped in the categories block below. + 'ciny/create': 'admin/categories/collections', + + // ── lookups ─────────────────────────────────────────────────────── + 'cities/list': 'admin/cities/list', + + // ── reports (both screens rendered permanently empty without these) ── + 'report/order-item': 'admin/dashboard/report/order-item', + 'report/product': 'admin/dashboard/report/product', + 'workshop/table': 'admin/workshops/list', + brand: 'admin/products/brands', + /** + * Legacy `POST category` is ONE endpoint serving two callers: the category + * grid (CategoryTbl's url) and the product form's picker, which reads + * `data.rows` off the same response. Both map here, so the `{rows, query}` + * envelope satisfies each. + */ + category: 'admin/categories/list', + 'category/': 'admin/categories', + ciny: 'admin/categories/collections/list', +}; + +/** + * Pattern rewrites for paths carrying an id. Evaluated in order, first match wins. + */ +const PATTERNS: { test: RegExp; to: (m: RegExpMatchArray) => string }[] = [ + // ──────────────────────────────────────────────────────────────────── + // Id/key-carrying paths whose EXACT siblings were mapped but whose own + // shapes were not, so they fell through to the pass-through branch and + // 404'd against the API root. Every one of these has a real backend route + // — these were mapping omissions, not missing features. + // + // NOTE all of them are safe against their EXACT counterparts because + // mapEndpoint consults EXACT in full before reaching PATTERNS. + // ──────────────────────────────────────────────────────────────────── + + // Cities: the two percent lookups are two-segment, so they precede cities/:id. + { test: /^cities\/get-percent-aux\/(.+)$/, to: (m) => `admin/misc/cities/percent-aux/${m[1]}` }, + { test: /^cities\/get-percent\/(.+)$/, to: (m) => `admin/misc/cities/percent/${m[1]}` }, + { test: /^cities\/(.+)$/, to: (m) => `admin/misc/cities/${m[1]}` }, + + // Settings constants by key — drives the برچسب ها and اسلایدر موبایل tabs. + { test: /^const\/(.+)$/, to: (m) => `admin/misc/const/${m[1]}` }, + + // Brand edit/delete ('brand/create' stays an EXACT match). + { test: /^brand\/(.+)$/, to: (m) => `admin/misc/brands/${m[1]}` }, + + // Customer activity log. Legacy's path is 'log/ago/{id}'. + { test: /^log\/ago\/(.+)$/, to: (m) => `admin/misc/log/customer/${m[1]}` }, + + // Notifications: mark-read is two segments, so it precedes notify/:id. + { test: /^notify\/read\/(.+)$/, to: (m) => `admin/misc/notify/read/${m[1]}` }, + { test: /^notify\/(.+)$/, to: (m) => `admin/misc/notify/${m[1]}` }, + + // Retail customer edit. 'single/order-sts/edit' is EXACT and still wins. + { test: /^single\/(.+)$/, to: (m) => `admin/customers-omde/${m[1]}` }, + + // Custom order by id: legacy PUT/DELETE custom-order/{id} + { test: /^custom-order\/(.+)$/, to: (m) => `admin/pre-orders/${m[1]}` }, + { test: /^get-custom-order\/(.+)$/, to: (m) => `admin/pre-orders/${m[1]}` }, + // Wholesale customers: archive toggle + per-customer dashboard + { test: /^major\/archive\/(.+)$/, to: (m) => `admin/customers-omde/archive/${m[1]}` }, + { test: /^major\/dashboard\/(.+)$/, to: (m) => `admin/customers-omde/dashboard/${m[1]}` }, + // Comment by id (DELETE comment/{id}); must precede the customer pattern below + // since both are single-segment-with-id shapes. + { test: /^comment\/(.+)$/, to: (m) => `admin/comments/${m[1]}` }, + // Customer by id: legacy GET/PUT customer/{id} + { test: /^customer\/(.+)$/, to: (m) => `admin/customers-omde/${m[1]}` }, + // Products — note legacy's irregular shapes: a POST that deletes, and + // 'product/show/{id}' / 'product/update/{id}' rather than REST verbs. + { test: /^delete\/product\/(.+)$/, to: (m) => `admin/products/delete/${m[1]}` }, + { test: /^product\/show\/(.+)$/, to: (m) => `admin/products/${m[1]}` }, + { test: /^product\/update\/(.+)$/, to: (m) => `admin/products/${m[1]}` }, + { test: /^product\/(.+)\/variant$/, to: (m) => `admin/products/${m[1]}/variants` }, + // Category by id: legacy GET/PUT/DELETE category/{id} + { test: /^category\/(.+)$/, to: (m) => `admin/categories/${m[1]}` }, + { test: /^ciny\/(.+)$/, to: (m) => `admin/categories/collections/${m[1]}` }, + // Financial documents. NOTE the confirm path keeps legacy's misspelling + // ("confirem") on the client side; only the server path is corrected. + { test: /^cash\/confirem\/(.+)$/, to: (m) => `admin/documents/${m[1]}/confirm` }, + { test: /^cash\/(.+)$/, to: (m) => `admin/documents/${m[1]}` }, + // Promotions. These MUST precede the generic product/ patterns above them in + // intent, but they are more specific so they match first regardless. + { test: /^product\/variant\/promotion\/(.+)$/, to: (m) => `admin/promotions/${m[1]}` }, + { test: /^major\/promotion\/(.+)$/, to: (m) => `admin/promotions/${m[1]}` }, + { test: /^ignore\/(.+)$/, to: (m) => `admin/promotions/exclusions/${m[1]}` }, + // Admin users + roles. The two-segment roles path must precede the one-segment + // one, or 'roles/85/some.rid' would be read as a user-type id of '85/some.rid'. + { test: /^roles\/(\d+)\/(.+)$/, to: (m) => `admin/users/types/${m[1]}/roles/${m[2]}` }, + { test: /^roles\/(.+)$/, to: (m) => `admin/users/types/${m[1]}/roles` }, + { test: /^users\/(.+)$/, to: (m) => `admin/users/${m[1]}` }, + // Branches + workshops. The 'active' paths are two segments, so they must be + // tried before the plain id patterns. + // Allocation by id — must precede branch/:id, or 'branch/product/xyz' would be + // read as a branch whose id is 'product/xyz'. + { test: /^branch\/product\/(.+)$/, to: (m) => `admin/invoices/${m[1]}` }, + { test: /^branch\/active\/(.+)$/, to: (m) => `admin/branches/active/${m[1]}` }, + { test: /^workshop\/active\/(.+)$/, to: (m) => `admin/workshops/active/${m[1]}` }, + { test: /^branch\/(.+)$/, to: (m) => `admin/branches/${m[1]}` }, + { test: /^workshop\/(.+)$/, to: (m) => `admin/workshops/${m[1]}` }, +]; + +/** Resolves a legacy endpoint path to its current path. */ +export function mapEndpoint(url: string): string { + // Absolute URLs bypass the mapping entirely. + if (/^https?:\/\//i.test(url)) return url; + + const [pathOnly, query] = normalize(url).split('?'); + + const exact = EXACT[pathOnly]; + if (exact) return query ? `${exact}?${query}` : exact; + + for (const { test, to } of PATTERNS) { + const m = pathOnly.match(test); + if (m) { + const mapped = to(m); + return query ? `${mapped}?${query}` : mapped; + } + } + + // Unmapped: pass through normalized, so it still resolves against baseURL. + return query ? `${pathOnly}?${query}` : pathOnly; +} + +/** True when the path has an explicit rewrite — used by dev-time diagnostics. */ +export function isMapped(url: string): boolean { + const pathOnly = normalize(url).split('?')[0]; + return pathOnly in EXACT || PATTERNS.some((p) => p.test.test(pathOnly)); +} diff --git a/apps/admin-panel/src/assets/fonts/yekan/iran-yekan-400.woff2 b/apps/admin-panel/src/assets/fonts/yekan/iran-yekan-400.woff2 new file mode 100644 index 0000000..7a2630d Binary files /dev/null and b/apps/admin-panel/src/assets/fonts/yekan/iran-yekan-400.woff2 differ diff --git a/apps/admin-panel/src/assets/fonts/yekan/iran-yekan-700.woff2 b/apps/admin-panel/src/assets/fonts/yekan/iran-yekan-700.woff2 new file mode 100644 index 0000000..277e738 Binary files /dev/null and b/apps/admin-panel/src/assets/fonts/yekan/iran-yekan-700.woff2 differ diff --git a/apps/admin-panel/src/assets/images/logo/favicon.ico b/apps/admin-panel/src/assets/images/logo/favicon.ico new file mode 100644 index 0000000..60a94e9 Binary files /dev/null and b/apps/admin-panel/src/assets/images/logo/favicon.ico differ diff --git a/apps/admin-panel/src/assets/images/logo/henza-horizontal.svg b/apps/admin-panel/src/assets/images/logo/henza-horizontal.svg new file mode 100644 index 0000000..bac57ff --- /dev/null +++ b/apps/admin-panel/src/assets/images/logo/henza-horizontal.svg @@ -0,0 +1 @@ + diff --git a/apps/admin-panel/src/assets/images/logo/henza-icon.svg b/apps/admin-panel/src/assets/images/logo/henza-icon.svg new file mode 100644 index 0000000..aa0dc1c --- /dev/null +++ b/apps/admin-panel/src/assets/images/logo/henza-icon.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/admin-panel/src/assets/styles/ant.less b/apps/admin-panel/src/assets/styles/ant.less new file mode 100644 index 0000000..f47a16a --- /dev/null +++ b/apps/admin-panel/src/assets/styles/ant.less @@ -0,0 +1,426 @@ +@layout-header-height: 61px; +@border-color-base: ~'var(--accent)'; +@ant-prefix: ant; + +:root { + --primary: #41b883; + --accent: #35495e; +} + + +body #app { + .@{ant-prefix}-pagination-item { + margin-right: 0; + margin-left: 8px; + } + + .@{ant-prefix}-pagination-prev, .@{ant-prefix}-pagination-jump-prev, .@{ant-prefix}-pagination-jump-next { + margin-right: 0; + margin-left: 8px; + } + + .@{ant-prefix}-pagination-options { + margin-right: 16px; + margin-left: 0; + } + + .@{ant-prefix}-pagination-prev, .@{ant-prefix}-pagination-next { + .@{ant-prefix}-pagination-item-link { + transform: rotate(180deg); + } + } + + .@{ant-prefix}-pagination-options-quick-jumper { + margin-right: 8px; + } + + + .@{ant-prefix}-table-thead > tr > th { + text-align: right; + } + + .@{ant-prefix}-table table { + text-align: right; + } + + .@{ant-prefix}-modal-header table { + padding-right: 48px; + } + + .@{ant-prefix}-col-1, .@{ant-prefix}-col-2, .@{ant-prefix}-col-3, .@{ant-prefix}-col-4, + .@{ant-prefix}-col-5, .@{ant-prefix}-col-6, .@{ant-prefix}-col-7, .@{ant-prefix}-col-8, + .@{ant-prefix}-col-9, .@{ant-prefix}-col-10, .@{ant-prefix}-col-11, .@{ant-prefix}-col-12, + .@{ant-prefix}-col-13, .@{ant-prefix}-col-14, .@{ant-prefix}-col-15, .@{ant-prefix}-col-16, + .@{ant-prefix}-col-17, .@{ant-prefix}-col-18, .@{ant-prefix}-col-19, .@{ant-prefix}-col-20, + .@{ant-prefix}-col-21, .@{ant-prefix}-col-22, .@{ant-prefix}-col-23, .@{ant-prefix}-col-24 { + float: right; + } + + @media (max-width: 575px) { + .ant-form-item-label { + text-align: right !important; + } + } + + @media screen and (max-width: 576px) { + .antd-pro-pages-forms-step-form-style-information .antd-pro-pages-forms-step-form-style-label { + text-align: right; + } + } + + .ant-row { + text-align: right; + } + + .antd-pro-components-notice-icon-notice-list-datetime { + direction: rtl; + } + + @media (max-width: 480px) { + .ant-steps-horizontal.ant-steps-label-horizontal .ant-steps-item-icon { + float: right; + } + + .ant-steps-item.ant-steps-item-wait { + margin-right: 0; + } + + .ant-steps-horizontal:not(.ant-steps-label-vertical) .ant-steps-item { + margin-right: 0; + } + } + + .@{ant-prefix}-col-lg-1, .@{ant-prefix}-col-lg-2, .@{ant-prefix}-col-lg-3, .@{ant-prefix}-col-lg-4, .@{ant-prefix}-col-lg-5, .@{ant-prefix}-col-lg-6, .@{ant-prefix}-col-lg-7, .@{ant-prefix}-col-lg-8, .@{ant-prefix}-col-lg-9, .@{ant-prefix}-col-lg-10, .@{ant-prefix}-col-lg-11, .@{ant-prefix}-col-lg-12, .@{ant-prefix}-col-lg-13, .@{ant-prefix}-col-lg-14, .@{ant-prefix}-col-lg-15, .@{ant-prefix}-col-lg-16, .@{ant-prefix}-col-lg-17, .@{ant-prefix}-col-lg-18, .@{ant-prefix}-col-lg-19, .@{ant-prefix}-col-lg-20, .@{ant-prefix}-col-lg-21, .@{ant-prefix}-col-lg-22, .@{ant-prefix}-col-lg-23, .@{ant-prefix}-col-lg-24 { + float: right; + } + + .@{ant-prefix}-modal-footer button + button { + margin-right: 8px; + margin-left: 0; + } + + .@{ant-prefix}-select-selection-selected-value { + float: right; + } + + .@{ant-prefix}-select-dropdown-menu { + padding-right: 0; + } + + .@{ant-prefix}-calendar-picker-clear, .@{ant-prefix}-calendar-picker-icon { + left: 12px; + right: auto; + } + + .@{ant-prefix}-calendar-prev-year-btn, .@{ant-prefix}-calendar-prev-month-btn, .@{ant-prefix}-calendar-next-month-btn, .@{ant-prefix}-calendar-next-year-btn { + transform: rotate(180deg); + } + + .@{ant-prefix}-badge-status-text { + margin-right: 8px; + margin-left: 0; + } + + .@{ant-prefix}-popover-inner-content { + text-align: right; + } + + .@{ant-prefix}-btn > .@{ant-prefix}icon + span, .@{ant-prefix}-btn > span + .@{ant-prefix}icon { + margin-left: 0; + margin-right: 8px; + } + + .@{ant-prefix}-dropdown-menu-item, .@{ant-prefix}-dropdown-menu-submenu-title { + text-align: right; + } + + .@{ant-prefix}-form-item-label label:after { + margin: 0 2px 0 8px; + } + + .@{ant-prefix}-steps-small .@{ant-prefix}-steps-item-title { + padding-left: 12px; + padding-right: 0; + } + + .@{ant-prefix}-steps-item-title::after { + right: 100%; + left: auto; + } + + .@{ant-prefix}-steps-small .@{ant-prefix}-steps-item-icon { + margin-right: 8px; + margin-left: 8px; + } + + .@{ant-prefix}-steps-small.@{ant-prefix}-steps-horizontal:not(.@{ant-prefix}-steps-label-vertical) .@{ant-prefix}-steps-item { + margin-left: 12px; + margin-right: 0; + } + + .@{ant-prefix}-select-selection__placeholder, + .@{ant-prefix}-select-search__field__placeholder { + right: 20px; + left: 0; + text-align: right; + } + + @media (min-width: 576px) { + .ant-col-sm-1, + .ant-col-sm-2, + .ant-col-sm-3, + .ant-col-sm-4, + .ant-col-sm-5, + .ant-col-sm-6, + .ant-col-sm-7, + .ant-col-sm-8, + .ant-col-sm-9, + .ant-col-sm-10, + .ant-col-sm-11, + .ant-col-sm-12, + .ant-col-sm-13, + .ant-col-sm-14, + .ant-col-sm-15, + .ant-col-sm-16, + .ant-col-sm-17, + .ant-col-sm-18, + .ant-col-sm-19, + .ant-col-sm-20, + .ant-col-sm-21, + .ant-col-sm-22, + .ant-col-sm-23, + .ant-col-sm-24 { + float: right; + flex: 0 0 auto; + text-align: right; + } + } + + .@{ant-prefix}-input-number-handler-wrap { + border-radius: 4px 0px 0px 4px; + right: auto; + left: 0; + border-right: 1px solid #d9d9d9; + border-left: none; + } + + .@{ant-prefix}-input-number-input { + text-align: right; + } + + .@{ant-prefix}-input-group { + direction: ltr; + } + + .@{ant-prefix}-form-vertical .@{ant-prefix}-form-item-label, .@{ant-prefix}-col-24.@{ant-prefix}-form-item-label, .@{ant-prefix}-col-xl-24.@{ant-prefix}-form-item-label { + text-align: right; + } + + .@{ant-prefix}-time-picker-icon, .@{ant-prefix}-time-picker-clear { + left: 11px; + right: auto; + } + + .@{ant-prefix}-popover-message-title { + padding-left: 0; + padding-right: 22px; + } + + .@{ant-prefix}-popover-title { + text-align: right; + } + + .anticon { + vertical-align: middle; + } + + .@{ant-prefix}-steps-horizontal:not(.@{ant-prefix}-steps-label-vertical) .@{ant-prefix}-steps-item:last-child .@{ant-prefix}-steps-item-title { + padding-right: 14px; + } + + .antd-pro-components-result-index-result .antd-pro-components-result-index-actions button:not(:last-child) { + margin-right: 0; + margin-left: 8px; + } + + + .antd-pro-pages-list-basic-list-standardList .antd-pro-pages-list-basic-list-headerInfo > em { + right: auto; + left: 0; + } + + + .@{ant-prefix}-tabs-nav-scroll { + direction: rtl; + } + + .@{ant-prefix}-badge-status { + vertical-align: middle + } + + .@{ant-prefix}-tabs-nav-container, .antd-pro-components-notice-icon-index-popover .@{ant-prefix}-spin-container { + direction: ltr; + + & .@{ant-prefix}-tabs-tab { + direction: rtl; + } + } + + .ant-input-search-enter-button + .ant-input-group-addon .ant-input-search-button, .ant-input-search-enter-button input + .ant-input-group-addon .ant-input-search-button { + width: 100%; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + .@{ant-prefix}-tabs-tabpane { + direction: rtl; + text-align: right; + } + + .@{ant-prefix}-collapse > .@{ant-prefix}-collapse-item > .@{ant-prefix}-collapse-header .@{ant-prefix}-collapse-arrow { + transform: translateY(-50%) scale(-1, 1); + } + + + .@{ant-prefix}-radio-group, .@{ant-prefix}-radio-group-outline, .@{ant-prefix}-btn-group { + direction: ltr; + } + + .@{ant-prefix}-list-item-content { + text-align: right; + } + + .@{ant-prefix}-list-item-meta-avatar { + margin-right: 0; + margin-left: 16px; + } + + .@{ant-prefix}-input-group .@{ant-prefix}-input { + text-align: right; + } + + .@{ant-prefix}-select-selection--multiple .@{ant-prefix}-select-selection__choice { + float: right; + } + + .@{ant-prefix}-list-vertical .@{ant-prefix}-list-item-action > li:first-child { + padding-right: 0; + } + + .ant-input-search-enter-button input { + border-left-width: 0; + border-right-width: 1px; + } + + .@{ant-prefix}-list-item-action-split { + right: 0; + left: auto; + } + + .@{ant-prefix}-tag { + margin-left: 8px; + margin-right: 0; + } + + .@{ant-prefix}-modal-confirm .@{ant-prefix}-modal-confirm-btns button + button { + margin-left: 0; + margin-right: 8px; + margin-bottom: 0; + } + + .@{ant-prefix}-menu-inline .@{ant-prefix}-menu-submenu-title { + padding-right: 20px; + padding-left: 34px; + } + + .@{ant-prefix}-menu-item .@{ant-prefix}icon, .@{ant-prefix}-menu-submenu-title .@{ant-prefix}icon { + margin-right: 0; + margin-left: 10px; + } + + .@{ant-prefix}-menu-submenu-vertical > .@{ant-prefix}-menu-submenu-title .@{ant-prefix}-menu-submenu-arrow, .@{ant-prefix}-menu-submenu-vertical-left > .@{ant-prefix}-menu-submenu-title .@{ant-prefix}-menu-submenu-arrow, .@{ant-prefix}-menu-submenu-vertical-right > .@{ant-prefix}-menu-submenu-title .@{ant-prefix}-menu-submenu-arrow, .@{ant-prefix}-menu-submenu-inline > .@{ant-prefix}-menu-submenu-title .@{ant-prefix}-menu-submenu-arrow { + right: auto; + left: 16px; + } + + .@{ant-prefix}-steps-dot .@{ant-prefix}-steps-item-tail { + margin: 0 80px 0 0; + left: auto; + right: 0; + } + + .@{ant-prefix}-steps-dot .@{ant-prefix}-steps-item-icon { + margin-right: 67px; + margin-left: 8px; + } + + .@{ant-prefix}-tabs-nav { + direction: ltr; + } + + .@{ant-prefix}-input-search-enter-button .@{ant-prefix}-input-group { + direction: rtl; + } + + .@{ant-prefix}-input-search .@{ant-prefix}-input-group-addon .@{ant-prefix}-input-search-button { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .ant-table-filter-dropdown .ant-dropdown-menu-item > label + span { + padding-right: 5px; + padding-left: 0; + } + + .ant-input-group:not(.ant-input-wrapper) > .ant-input:first-child, .ant-input-group-addon:first-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + .ant-input-group.ant-input-group-compact > *:last-child { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + } + + .ant-upload-list-item-info { + padding-right: 45px; + + .ant-upload-list-item-name { + font-family: Arial !important; + direction: ltr; + } + + .anticon-paper-clip { + right: 25px; + } + } + + .ant-radio-wrapper { + direction: ltr; + margin-right: 0; + margin-left: 8px; + } + +} + + +.tether-element { + z-index: 10000; +} + +.@{ant-prefix}-modal-wrap { + .@{ant-prefix}-modal-header { + padding-right: 48px; + } + +} +.ant-select-dropdown{ + + z-index: 2500000; +} diff --git a/apps/admin-panel/src/assets/styles/farsi.less b/apps/admin-panel/src/assets/styles/farsi.less new file mode 100644 index 0000000..966f167 --- /dev/null +++ b/apps/admin-panel/src/assets/styles/farsi.less @@ -0,0 +1,61 @@ +// Ported from legacy henza_admin/src/assets/scss/ara/farsi.scss +// Converted .scss -> .less (no Sass features were used; this file is plain CSS). +// +// PORTED FAITHFULLY INCLUDING A KNOWN LEGACY BUG — see docs/ADMIN-PORT-PLAN.md: +// 1. All five @font-face blocks point at the SAME iran-yekan-400.woff2 file even +// though iran-yekan-700.woff2 exists. Bold text therefore renders in the +// regular weight (synthesised bold at best). +// 2. `format("woff")` is declared for a .woff2 file (wrong format hint). +// 3. The `body, *` reset sets `font-weight: inherit`, which flattens all heading +// weights (h1..h6 lose their default bold). +// None of these are fixed here. Flagged, not silently corrected. + +@font-face { + font-family: 'p-yek'; + src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff"); + font-weight: normal; + font-style: normal +} + + +@font-face { + font-family: 'p-yek'; + src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff"); + font-weight: bold; +} + + +@font-face { + font-family: 'p-yek'; + src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff"); + font-weight: 700; +} + + +@font-face { + font-family: 'p-yek'; + src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff"); + font-weight: 300; +} + +@font-face { + font-family: 'p-yek'; + src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff"); + font-weight: 400; +} + +body, * { + font-family: p-yek ,sans-serif, tahoma; + letter-spacing: 0px !important; + font-size: inherit; + line-height: inherit; + font-weight: inherit; +} + + + +.ara-icon-button{ + width: 35px; + height: 35px; + border-radius: 50%; +} diff --git a/apps/admin-panel/src/assets/styles/main.less b/apps/admin-panel/src/assets/styles/main.less new file mode 100644 index 0000000..7a8f75f --- /dev/null +++ b/apps/admin-panel/src/assets/styles/main.less @@ -0,0 +1,11 @@ +// Style entry for admin-panel. +// Order matters and mirrors legacy henza_admin/src/main.js: +// 1. ant-design-vue's own less theme (compiled with the modifyVars in vite.config.ts) +// 2. farsi.scss -> font faces + global reset +// 3. ara/ant.less -> RTL overrides layered on top of ant's defaults +@import 'ant-design-vue/dist/antd.less'; +@import './farsi.less'; +@import './ant.less'; +// 4. utilities.less -> the global utility layer replacing legacy's Tailwind +// build. Must come LAST so its card chrome layers over ant's own styles. +@import './utilities.less'; diff --git a/apps/admin-panel/src/assets/styles/utilities.less b/apps/admin-panel/src/assets/styles/utilities.less new file mode 100644 index 0000000..d44dbeb --- /dev/null +++ b/apps/admin-panel/src/assets/styles/utilities.less @@ -0,0 +1,94 @@ +// ───────────────────────────────────────────────────────────────────────── +// Global utility layer. +// +// WHY THIS EXISTS. Legacy henza_admin shipped a full Tailwind build +// (tailwind.config.js, ~28KB) whose custom scale the templates depend on — +// `mb-base`, `text-primary`, `w-5`, `grid-cols-4` and friends all resolved +// there. This port never installed Tailwind, but ~185 of those class names +// survived in the ported templates. They were patched over by re-declaring a +// handful of them inside each view's own ` diff --git a/apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue b/apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue new file mode 100644 index 0000000..35423ac --- /dev/null +++ b/apps/admin-panel/src/components/statistics-cards/StatisticsCardLine.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/apps/admin-panel/src/components/tooltip-btn/TooltipBtn.vue b/apps/admin-panel/src/components/tooltip-btn/TooltipBtn.vue new file mode 100644 index 0000000..921a926 --- /dev/null +++ b/apps/admin-panel/src/components/tooltip-btn/TooltipBtn.vue @@ -0,0 +1,80 @@ + + + diff --git a/apps/admin-panel/src/components/upload/ImageUploadInput.vue b/apps/admin-panel/src/components/upload/ImageUploadInput.vue new file mode 100644 index 0000000..76f4a3c --- /dev/null +++ b/apps/admin-panel/src/components/upload/ImageUploadInput.vue @@ -0,0 +1,119 @@ + + + diff --git a/apps/admin-panel/src/components/x-table/XTable.vue b/apps/admin-panel/src/components/x-table/XTable.vue new file mode 100644 index 0000000..354baef --- /dev/null +++ b/apps/admin-panel/src/components/x-table/XTable.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/apps/admin-panel/src/components/x-table/model.ts b/apps/admin-panel/src/components/x-table/model.ts new file mode 100644 index 0000000..6467829 --- /dev/null +++ b/apps/admin-panel/src/components/x-table/model.ts @@ -0,0 +1,191 @@ +/** + * Ported from legacy henza_admin/src/components/x-table/model/{XTbl,Xtc}.js + * and src/table/index.js (ColCreator). + * + * The fluent column API is preserved exactly — `new Xtc(key, title).filterText() + * .renderSlot('x').noSort()` builds the same shape the legacy table schemas used, + * so the 18 schema files port over with only their import path changed. + * + * Legacy had FOUR near-duplicate XTable forks (x-table/, x-table_select/, + * custom-xtable-select/, backup-table/, ~1,300 LOC). Per the agreed decision this + * port keeps ONE — the x-table/ variant, which is the most complete (it is the + * only one with QuerySelectTableFilter). + */ + +export type FilterType = 'default' | 'switch' | 'query' | 'date' | 'number' | 'select'; + +export interface ColumnSlots { + customRender?: string; + filterDropdown?: string; + filterIcon?: string; +} + +/** Port of legacy Xtc — one column definition. */ +export class Xtc { + key: string; + title: string; + dataIndex: string; + show = true; + /** legacy `__sorter`: sortable unless noSort() was called */ + __sorter = true; + filtered = false; + filterType?: FilterType; + filterAction?: string; + /** legacy `filterItems`: a value -> label map for select filters */ + filterItems?: Record; + scopedSlots?: ColumnSlots; + /** legacy filterKey()/master — filter against a different field than dataIndex */ + __fkey?: string; + width?: string | number; + + constructor(key: string, title: string) { + this.key = key; + this.title = title; + this.dataIndex = key; + } + + private __filter() { + this.filtered = true; + if (!this.scopedSlots) this.scopedSlots = {}; + this.scopedSlots.filterDropdown = `${this.key}_filterDropdown`; + this.scopedSlots.filterIcon = `${this.key}_filterIcon`; + } + + /** legacy filterKey(key) */ + filterKey(key: string) { + this.__fkey = key; + return this; + } + + /** legacy renderSlot(name) — render this cell through a named slot */ + renderSlot(name: string) { + if (!this.scopedSlots) this.scopedSlots = {}; + this.scopedSlots.customRender = name; + return this; + } + + filterText() { + this.__filter(); + this.filterType = 'default'; + return this; + } + + filterSwitch() { + this.__filter(); + this.filterType = 'switch'; + return this; + } + + filterQuery(action: string) { + this.__filter(); + this.filterType = 'query'; + this.filterAction = action; + return this; + } + + filterDate() { + this.__filter(); + this.filterType = 'date'; + return this; + } + + filterNumber() { + this.__filter(); + this.filterType = 'number'; + return this; + } + + /** + * legacy filterSelect(items) — `items` is an OBJECT MAP of value -> label, + * e.g. { 0: 'معلق', 1: 'ثبت سفارش' }, stored on `filterItems`. The shape is + * kept exactly so the ported table models pass their maps unchanged. + * + * Legacy also permitted calling it with no argument at all (TaminListModel does + * this), which left filterItems undefined and rendered an empty dropdown. + * Preserved: the parameter is optional here too. + */ + filterSelect(items?: Record) { + this.__filter(); + this.filterType = 'select'; + this.filterItems = items; + return this; + } + + /** legacy noSort() */ + noSort() { + this.__sorter = false; + return this; + } + + /** legacy hidden() — column exists but starts unchecked in the column picker */ + hidden() { + this.show = false; + return this; + } + + setWidth(w: string | number) { + this.width = w; + return this; + } +} + +export interface DefaultFilter { + field: string; + op: string; + value: unknown; +} + +/** Port of legacy XTbl — a table definition: endpoint + title + columns. */ +export class XTbl { + url: string; + title: string; + cols: Xtc[] = []; + defaultFilters?: DefaultFilter[]; + _hidePagination = false; + _hideSearch = false; + _noFilters = false; + + constructor(url: string, title: string) { + this.url = url; + this.title = title; + } + + add(col: Xtc) { + this.cols.push(col); + return this; + } + + addDefaultFilter(filter: DefaultFilter) { + if (!this.defaultFilters) this.defaultFilters = []; + this.defaultFilters.push(filter); + return this; + } + + hidePagination() { + this._hidePagination = true; + return this; + } + + /** + * NOTE: legacy's hideSearch() set `_hidePageination` (sic) instead of a search + * flag — a copy-paste bug, so calling it hid PAGINATION, not the search box. + * Fixed here to set the intended flag; no ported schema calls it, so nothing + * changes in behaviour. Recorded in docs/ADMIN-PORT-PLAN.md. + */ + hideSearch() { + this._hideSearch = true; + return this; + } + + /** legacy noFilters(): hides the filter bar entirely (walletTbl uses this). */ + noFilters() { + this._noFilters = true; + return this; + } + + getSortFields() { + return this.cols.filter((c) => c.__sorter); + } +} + +export default XTbl; diff --git a/apps/admin-panel/src/layouts/components/TheNavbar.vue b/apps/admin-panel/src/layouts/components/TheNavbar.vue new file mode 100644 index 0000000..a0fd644 --- /dev/null +++ b/apps/admin-panel/src/layouts/components/TheNavbar.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/apps/admin-panel/src/layouts/components/VerticalNavMenu.vue b/apps/admin-panel/src/layouts/components/VerticalNavMenu.vue new file mode 100644 index 0000000..495ed27 --- /dev/null +++ b/apps/admin-panel/src/layouts/components/VerticalNavMenu.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/apps/admin-panel/src/layouts/full-page/FullPage.vue b/apps/admin-panel/src/layouts/full-page/FullPage.vue new file mode 100644 index 0000000..0b2ebc5 --- /dev/null +++ b/apps/admin-panel/src/layouts/full-page/FullPage.vue @@ -0,0 +1,6 @@ + diff --git a/apps/admin-panel/src/layouts/main/Main.vue b/apps/admin-panel/src/layouts/main/Main.vue new file mode 100644 index 0000000..82c3815 --- /dev/null +++ b/apps/admin-panel/src/layouts/main/Main.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/apps/admin-panel/src/layouts/navMenuItems.ts b/apps/admin-panel/src/layouts/navMenuItems.ts new file mode 100644 index 0000000..6896d6c --- /dev/null +++ b/apps/admin-panel/src/layouts/navMenuItems.ts @@ -0,0 +1,650 @@ +// Ported from legacy henza_admin/src/layouts/components/vertical-nav-menu/navMenuItems.js +// +// Converted MECHANICALLY from the legacy file (see scratchpad/convert_menu.js) so +// that every Farsi label, permissionId, url, slug and icon survives byte-exact. +// The only edits are structural: the legacy `import {isBranch}` and the trailing +// const menu = isBranch() ? branchMenu : adminMenu +// are replaced by exporting both trees plus a getMenu() selector, so the choice +// happens at the call site instead of at module load. +// +// Node shapes present in the legacy data (field census across 611 lines): +// url x76, i18n x78, permissionId x74, slug x53, icon x33, submenu x19, header x2 +// A "header" node is a section divider carrying `items`, not `submenu`. +// +// Legacy quirks preserved deliberately (see docs/ADMIN-PORT-PLAN.md): +// - two nodes have their permissionId COMMENTED OUT (banker-list, shopkeeper-list), +// so they are visible to everyone +// - "newsletters.newsletters" and "wallet.list" never match any granted rid (#7, #8) +// - several nodes repeat the same permissionId or reuse another view's `name` +import { isBranch } from '../utils'; + +export interface NavMenuNode { + url?: string | null; + name?: string; + slug?: string; + icon?: string; + i18n?: string; + tag?: string; + tagColor?: string; + permissionId?: string; + header?: string; + items?: NavMenuNode[]; + submenu?: NavMenuNode[]; +} + +export const adminMenu: NavMenuNode[] = [ + { + url: null, + name: 'پیشخوان', + // tag: '2', + tagColor: 'warning', + icon: 'HomeIcon', + i18n: 'پیشخوان', + permissionId:"dashboard", + submenu: [ + { + url: '/dashboard/ecommerce', + name: 'eCommerce', + slug: 'dashboard-ecommerce', + i18n: 'آمار عمده فروشی', + permissionId:"dashboard.omde" + }, + { + url: '/dashboard/analytics', + name: 'eCommerce', + slug: 'dashboard-ecommerce', + i18n: 'آمار تک فروشی', + permissionId:"dashboard.tak" + }, + ] + }, + { + header: 'Apps', + icon: 'PackageIcon', + i18n: 'کاربری', + items: [ + { + url: null, + name: 'User', + icon: 'UserIcon', + i18n: 'اشخاص', + permissionId:"persons", + submenu: [ + { + url: '/customer/add', + name: 'AddCustomer', + slug: 'add-customer', + i18n: 'اضافه کردن شخص', + permissionId:"persons.create" + }, + { + url: '/customer/reagent-list', + name: 'List', + slug: 'raegentList', + i18n: 'لیست کارشناسان فروش', + permissionId:"persons.list.sellers" + }, + { + url: '/customer/omde-list', + name: 'List', + slug: 'omde-customer-list', + i18n: 'لیست مشتریان عمده', + permissionId:"persons.list.client.omde" + }, + { + url: '/customer/omde-non-verified-list', + name: 'List', + slug: 'omde-non-verified-list', + i18n: 'لیست مشتریان در انتظار', + permissionId:"persons.list.client.omde" + }, + { + url: '/customer/omde-archived-list', + name: 'List', + slug: 'omde-archived-list', + i18n: 'لیست مشتریان بایگانی', + permissionId:"persons.list.client.omde" + }, + { + url: '/customer/list', + name: 'List', + slug: 'app-user-list', + i18n: 'لیست مشتریان تک', + permissionId:"persons.list.client.single" + }, + { + url: '/customer/banker-list', + name: 'List', + slug: 'banker-list', + i18n: 'لیست بنکداران' + // permissionId:"persons.list.client." + }, + { + // legacy navMenuItems.js:90 spelled this with a capital S while the route + // is '/customer/shopkeeper-list'. Case-sensitive in vue-router 4, so the + // menu item dead-ended at the 404 page. Lowercased to match the route. + url: '/customer/shopkeeper-list', + name: 'List', + slug: 'shopkeeper-list', + i18n: 'لیست مغازه داران حضوری' + // permissionId:"persons.list.client." + }, + ] + }, + { + url: null, + name: 'Branch', + icon: 'GridIcon', + i18n: 'شعب', + permissionId:"branch", + submenu: [ + { + url: '/branch/add', + name: 'AddBranch', + slug: 'add-branch', + i18n: 'افزودن شعب', + permissionId:"branch.create" + }, + { + url: '/branch/list', + name: 'BranchList', + slug: 'branch-list', + i18n: 'لیست شعب', + permissionId:"branch.list" + }, + ] + }, + ///////////////////////////////////////////// + { + url: null, + name: 'Workshop', + icon: 'GridIcon', + i18n: 'کارگاه', + permissionId:"workshop", + submenu: [ + { + url: '/workshop/add', + name: 'AddWorkshop', + slug: 'add-workshop', + i18n: 'افزودن کارگاه', + permissionId:"workshop.create" + }, + { + url: '/workshop/list', + name: 'WorkshopList', + slug: 'workshop-list', + i18n: 'لیست کارگاه ها', + permissionId:"workshop.list" + }, + ] + }, + ///////////////////////////////////////////// + { + url: null, + name: 'eCommerce', + icon: 'ShoppingBagIcon', + i18n: 'محصولات', + permissionId:"product", + submenu: [ + { + url: '/apps/product/add', + name: 'AddProduct', + slug: 'add-product', + i18n: 'افزودن محصول', + permissionId:"product.create" + }, + { + url: '/apps/product/list', + name: 'ProductList', + slug: 'product-list', + i18n: 'محصولات', + permissionId:"product.list" + }, + { + url: '/product/change/percent', + name: 'changePercent', + slug: 'change-Percent', + i18n: 'تغییر درصد محصولات', + permissionId:"product.percent" + }, + ] + }, + { + url: null, + name: 'order', + icon: 'BoxIcon', + i18n: 'سفارش ها', + permissionId:"order", + submenu: [ + { + url: '/order/create', + name: 'orderOmdeList', + slug: 'order-omde-list', + i18n: 'ثبت سفارش تک', + permissionId:"order.create" + }, + { + url: '/order/list', + name: 'orderList', + slug: 'order-list', + i18n: 'لیست سفارش های تک', + permissionId:"order.list" + }, + { + url: '/order/make', + name: 'makeOrder', + slug: 'order-makeOrder', + i18n: 'سفارش های ساختنی', + permissionId:"order.makeOrder" + }, + { + url: '/order/omde-list', + name: 'orderOmdeList', + slug: 'order-omde-list', + i18n: 'لیست سفارش های عمده', + permissionId:"order.omde" + }, + { + url: '/order/tamin-list', + name: 'orderTaminList', + slug: 'order-tamin-list', + i18n: 'لیست تامین', + permissionId:"order.tamin" + } + ] + }, + { + url: null, + name: 'users', + icon: 'UserIcon', + i18n: 'مدیران', + permissionId:"user", + submenu: [ + { + url: '/permission', + name: 'permission', + slug: 'user-permission', + i18n: 'دسترسی ها', + permissionId:"user.permission" + }, + { + url: '/user/create', + name: 'AddUser', + slug: 'add-user', + i18n: 'افزودن مدیر', + permissionId:"user.create" + }, + { + url: '/user/list', + name: 'UserList', + slug: 'user-list', + i18n: 'لیست مدیران', + permissionId:"user.list" + }, + ] + }, + { + url: null, + name: 'wallet', + icon: 'CreditCardIcon', + i18n: 'کیف پول', + permissionId:"wallet", + submenu: [ + { + url: '/wallet', + name: 'wallet', + slug: 'wallet', + i18n: 'لیست کیف پول های شارژ شده', + permissionId:"wallet.list" + }, + ] + }, + { + url: null, + name: 'category', + icon: 'ListIcon', + i18n: 'دسته بندی ها', + permissionId:"collection", + submenu: [ + { + url: '/category/add', + name: 'categoryAdd', + slug: 'add-category', + i18n: 'افزودن دسته بندی', + permissionId:"collection.create" + }, + { + url: '/category/list', + name: 'categoryList', + slug: 'list-category', + i18n: 'لیست دسته بندی ها', + permissionId:"collection.list" + } + ] + }, + { + url: null, + name: 'finical', + icon: 'DollarSignIcon', + i18n: 'اسناد مالی', + permissionId:"document", + submenu: [ + { + url: '/finical/list', + name: 'finical_list', + slug: 'finical_list', + i18n: 'لیست اسناد', + permissionId:"document.list" + }, + { + url: '/add/findoc', + name: 'findoc', + slug: 'findoc', + i18n: 'وارد کردن سند مالی مشتری', + permissionId:"document.create" + }, + ] + }, + { + url: null, + name: 'report', + icon: 'GiftIcon', + i18n: 'تخفیفات', + permissionId:"offers", + submenu: [ + { + url: '/major/promotion/tak/add', + name: 'major_promotion_add', + slug: 'major_promotion_add', + i18n: 'تعریف تخفیف تک', + permissionId:"offers.create.tak" + }, + { + url: '/major/promotion/autopromotion', + name: 'major_promotion_list', + slug: 'major_promotion_list', + i18n: 'تخفیف اتوماتیک ', + permissionId:"offers.autopromotion" + }, + { + url: '/major/promotion/add', + name: 'major_promotion_add', + slug: 'major_promotion_add', + i18n: 'تعریف تخفیف عمده', + permissionId:"offers.create" + }, + { + url: '/major/promotion/list', + name: 'major_promotion_list', + slug: 'major_promotion_list', + i18n: 'لیست تخفیفات ', + permissionId:"offers.list" + }, + ] + }, + { + name: 'report', + icon: 'SlashIcon', + i18n: 'استثناعات تخفیف', + permissionId:"offersIgnore", + url: '/major/offersIgnore', + slug: 'offersIgnore', + }, + + { + url: null, + name: 'Invoice', + icon: 'BookIcon', + i18n: 'فاکتور انتقال', + permissionId:"invoice", + submenu: [ + { + url: '/invoice/add', + name: 'AddInvoice', + slug: 'add-invoice', + i18n: 'افزودن فاکتور', + permissionId:"invoice.create" + }, + { + url: '/invoice/list', + name: 'InvoiceList', + slug: 'invoice-list', + i18n: 'لیست فاکتورها', + permissionId:"invoice.list" + }, + ] + }, + + { + url: null, + name: 'report', + icon: 'FileTextIcon', + i18n: 'گزارشات', + permissionId:"report", + submenu: [ + { + url: '/report/order_item', + name: 'report_order_item', + slug: 'report_ordedr_item', + i18n: 'گزارش تجمیعی سفارش و اسناد', + permissionId:"report.order.item" + }, + { + url: '/report/product', + name: 'report_product', + slug: 'report_product', + i18n: 'گزارش محصولات', + permissionId:"report.product" + }, + ] + }, + { + url: null, + name: 'cities offer percent', + // tagColor: 'warning', + icon: 'PercentIcon', + i18n: 'شهر ها', + permissionId:"cities", + submenu: [ + { + url: '/city/create', + name: 'create city', + slug: 'create_city', + i18n: 'افزودن شهر', + permissionId:"cities.create" + }, + { + url: '/city/list', + name: 'city list', + slug: 'city_list', + i18n: 'لیست شهر ها', + permissionId:"cities.list" + }, + ] + }, + { + url: '/comments', + name: 'comments', + // tagColor: 'warning', + icon: 'MessageSquareIcon', + i18n: 'دیدگاه ها', + permissionId:"comment" + }, + { + url: null, + name: 'sms', + icon: 'MailIcon', + i18n: 'پیامک', + permissionId:"sms", + submenu: [ + { + url: '/sms/create', + name: 'create sms', + slug: 'create_sms', + i18n: 'ارسال پیامک', + permissionId:"sms.create", + }, + ] + }, + { + url: null, + name: 'notify', + icon: 'BellIcon', + i18n: 'نوتیفیکیشن', + permissionId:"notify", + submenu: [ + { + url: '/notify/create', + name: 'create notify', + slug: 'create_notify', + i18n: 'ارسال نوتیفیکیشن', + permissionId:"notify.create", + }, + ] + }, + { + url: '/statics', + name: 'statics', + icon: 'BarChartIcon', + i18n: 'آمار', + permissionId:"statics", + }, + { + url: '/newsletters', + name: 'newsletters', + icon: 'FileTextIcon', + i18n: 'خبرنامه', + permissionId:"newsletters.newsletters", + }, + ] + }, + { + header: 'UI', + icon: 'LayersIcon', + i18n: 'مدیریت', + items: [ + { + url: null, + name: 'پیکر بندی', + icon: 'SettingsIcon', + i18n: 'پیکر بندی', + permissionId:"config", + submenu: [ + { + url: '/config/manage/const', + name: 'Const Management', + slug: 'manage-const', + i18n: 'مدیریت ثابت ها', + permissionId:"config.statics" + }, + { + url: '/config/manage/report', + name: 'report Management', + slug: 'manage-report', + i18n: 'گزارش', + permissionId:"config.report" + }, + { + url: '/config/manage/wallet', + name: 'wallet Management', + slug: 'manage-const', + i18n: 'مدیریت کیف پول', + permissionId:"config.wallet" + }, + ] + }, + ] + } +] + +export const branchMenu: NavMenuNode[] = [ + { + url: '/dashboard', + name: 'dashboard', + tagColor: 'warning', + icon: 'HomeIcon', + i18n: 'پیشخوان', + permissionId:"dashboard", + }, + { + url: null, + name: 'User', + icon: 'UserIcon', + i18n: 'اشخاص', + permissionId:"persons", + submenu: [ + { + url: '/customer/add', + name: 'AddCustomer', + slug: 'add-customer', + icon:'UserPlusIcon', + i18n: 'اضافه کردن مشتری', + permissionId:"persons.create" + }, + { + url: '/customer/list', + name: 'List', + icon:'UsersIcon', + slug: 'app-user-list', + i18n: 'لیست مشتریان ', + permissionId:"persons.list.client.single" + }, + ] + }, + { + url: '/products', + name: 'ProductList', + slug: 'product-list', + icon: 'ShoppingBagIcon', + i18n: 'محصولات', + permissionId:"product.list" + }, + { + url: '/buy/order/list', + name: 'ProductList', + slug: 'product-list', + icon: 'ShoppingBagIcon', + i18n: 'فاکتور خرید', + permissionId:"product.list" + }, + /*{ + url: '/buy/order/list', + name: 'ProductList', + slug: 'product-list', + icon: 'ShoppingBagIcon', + i18n: 'فاکتور خرید', + permissionId:"product.list" + },*/ + { + url: null, + name: 'order', + icon: 'BoxIcon', + i18n: 'سفارش ها', + permissionId:"order", + submenu: [ + { + url: '/order/create', + name: 'orderOmdeList', + icon:'InboxIcon', + slug: 'order-omde-list', + i18n: 'ثبت سفارش', + permissionId:"order.create" + }, + { + url: '/order/list', + name: 'orderList', + icon:'ListIcon', + slug: 'order-list', + i18n: 'لیست سفارش', + permissionId:"order.list" + }, + ] + }, +] + +/** Legacy: `const menu = isBranch() ? branchMenu : adminMenu`. */ +export function getMenu(): NavMenuNode[] { + return isBranch() ? branchMenu : adminMenu; +} + +export default getMenu; diff --git a/apps/admin-panel/src/main.d.ts b/apps/admin-panel/src/main.d.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/apps/admin-panel/src/main.d.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/apps/admin-panel/src/main.js b/apps/admin-panel/src/main.js deleted file mode 100644 index 0cf8dfb..0000000 --- a/apps/admin-panel/src/main.js +++ /dev/null @@ -1,5 +0,0 @@ -import { createApp } from 'vue'; -import { createPinia } from 'pinia'; -import App from './App.vue'; -import { router } from './router'; -createApp(App).use(createPinia()).use(router).mount('#app'); diff --git a/apps/admin-panel/src/main.ts b/apps/admin-panel/src/main.ts index 4bb269d..652ede9 100644 --- a/apps/admin-panel/src/main.ts +++ b/apps/admin-panel/src/main.ts @@ -1,6 +1,20 @@ +// Ported from legacy henza_admin/src/main.js. +// Vue-2-only pieces are intentionally absent: Vuesax (no Vue 3 build), VeeValidate, +// vue2-hammer, vue-clipboard2, the ACL plugin, and the i18n bundle whose only real +// content was the Farsi labels already inlined in navMenuItems. Everything that +// survives keeps legacy's registration order. import { createApp } from 'vue'; import { createPinia } from 'pinia'; -import App from './App.vue'; -import { router } from './router'; +import Antd from 'ant-design-vue'; -createApp(App).use(createPinia()).use(router).mount('#app'); +import App from './App.vue'; +import router from './router'; +import './assets/styles/main.less'; + +const app = createApp(App); + +app.use(createPinia()); +app.use(Antd); +app.use(router); + +app.mount('#app'); diff --git a/apps/admin-panel/src/router/admin.ts b/apps/admin-panel/src/router/admin.ts new file mode 100644 index 0000000..65a4d77 --- /dev/null +++ b/apps/admin-panel/src/router/admin.ts @@ -0,0 +1,1057 @@ +// Ported from legacy henza_admin/src/router/admin.js +// +// Converted MECHANICALLY (see scratchpad/convert_routes.js) so every path, name, +// meta.role, breadcrumb and pageTitle survives byte-exact. Only the component +// imports are rewritten to point at the ported views. As of Batch 15 every +// referenced view exists, so no placeholder is emitted. +// +// ONE DELIBERATE DEVIATION from byte-exactness. Legacy declared these seven +// paths WITHOUT a leading slash (henza_admin/src/router/admin.js:260-320): +// major/promotion/{list,add,edit/:id,tak/add,tak/edit/:id,autopromotion} +// major/offersIgnore +// Under vue-router 3 a relative child of a parent whose path is '' still +// resolved to an absolute '/major/...', which is the URL legacy's own nav menu +// linked to and the URL users actually visited. vue-router 4 does NOT do that: +// the child stays relative, never matches '/major/...', and the catch-all +// redirects to /pages/error-404 — so all seven screens were unreachable. +// The leading slash is added here to preserve legacy's RUNTIME behaviour (the +// working URL), at the cost of the literal source text matching legacy. +// +// NOTE: legacy also shipped 8 router files with zero importers (apps.js, +// category.js, customer.js, dashboard.js, full.js, manage.js, order.js, +// product.js, temp.js). They are dead code and are not ported. +import type { RouteRecordRaw } from 'vue-router'; + +export const adminRoute: RouteRecordRaw[] = [ + { + path: '/', + name: 'dashboard', + redirect: '/my-data' + }, + { + path: '/dashboard/analytics', + name: 'dashboard-analytics', + component: () => import('../views/DashboardAnalytics.vue'), + meta: { + rule: 'editor', + role: 'dashboard.tak' + } + }, + { + path: '/my-data', + name: 'my-data', + component: () => import('../views/user/myData.vue'), + meta: { + rule: 'editor' + // role: 'dashboard.tak' + } + }, + { + path: '/dashboard/ecommerce', + name: 'dashboard-ecommerce', + component: () => import('../views/DashboardECommerce.vue'), + meta: { + rule: 'admin', + role: 'dashboard.omde' + } + }, + { + path: '/config/manage/const', + component: () => import('../views/manage/Const.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'مدیریت ثابت ها', + active: true + } + ], + pageTitle: 'مدیریت ثابت ها', + rule: 'editor', + role: 'config.statics', + parent: 'manage', + no_scroll: true + } + }, + { + path: '/config/manage/wallet', + component: () => import('../views/manage/wallet.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'مدیریت کیف پول', + active: true + } + ], + pageTitle: 'مدیریت کیف پول', + rule: 'editor', + role: 'config.wallet', + parent: 'manage', + no_scroll: true + } + }, + { + path: '/config/manage/report', + component: () => import('../views/manage/report.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'گزارش', + active: true + } + ], + pageTitle: 'گزارش', + rule: 'editor', + role: 'config.report', + parent: 'manage', + no_scroll: true + } + }, + // Product + { + path: '/apps/product/', + redirect: '/apps/product/list', + name: 'product' + }, + { + path: '/product/change/percent', + component: () => import('../views/apps/product/ChangePercent.vue'), + meta: { + rule: 'editor', + role: 'product.percent', + parent: 'product', + no_scroll: true + } + }, + { + path: '/apps/product/add', + component: () => import('../views/apps/product/AddProduct.vue'), + meta: { + rule: 'editor', + role: 'product.create', + parent: 'product', + no_scroll: true + } + }, + { + path: '/apps/product/edit/:id/:variant_id?', + name: 'product-edit', + component: () => import('../views/apps/product/EditProduct.vue'), + meta: { + rule: 'editor', + role: 'product.list.edit', + parent: 'product', + no_scroll: true + } + }, + { + path: '/apps/product/list', + name: 'product-list', + component: () => import('../views/apps/product/ProductList.vue'), + meta: { + rule: 'editor', + role: 'product.list', + parent: 'product', + no_scroll: true + } + }, + // Category + { + path: '/category', + redirect: '/category/list', + name: 'category' + }, + { + path: '/category/add/:id?', + name: 'category-add', + component: () => import('../views/apps/category/Add.vue'), + meta: { + rule: 'editor', + role: 'collection.create', + parent: 'category', + no_scroll: true + } + }, + { + path: '/category/edit/:id', + component: () => import('../views/apps/category/Edit.vue'), + name: 'category-edit', + meta: { + rule: 'editor', + role: 'collection.list.edit', + parent: 'category', + no_scroll: true + } + }, + { + path: '/category/list', + name: 'category-list', + component: () => import('../views/apps/category/List.vue'), + meta: { + rule: 'editor', + role: 'collection.list', + parent: 'category', + no_scroll: true + } + }, + //finical + { + path: '/add/findoc', + component: () => import('../views/finical/addFinDoc.vue'), + meta: { + rule: 'editor', + role: 'document.create' + //no_scroll: true + } + }, + { + path: '/edit/findoc/:id', + component: () => import('../views/finical/addFinDoc.vue'), + meta: { + rule: 'editor', + role: 'document.list.edit' + //no_scroll: true + } + }, + // Order + { + path: '/order', + redirect: '/order/list', + name: 'order' + }, + { + path: '/order/list', + component: () => import('../views/order/List.vue'), + meta: { + rule: 'editor', + role: 'order.list', + parent: 'order', + no_scroll: true + } + }, + { + path: '/order/omde-list', + component: () => import('../views/order/OmdeList.vue'), + meta: { + rule: 'editor', + role: 'order.omde', + parent: 'order', + no_scroll: true + } + }, + { + path: '/order/make', + component: () => import('../views/order/MakeOrder.vue'), + meta: { + rule: 'editor', + role: 'order.makeOrder', + parent: 'order', + no_scroll: true + } + }, + { + path: '/order/create', + component: () => import('../views/order/create.vue'), + meta: { + rule: 'editor', + role: 'order.create', + parent: 'order', + no_scroll: true + } + }, + { + path: '/order/tamin-list', + component: () => import('../views/order/TaminList.vue'), + meta: { + rule: 'editor', + role: 'order.tamin', + parent: 'order', + no_scroll: true + } + }, + { + path: '/major/promotion/list', + component: () => import('../views/promotion/promotion_list.vue'), + meta: { + rule: 'editor', + role: 'offers.list', + //parent: 'order', + no_scroll: true + } + }, + { + path: '/major/promotion/add', + component: () => import('../views/promotion/promotion_add_edit.vue'), + meta: { + rule: 'editor', + role: 'offers.create', + //parent: 'order', + no_scroll: true + } + }, + { + path: '/major/promotion/edit/:id', + component: () => import('../views/promotion/promotion_add_edit.vue'), + meta: { + rule: 'editor', + role: 'offers.list.edit', + //parent: 'order', + no_scroll: true + } + }, + { + path: '/major/promotion/tak/add', + component: () => import('../views/promotion/promotion_tak_add_edit.vue'), + meta: { + rule: 'editor', + role: 'offers.create.tak', + //parent: 'order', + no_scroll: true + } + }, + { + path: '/major/promotion/tak/edit/:id', + component: () => import('../views/promotion/promotion_tak_add_edit.vue'), + meta: { + rule: 'editor', + role: 'offers.list.tak.edit', + //parent: 'order', + no_scroll: true + } + }, + { + path: '/major/promotion/autopromotion', + component: () => import('../views/promotion/autopromotion.vue'), + meta: { + rule: 'editor', + role: 'offers.autopromotion', + no_scroll: true + } + }, + // offersIgnore + { + path: '/major/offersIgnore', + component: () => import('../views/offersIgnore/offersIgnore-add-edit.vue'), + meta: { + rule: 'editor', + role: 'offersIgnore' + } + }, + // offersIgnore + { + path: '/comments', + name: 'comments', + component: () => import('../views/customer/Comments/Comments.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'دیدگاه ها', + active: true + } + ], + pageTitle: 'دیدگاه ها', + rule: 'editor', + role: 'comment', + // parent: 'comments', + no_scroll: true + } + }, + { + path: '/sms/create', + name: 'create_sms', + component: () => import('../views/sms/create.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'ارسال پیامک', + active: true + } + ], + pageTitle: 'ارسال پیامک', + rule: 'editor', + role: 'sms.create', + no_scroll: true + } + }, + { + path: '/notify', + name: 'notify', + component: () => import('../views/notify/user.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'لیست نوتیفیکیشن های دریافتی', + active: true + } + ], + pageTitle: 'لیست نوتیفیکیشن های ذریافتی', + rule: 'editor', + role: 'notify.me', + no_scroll: true + } + }, + { + path: '/notify/create', + name: 'create_notify', + component: () => import('../views/notify/create.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'ارسال نوتیفیکیشن', + active: true + } + ], + pageTitle: 'ارسال نوتیفیکیشن', + rule: 'editor', + role: 'notify.create', + no_scroll: true + } + }, + { + path: '/wallet', + name: 'wallet', + component: () => import('../views/wallet/wallet.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'لیست کیف پول های شارژ شده', + active: true + } + ], + pageTitle: 'لیست کیف پول های شارژ شده', + rule: 'editor', + role: 'wallet', + no_scroll: true + } + }, + { + path: '/reagent/edit/:id', + name: 'reagent-edit', + component: () => import('../views/apps/customer/EditReagent.vue'), + meta: { + rule: 'editor', + role: 'persons.list.sellers.edit', + parent: 'product', + no_scroll: true + } + }, + { + path: '/bulkCustomer/edit/:id', + name: 'bulkCustomer-edit', + component: () => import('../views/apps/customer/EditBulkCustomer.vue'), + meta: { + rule: 'editor', + role: 'persons.list.client.omde.edit', + parent: 'product', + no_scroll: true + } + }, + { + path: '/bulkCustomer/view/:id', + name: 'bulkCustomer-view', + component: () => import('../views/customer/omde/ViewOmdeCustomer.vue'), + meta: { + rule: 'editor', + role: 'persons.list.client.omde.view', + parent: 'product', + no_scroll: true + } + }, + { + path: '/singleCustomer/edit/:id', + name: 'singleCustomer-edit', + component: () => import('../views/apps/customer/EditSingleCustomer.vue'), + meta: { + rule: 'editor', + role: 'persons.list.client.single.edit', + parent: 'product', + no_scroll: true + } + }, + { + path: '/singleCustomer/view/:id', + name: 'singleCustomer-view', + component: () => import('../views/customer/single-customer/ViewSingleCustomer.vue'), + meta: { + rule: 'editor', + role: 'persons.list.client.single.view', + parent: 'product', + no_scroll: true + } + }, + { + path: '/customer/omde-list', + name: 'omde-list-customer', + component: () => import('../views/customer/omde/OmdeList.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'مشتریان عمده', + active: true + } + ], + pageTitle: 'مشتریان عمده', + rule: 'editor', + role: 'persons.list.client.omde' + } + }, + { + path: '/customer/omde-non-verified-list', + name: 'omde-non-verified-list', + component: () => import('../views/customer/omde/OmdeNonVerifiedList.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'مشتریان در انتظار', + active: true + } + ], + pageTitle: 'مشتریان در انتظار', + rule: 'editor', + role: 'persons.list.client.omde' + } + }, + { + path: '/customer/banker-list', + name: 'banker-list', + component: () => import('../views/customer/omde/BankerList.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'لیست بنکداران', + active: true + } + ], + pageTitle: 'لیست بنکداران', + rule: 'editor', + role: 'persons.list.client.omde' + } + }, + { + path: '/customer/shopkeeper-list', + name: 'shopkeeper-list', + component: () => import('../views/customer/omde/ShopkeeperList.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'لیست مغازه داران حضوری', + active: true + } + ], + pageTitle: 'لیست مغازه داران حضوری', + rule: 'editor', + role: 'persons.list.client.omde' + } + }, + { + path: '/customer/omde-archived-list', + name: 'omde-archived-list', + component: () => import('../views/customer/omde/OmdeArchivedList.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'مشتریان بایگانی', + active: true + } + ], + pageTitle: 'مشتریان بایگانی', + rule: 'editor', + role: 'persons.list.client.omde' + } + }, + { + path: '/customer/reagent-list', + name: 'raegentList', + component: () => import('../views/customer/reagentList/ReagentList.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'کارشناسان فروش', + active: true + } + ], + pageTitle: 'کارشناسان فروش', + role: 'persons.list.sellers', + rule: 'editor' + } + }, + { + path: '/customer/add', + name: 'add-customer', + component: () => import('../views/apps/customer/AddCustomer.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + {title: 'مشتریان'}, + { + title: 'افزودن مشتری', + active: true + } + ], + pageTitle: 'افزودن مشتری', + role: 'persons.create', + rule: 'editor' + } + }, + { + path: '/customer/list', + name: 'list-customer', + component: () => import('../views/customer/single-customer/SingleCustomer.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + {title: 'مشتریان'} + ], + pageTitle: 'لیست مشتریان', + role: 'persons.list.client.single', + rule: 'editor' + } + }, + { + path: '/report/order_item', + name: 'report_order_item', + component: () => import('../views/report/order_item.vue'), + meta: { + /* breadcrumb: [ + {title: 'خانه', url: '/'}, + {title: 'مشتریان'} + ],*/ + //pageTitle: 'لیست مشتریان', + role: 'report.order.item', + rule: 'editor' + } + }, + { + path: '/report/product', + name: 'report_product', + component: () => import('../views/report/product.vue'), + meta: { + role: 'report.product', + rule: 'editor' + } + }, + { + path: '/finical/list', + name: 'finical_list', + component: () => import('../views/finical/list.vue'), + meta: { + /* breadcrumb: [ + {title: 'خانه', url: '/'}, + {title: 'مشتریان'} + ],*/ + //pageTitle: 'لیست مشتریان', + role: 'document.list', + rule: 'editor' + } + }, + // user + { + path: '/user/list', + component: () => import('../views/user/ListUser.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'لیست مدیران', + active: true + } + ], + pageTitle: 'لیست مدیران', + role: 'user.list', + rule: 'editor', + parent: 'user', + no_scroll: true + } + }, + { + path: '/user/create', + component: () => import('../views/user/UserCreate.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'افزودن کاربر', + active: true + } + ], + pageTitle: 'افزودن کاربر', + role: 'user.create', + rule: 'editor', + parent: 'user', + no_scroll: true + } + }, + { + path: '/user/edit/:id', + component: () => import('../views/user/UserEdit.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'ویرایش نوع کاربر', + active: true + } + ], + pageTitle: 'ویرایش نوع کاربر', + rule: 'editor', + role: 'user.list.edit', + parent: 'user', + no_scroll: true + } + }, + // permission + { + path: '/permission', + component: () => import('../views/user/Permission.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'دسترسی ها', + active: true + } + ], + pageTitle: 'دسترسی ها', + rule: 'editor', + role: 'user.permission', + parent: 'permission', + no_scroll: true + } + }, + { + path: '/city/create', + component: () => import('../views/city/createCity.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'افزودن شهر', + active: true + } + ], + pageTitle: 'افزودن شهر', + rule: 'editor', + role: 'cities.create', + parent: 'city', + no_scroll: true + } + }, + { + path: '/city/list', + component: () => import('../views/city/cityList.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'لیست شهر ها', + active: true + } + ], + pageTitle: 'لیست شهر ها', + rule: 'editor', + role: 'cities.list', + parent: 'city', + no_scroll: true + } + }, + + { + path: '/statics', + name: 'statics', + component: () => import('../views/statics/index.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'آمار', + active: true + } + ], + pageTitle: 'آمار', + rule: 'editor', + role: 'statics', + no_scroll: true + } + }, + { + path: '/newsletters', + name: 'newsletters', + component: () => import('../views/newsletters/newsletters.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + { + title: 'خبرنامه', + active: true + } + ], + pageTitle: 'خبرنامه', + rule: 'editor', + // role: 'newsletters', + no_scroll: true + } + }, + { + path: '/branch/add', + name: 'add-branch', + component: () => import('../views/branch/AddBranches.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + {title: 'شعب'}, + { + title: 'افزودن شعب', + active: true + } + ], + pageTitle: 'افزودن شعب', + role: 'branch.create', + rule: 'editor' + } + }, + { + path: '/branch/edit/:id', + name: 'edit-branch', + component: () => import('../views/branch/EditBranches.vue'), + meta: { + pageTitle: 'ویرایش شعب', + role: 'branch.create', + rule: 'editor' + } + }, + { + path: '/branch/list', + name: 'list-branch', + component: () => import('../views/branch/ListBranches.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + {title: 'شعب'}, + { + title: 'لیست شعب', + active: true + } + ], + pageTitle: 'لیست شعب', + role: 'branch.list', + rule: 'editor' + } + }, + //////////////////////////////////////////////////////////////////////// + { + path: '/workshop/add', + name: 'add-workshop', + component: () => import('../views/workshop/AddWorkshop.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + {title: 'کارگاه'}, + { + title: 'افزودن کارگاه', + active: true + } + ], + pageTitle: 'افزودن کارگاه', + role: 'workshop.create', + rule: 'editor' + } + }, + { + path: '/workshop/edit/:id', + name: 'edit-workshop', + component: () => import('../views/workshop/EditWorkshop.vue'), + meta: { + pageTitle: 'ویرایش کارگاه', + role: 'workshop.create', + rule: 'editor' + } + }, + { + path: '/workshop/list', + name: 'list-workshop', + component: () => import('../views/workshop/ListWorkshop.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + {title: 'کارگاه'}, + { + title: 'لیست کارگاه ها', + active: true + } + ], + pageTitle: 'لیست کارگاه ها', + role: 'workshop.list', + rule: 'editor' + } + }, + //////////////////////////////////////////////////////////////////////// + { + path: '/invoice/add', + name: 'add-invoice', + component: () => import('../views/invoice/AddInvoice.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + {title: 'فاکتور انتقال'}, + { + title: 'افزودن فاکتور', + active: true + } + ], + pageTitle: 'افزودن فاکتور', + role: 'invoice.create', + rule: 'editor' + } + }, + { + path: '/invoice/edit/:id', + name: 'edit-invoice', + component: () => import('../views/invoice/EditInvoice.vue'), + meta: { + pageTitle: 'ویرایش فاکتورها', + role: 'invoice.create', + rule: 'editor' + } + }, + { + path: '/invoice/list', + name: 'list-invoice', + component: () => import('../views/invoice/ListInvoice.vue'), + meta: { + breadcrumb: [ + { + title: 'خانه', + url: '/' + }, + {title: 'فاکتورانتقال'}, + { + title: 'لیست فاکتورها', + active: true + } + ], + pageTitle: 'لیست فاکتورها', + role: 'invoice.list', + rule: 'editor' + } + } +] + + +export const fullPageAdminRoute: RouteRecordRaw[] = [ + { + path: '/auth/login', + name: 'page-login', + component: () => import('../views/auth/Login.vue'), + meta: { + rule: 'editor' + } + }, + { + path: '/reg', + name: 'reg', + component: () => import('../views/reg.vue'), + meta: { + rule: 'editor' + } + } +] diff --git a/apps/admin-panel/src/router/branch.ts b/apps/admin-panel/src/router/branch.ts new file mode 100644 index 0000000..daad456 --- /dev/null +++ b/apps/admin-panel/src/router/branch.ts @@ -0,0 +1,182 @@ +// Ported from legacy henza_admin/src/router/branch.js +// +// Converted MECHANICALLY (see scratchpad/convert_routes.js) so every path, name, +// meta.role, breadcrumb and pageTitle survives byte-exact. Only the component +// imports are rewritten to point at the ported views. As of Batch 15 every +// referenced view exists, so no placeholder is emitted. +// +// NOTE: legacy also shipped 8 router files with zero importers (apps.js, +// category.js, customer.js, dashboard.js, full.js, manage.js, order.js, +// product.js, temp.js). They are dead code and are not ported. +import type { RouteRecordRaw } from 'vue-router'; + +export const branchRoute: RouteRecordRaw[] = [ + { + path: '/', + name: 'main', + redirect: '/dashboard' + }, + { + path: '/dashboard', + name: 'dashboard', + component: () => import('../views/branch/dashboard.vue'), + meta: { + rule: 'admin', + role: 'dashboard.omde' + } + }, + { + path: '/products', + name: 'product-list', + component: () => import('../views/apps/product/ProductList.vue'), + meta: { + rule: 'editor', + role: 'product.list', + parent: 'product', + no_scroll: true + } + }, + /*{ + path: '/order', + redirect: '/order/list', + name: 'order' + },*/ + { + path: '/order/list', + component: () => import('../views/order/List.vue'), + meta: { + rule: 'editor', + role: 'order.list', + parent: 'order', + no_scroll: true + } + }, + { + path: '/buy/order/list', + component: () => import('../views/branch/buyList.vue'), + meta: { + rule: 'editor', + role: 'order.list', + parent: 'order', + no_scroll: true + } + }, + /*{ + path: '/buy/order/list', + component: () => import('../views/order/List.vue'), + meta: { + rule: 'editor', + role: 'buy.order.list', + no_scroll: true + } + },*/ + { + path: '/order/create', + component: () => import('../views/branch/order/create.vue'), + meta: { + rule: 'editor', + role: 'order.create', + parent: 'order', + no_scroll: true + } + }, + /*{ + path: '/singleCustomer/edit/:id', + name: 'singleCustomer-edit', + component: () => import('../views/apps/customer/EditSingleCustomer.vue'), + meta: { + rule: 'editor', + role: 'persons.list.client.single.edit', + parent: 'product', + no_scroll: true + } + },*/ + { + path: '/singleCustomer/view/:id', + name: 'singleCustomer-view', + component: () => import('../views/customer/single-customer/ViewSingleCustomer.vue'), + meta: { + rule: 'editor', + role: 'persons.list.client.single.view', + parent: 'product', + no_scroll: true + } + }, + { + path: '/singleCustomer/edit/:id', + name: 'singleCustomer-edit', + component: () => import('../views/apps/customer/EditSingleCustomer.vue'), + meta: { + rule: 'editor', + role: 'persons.list.client.single.edit', + parent: 'product', + no_scroll: true + } + }, + { + path: '/customer/add', + name: 'add-customer', + component: () => import('../views/branch/addCustomer.vue'), + meta: { + role: 'persons.create', + rule: 'editor' + } + }, + { + path: '/customer/list', + name: 'list-customer', + component: () => import('../views/customer/single-customer/SingleCustomer.vue'), + meta: { + role: 'persons.list.client.single', + rule: 'editor' + } + }, + /*{ + path: '/report/order_item', + name: 'report_order_item', + component: () => import('../views/report/order_item.vue'), + meta: { + /!* breadcrumb: [ + {title: 'خانه', url: '/'}, + {title: 'مشتریان'} + ],*!/ + //pageTitle: 'لیست مشتریان', + role: 'report.order.item', + rule: 'editor' + } + },*/ + /*{ + path: '/report/product', + name: 'report_product', + component: () => import('../views/report/product.vue'), + meta: { + role: 'report.product', + rule: 'editor' + } + },*/ + /*{ + path: '/finical/list', + name: 'finical_list', + component: () => import('../views/finical/list.vue'), + meta: { + /!* breadcrumb: [ + {title: 'خانه', url: '/'}, + {title: 'مشتریان'} + ],*!/ + //pageTitle: 'لیست مشتریان', + role: 'document.list', + rule: 'editor' + } + },*/ +] + +export const fullPageBranchRoute: RouteRecordRaw[] = [ { + path: '/auth/login', + name: 'page-login', + //component: () => import('../views/auth/Login.vue'), + component: () => import('../views/branch/login.vue'), + meta: { + rule: 'editor' + } +}, +] diff --git a/apps/admin-panel/src/router/index.d.ts b/apps/admin-panel/src/router/index.d.ts deleted file mode 100644 index b18f1f1..0000000 --- a/apps/admin-panel/src/router/index.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare const router: import("vue-router").Router; diff --git a/apps/admin-panel/src/router/index.js b/apps/admin-panel/src/router/index.js deleted file mode 100644 index 5d96f67..0000000 --- a/apps/admin-panel/src/router/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import { createRouter, createWebHistory } from 'vue-router'; -import DashboardView from '../views/DashboardView.vue'; -import OrderNewView from '../views/OrderNewView.vue'; -import OrderDetailView from '../views/OrderDetailView.vue'; -export const router = createRouter({ - history: createWebHistory(), - routes: [ - { path: '/', name: 'dashboard', component: DashboardView }, - { path: '/orders/new', name: 'order-new', component: OrderNewView }, - { path: '/orders/:id', name: 'order-detail', component: OrderDetailView }, - ], -}); diff --git a/apps/admin-panel/src/router/index.ts b/apps/admin-panel/src/router/index.ts index 1823cf4..d81661d 100644 --- a/apps/admin-panel/src/router/index.ts +++ b/apps/admin-panel/src/router/index.ts @@ -1,13 +1,128 @@ +// Ported from legacy henza_admin/src/router.js import { createRouter, createWebHistory } from 'vue-router'; -import DashboardView from '../views/DashboardView.vue'; -import OrderNewView from '../views/OrderNewView.vue'; -import OrderDetailView from '../views/OrderDetailView.vue'; +import { adminRoute, fullPageAdminRoute } from './admin'; +import { branchRoute, fullPageBranchRoute } from './branch'; +import { isBranch, tokenName, userName } from '../utils'; +import { useUsersStore } from '../stores/users'; +import api from '../api/client'; -export const router = createRouter({ - history: createWebHistory(), +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + // legacy router.js:17-19 + scrollBehavior() { + return { left: 0, top: 0 }; + }, routes: [ - { path: '/', name: 'dashboard', component: DashboardView }, - { path: '/orders/new', name: 'order-new', component: OrderNewView }, - { path: '/orders/:id', name: 'order-detail', component: OrderDetailView }, + // ── MAIN LAYOUT ROUTES ────────────────────────────────────────── + { + path: '', + component: () => import('../layouts/main/Main.vue'), + children: isBranch() ? branchRoute : adminRoute, + }, + // ── FULL PAGE LAYOUTS ─────────────────────────────────────────── + { + path: '', + component: () => import('../layouts/full-page/FullPage.vue'), + children: [ + ...(isBranch() ? fullPageBranchRoute : fullPageAdminRoute), + { + path: '/pages/error-404', + name: 'page-error-404', + component: () => import('../views/pages/Error404.vue'), + meta: { rule: 'editor' }, + }, + { + path: '/pages/error-500', + name: 'page-error-500', + component: () => import('../views/pages/Error500.vue'), + meta: { rule: 'editor' }, + }, + { + path: '/pages/not-authorized', + name: 'page-not-authorized', + component: () => import('../views/pages/NotAuthorized.vue'), + meta: { rule: 'editor' }, + }, + { + path: '/pages/maintenance', + name: 'page-maintenance', + component: () => import('../views/pages/Maintenance.vue'), + meta: { rule: 'editor' }, + }, + ], + }, + // Redirect to 404 page, if no match found + { + path: '/:pathMatch(.*)*', + redirect: '/pages/error-404', + }, ], }); + +// legacy router.js:147-153 +router.afterEach(() => { + const appLoading = document.getElementById('loading-bg'); + if (appLoading) { + appLoading.style.display = 'none'; + } +}); + +// legacy router.js:156-161 +const noneAuthUrl = ['/auth/login', '/auth/forgot-password', '/auth/register', '/reg']; + +/** + * Port of legacy getPermission() (router.js:163-182). + * + * Legacy cached the result in a MODULE-SCOPED `let roles`, which was never reset + * on logout — so the next user to sign in on the same tab inherited the previous + * user's permissions (bug #2 in docs/ADMIN-PORT-PLAN.md). Here the cache is the + * Pinia users store, which auth.logout() clears; `roles === undefined` still + * means "not fetched", exactly as legacy's check did. + */ +async function getPermission(): Promise { + const users = useUsersStore(); + if (users.roles) return users.roles; + + try { + const { data } = await api.get('users/show'); + users.getRoles(data.permission); + // Legacy stripped these before caching the user blob. + delete data.permission; + delete data.ut; + if (data.api_token) { + localStorage.setItem(tokenName, data.api_token); + } + localStorage.setItem(userName, JSON.stringify(data)); + } catch (e) { + console.log(e); + } + return users.roles; +} + +/** + * Port of legacy router.beforeEach (router.js:184-199). + * + * One deliberate change: legacy's final line was a bare + * router.push({path: '/auth/login'}) + * with no next() call, which in Vue Router 4 leaves the navigation permanently + * pending and hangs the app (bug #1). Returning the redirect is the Vue Router 4 + * equivalent and produces the same user-visible result. + */ +router.beforeEach(async (to) => { + if (noneAuthUrl.indexOf(to.path) > -1) { + return true; + } + + const token = localStorage.getItem(tokenName); + if (token) { + const roles = await getPermission(); + if (roles && to.meta && to.meta.role && !roles.includes(to.meta.role as string)) { + return { name: 'page-not-authorized' }; + } + return true; + } + + return { path: '/auth/login' }; +}); + +export default router; diff --git a/apps/admin-panel/src/stores/auth.ts b/apps/admin-panel/src/stores/auth.ts new file mode 100644 index 0000000..50e83f6 --- /dev/null +++ b/apps/admin-panel/src/stores/auth.ts @@ -0,0 +1,95 @@ +// Ported from legacy henza_admin/src/store/auth/moduleAuth.js +import { defineStore } from 'pinia'; +import api from '../api/client'; +import { tokenName, userName } from '../utils'; +import { useUsersStore } from './users'; +import type { UserInfo } from './users'; + +/** + * LEGACY BUG, recorded in docs/ADMIN-PORT-PLAN.md (#3). + * + * Legacy declared these as FUNCTIONS in Vuex state, not values: + * + * const state = { + * user: () => JSON.parse(localStorage.getItem(...)), + * token: () => localStorage.getItem(...), + * } + * + * so `store.state.auth.token` was a function object — always truthy, even when + * logged out. The router guard's `store.state.auth.token && store.state.auth.user` + * check was therefore dead code that always passed, and the real gate was the + * separate `localStorage.getItem(token)` read in beforeEach. + * + * Here they are getters that return the actual value. The observable behaviour + * is unchanged, because the real gate was — and still is — the localStorage read; + * this only removes a permanently-true condition, it does not let anyone in. + */ +export const useAuthStore = defineStore('auth', { + state: () => ({ + userPermission: [] as string[], + }), + getters: { + token: (): string | null => localStorage.getItem(tokenName), + user: (): UserInfo | null => { + const raw = localStorage.getItem(userName); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + }, + isLoggedIn(): boolean { + return Boolean(this.token); + }, + }, + actions: { + /** + * Port of legacy action `login`. + * + * Legacy: POST 'admin-login', then + * dispatch('users/getRoles', data.roles.map(x => x.rid)) + * delete data.roles; delete data.ut + * commit('INJECT', data) + * + * The endpoint name is translated by the mapping layer in api/endpoints.ts. + */ + async login(payload: { phone: string; password: string }) { + const { data } = await api.post('admin-login', payload); + + const users = useUsersStore(); + users.getRoles((data.roles ?? []).map((x: { rid: string }) => x.rid)); + + // Legacy stripped these before persisting the user blob. Kept: the panel + // reads permissions from the store, never from the cached user object. + const persisted = { ...data }; + delete persisted.roles; + delete persisted.ut; + + this.inject(persisted); + return persisted; + }, + + /** Port of legacy mutation INJECT. */ + inject(payload: UserInfo & { api_token: string }) { + localStorage.setItem(tokenName, payload.api_token); + // Consumed once by App.vue to show the welcome toast, then removed. + localStorage.setItem('login', 'true'); + localStorage.setItem(userName, JSON.stringify(payload)); + }, + + /** + * Not present in legacy — legacy had no logout action at all, which is why + * its module-scoped `roles` survived a session change (bug #2). Clearing + * both storage and the roles store is the fix for that. + */ + logout() { + localStorage.removeItem(tokenName); + localStorage.removeItem(userName); + localStorage.removeItem('login'); + const users = useUsersStore(); + users.deleteRoles(); + users.deleteUserInfo(); + }, + }, +}); diff --git a/apps/admin-panel/src/stores/cart.ts b/apps/admin-panel/src/stores/cart.ts new file mode 100644 index 0000000..6e51a8f --- /dev/null +++ b/apps/admin-panel/src/stores/cart.ts @@ -0,0 +1,125 @@ +// Ported from legacy henza_admin/src/store/cart/cart.js +// +// The admin-side order builder's basket. Behaviour is preserved exactly, +// including the two guards legacy applied: +// - ADD_TO_CART refuses an out-of-stock variant that is not a pre-order +// (`!variant.is_pre_order && variant.qty === 0`) +// - init() re-fetches every cart product and drops any that became +// out-of-stock while the basket sat in localStorage +// +// Storage key differs per mode ('admin_cart_items' vs 'branch_cart_items'), +// as in legacy. +import { defineStore } from 'pinia'; +import api from '../api/client'; +import { isBranch } from '../utils'; + +const cartKey = isBranch() ? 'branch_cart_items' : 'admin_cart_items'; + +export interface CartVariant { + id: string; + is_pre_order?: boolean; + qty?: number; + price?: number; + price_with_discount?: number; + grams?: number; + title?: string; +} + +export interface CartProduct { + id: string; + variants_all: CartVariant[]; + promotion?: { id: string } | null; + [k: string]: unknown; +} + +export interface CartItem { + vid: string; + pid: string; + product: CartProduct; + qty: number; + promotion_id?: string | null; +} + +export const useCartStore = defineStore('cart', { + state: () => ({ + items: [] as CartItem[], + }), + actions: { + /** legacy mutation ADD_TO_CART */ + addToCart(data: CartItem) { + const index = this.items.findIndex((item) => item.pid == data.pid && item.vid == data.vid); + const variant = data.product.variants_all.filter((x) => x.id === data.vid)[0]; + // legacy guard: reject an out-of-stock, non-pre-order variant + if (variant && !(!variant.is_pre_order && variant.qty === 0)) { + if (index == -1) { + this.items.push(data); + } else { + this.items[index].product = data.product; + this.items[index].qty = data.qty; + } + } + }, + + /** legacy mutation INIT — also persists to localStorage */ + setItems(res: CartItem[]) { + this.items = res; + localStorage.setItem(cartKey, JSON.stringify(res)); + }, + + add(data: CartItem) { + this.addToCart(data); + this.setItems(this.items); + }, + + remove(item: { pid: string; vid: string }) { + const index = this.items.findIndex((v) => v.pid == item.pid && v.vid == item.vid); + if (index > -1) this.items.splice(index, 1); + this.setItems(this.items); + }, + + updateItemQuantity(payload: { index: number; quantity: number }) { + this.items[payload.index].qty = payload.quantity; + this.setItems(this.items); + }, + + clear() { + this.items = []; + this.setItems(this.items); + }, + + /** + * legacy init(): rehydrate from localStorage, then re-fetch each product so + * prices and stock are current, dropping anything now out of stock. + */ + async init() { + try { + const items: CartItem[] = JSON.parse(localStorage.getItem(cartKey) ?? '[]') || []; + const ids = items.map((item) => item.product.id); + if (ids.length === 0) { + this.setItems([]); + return; + } + const { data } = await api.post('product/variant', { products_id: ids }); + if (data) { + const exp: CartItem[] = []; + for (const cartItem of items) { + const product = (data as CartProduct[]).find((item) => item.id == cartItem.product.id); + if (!product) continue; + if (!(!product.variants_all[0].is_pre_order && product.variants_all[0].qty === 0)) { + exp.push({ + vid: cartItem.vid, + pid: product.id, + product, + qty: cartItem.qty, + promotion_id: product.promotion && product.promotion.id ? product.promotion.id : null, + }); + } + } + this.setItems(exp); + } + } catch (e) { + console.log('error in cart', e); + } + }, + }, +}); diff --git a/apps/admin-panel/src/stores/invoiceCart.ts b/apps/admin-panel/src/stores/invoiceCart.ts new file mode 100644 index 0000000..e9e4e4c --- /dev/null +++ b/apps/admin-panel/src/stores/invoiceCart.ts @@ -0,0 +1,86 @@ +// Ported from legacy henza_admin/src/store/invoiceCart/invoiceCart.js +// +// The basket behind the branch-allocation ("فاکتور") screen. Structurally a copy +// of the order cart, with two deliberate differences: +// +// 1. STORAGE KEY — legacy wrote BOTH carts to 'admin_cart_items', so building an +// allocation clobbered a half-finished order and vice versa. Verified in +// legacy: store/cart/cart.js:27 and store/invoiceCart/invoiceCart.js:27 use +// the same key. This cart gets its own key instead; reproducing silent data +// loss would be worse than the deviation. Recorded in docs/ADMIN-PORT-PLAN.md. +// +// 2. No re-hydrate fetch — the allocation screen only needs product ids, so +// there is no variant/stock refresh to perform. +import { defineStore } from 'pinia'; +import { isBranch } from '../utils'; + +const cartKey = isBranch() ? 'branch_invoice_items' : 'admin_invoice_items'; + +export interface InvoiceCartItem { + pid: string; + vid?: string; + qty: number; + product: Record; +} + +export const useInvoiceCartStore = defineStore('invoiceCart', { + state: () => ({ + items: [] as InvoiceCartItem[], + }), + actions: { + /** legacy mutation INIT — also persists */ + setItems(res: InvoiceCartItem[]) { + this.items = res; + localStorage.setItem(cartKey, JSON.stringify(res)); + }, + + /** legacy mutation ADD_TO_CART, including its out-of-stock guard */ + add(data: InvoiceCartItem) { + const index = this.items.findIndex((i) => i.pid == data.pid && i.vid == data.vid); + const variants = (data.product?.variants_all ?? []) as { + id: string; + qty?: number; + is_pre_order?: boolean; + }[]; + const variant = variants.filter((x) => x.id === data.vid)[0]; + + // legacy: when a variant was named, it must be in stock or a pre-order. + // The allocation screen adds whole products (no vid), which legacy let + // through only because `variant` was undefined — preserved. + if (data.vid && variant && !variant.is_pre_order && variant.qty === 0) return; + + if (index == -1) { + this.items.push(data); + } else { + this.items[index].product = data.product; + this.items[index].qty = data.qty; + } + this.setItems(this.items); + }, + + remove(item: { pid: string; vid?: string }) { + const index = this.items.findIndex((v) => v.pid == item.pid && v.vid == item.vid); + if (index > -1) this.items.splice(index, 1); + this.setItems(this.items); + }, + + updateItemQuantity(payload: { index: number; quantity: number }) { + this.items[payload.index].qty = payload.quantity; + this.setItems(this.items); + }, + + clear() { + this.items = []; + this.setItems(this.items); + }, + + /** Rehydrate from localStorage on mount. */ + init() { + try { + this.items = JSON.parse(localStorage.getItem(cartKey) ?? '[]') || []; + } catch { + this.items = []; + } + }, + }, +}); diff --git a/apps/admin-panel/src/stores/layout.ts b/apps/admin-panel/src/stores/layout.ts new file mode 100644 index 0000000..b58bb70 --- /dev/null +++ b/apps/admin-panel/src/stores/layout.ts @@ -0,0 +1,37 @@ +// Pinia replacement for the parts of legacy henza_admin/src/store/state.js + +// mutations.js that the app shell actually uses: window width, scroll position, +// and the active theme. The Vuexy customizer state (sidebar width, theme tour, +// footer type, ...) is carried in themeConfig.ts instead, as in legacy. +import { defineStore } from 'pinia'; +import themeConfig, { type ThemeMode } from '../themeConfig'; + +export const useLayoutStore = defineStore('layout', { + state: () => ({ + windowWidth: typeof window !== 'undefined' ? window.innerWidth : 0, + scrollY: 0, + theme: themeConfig.theme as ThemeMode, + // legacy state.js: sidebar visibility for the vertical nav menu + isSidebarActive: true, + sidebarCollapsed: themeConfig.sidebarCollapsed, + }), + getters: { + // legacy used breakpoints of 1200 (theme tour) and 992 (nav menu collapse) + isSmallScreen: (s) => s.windowWidth < 992, + }, + actions: { + // legacy mutation: UPDATE_WINDOW_WIDTH + updateWindowWidth(width: number) { + this.windowWidth = width; + }, + // legacy mutation: UPDATE_WINDOW_SCROLL_Y + updateWindowScrollY(y: number) { + this.scrollY = y; + }, + toggleSidebar(value?: boolean) { + this.isSidebarActive = value ?? !this.isSidebarActive; + }, + setSidebarCollapsed(value: boolean) { + this.sidebarCollapsed = value; + }, + }, +}); diff --git a/apps/admin-panel/src/stores/session.d.ts b/apps/admin-panel/src/stores/session.d.ts deleted file mode 100644 index d6a6af4..0000000 --- a/apps/admin-panel/src/stores/session.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Placeholder for the logged-in admin's identity. No auth module exists yet - * (see migration notes) — this just tracks UI selection state until then. - */ -export declare const useSessionStore: import("pinia").StoreDefinition<"session", { - selectedCustomerId: string | null; -}, {}, { - selectCustomer(customerId: string): void; -}>; diff --git a/apps/admin-panel/src/stores/session.js b/apps/admin-panel/src/stores/session.js deleted file mode 100644 index effdc49..0000000 --- a/apps/admin-panel/src/stores/session.js +++ /dev/null @@ -1,15 +0,0 @@ -import { defineStore } from 'pinia'; -/** - * Placeholder for the logged-in admin's identity. No auth module exists yet - * (see migration notes) — this just tracks UI selection state until then. - */ -export const useSessionStore = defineStore('session', { - state: () => ({ - selectedCustomerId: null, - }), - actions: { - selectCustomer(customerId) { - this.selectedCustomerId = customerId; - }, - }, -}); diff --git a/apps/admin-panel/src/stores/session.ts b/apps/admin-panel/src/stores/session.ts deleted file mode 100644 index 4080d12..0000000 --- a/apps/admin-panel/src/stores/session.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineStore } from 'pinia'; - -/** - * Placeholder for the logged-in admin's identity. No auth module exists yet - * (see migration notes) — this just tracks UI selection state until then. - */ -export const useSessionStore = defineStore('session', { - state: () => ({ - selectedCustomerId: null as string | null, - }), - actions: { - selectCustomer(customerId: string) { - this.selectedCustomerId = customerId; - }, - }, -}); diff --git a/apps/admin-panel/src/stores/users.ts b/apps/admin-panel/src/stores/users.ts new file mode 100644 index 0000000..82747e3 --- /dev/null +++ b/apps/admin-panel/src/stores/users.ts @@ -0,0 +1,114 @@ +// Ported from legacy henza_admin/src/store/users/index.js +import { defineStore } from 'pinia'; +import api from '../api/client'; + +/** + * Ported VERBATIM in behaviour from legacy store/users/index.js:3-9. + * + * getParents(['a','b','c']) -> ['a.b', 'a'] + */ +function getParents(keys: string[]): string[] { + const work = [...keys]; + const parents: string[] = []; + while (work.length > 1) { + work.pop(); + parents.push(work.join('.')); + } + return parents; +} + +/** + * Ported from legacy store/users/index.js:10-22. + * + * Granting "persons.list.client.omde" implicitly grants "persons.list.client", + * "persons.list" and "persons", which is what keeps parent menu nodes visible. + * + * The legacy version assigned to a module-scoped `let rid` and relied on that + * outer variable inside the .map() callback. This version is pure — identical + * output, but no state leaking between calls (legacy's `rid` survived logout). + * + * NOTE it only walks UP; a rid never implies its children. That is why a user + * granted "wallet" still cannot see the nav item guarded by "wallet.list" — + * a legacy bug, preserved deliberately. See docs/ADMIN-PORT-PLAN.md. + */ +export function checkParent(values: string[]): string[] { + let rid: string[] = [...values]; + values.forEach((x) => { + const keys = x.split('.'); + if (keys.length > 1) { + rid = [...new Set([...rid, ...getParents(keys)])]; + } + }); + return rid; +} + +export interface UserInfo { + id?: string; + name?: string; + fullName?: string; + /** + * Legacy split names into fname/lname. The schema stores one fullName, but the + * API returns both keys so ported templates binding either keep working. + */ + fname?: string; + lname?: string; + mobile?: string; + role?: string; + ut?: number | null; + branch_id?: string | null; + user_type?: { id: number; title: string } | null; +} + +export const useUsersStore = defineStore('users', { + state: () => ({ + // legacy: cityFilters — id -> title map used by table filters + cityFilters: {} as Record, + userInfo: undefined as UserInfo | undefined, + // undefined = "not fetched yet" (distinct from [] = "fetched, has none"). + // The router guard relies on that distinction, as legacy's did. + roles: undefined as string[] | undefined, + }), + getters: { + /** + * Port of the global `Vue.prototype.$hasPermission` from legacy main.js. + * Returns undefined when roles have not loaded — legacy did the same, which + * renders as falsy in v-if. + */ + hasPermission: (state) => (permissionId: string) => { + const roles = state.roles; + if (roles) { + return roles.includes(permissionId); + } + return undefined; + }, + }, + actions: { + // legacy mutation setCities: builds an id -> title lookup + setCities(payload: { id: string | number; title: string }[]) { + payload.forEach((x) => { + this.cityFilters[x.id] = x.title; + }); + }, + async getCities() { + try { + const res = await api.post('cities/list'); + this.setCities(res.data.rows); + } catch (e) { + console.log(e); + } + }, + setUserInfo(payload: UserInfo) { + this.userInfo = payload; + }, + deleteUserInfo() { + this.userInfo = {}; + }, + /** legacy action getRoles: commits checkParent(roles), never the raw list. */ + getRoles(roles: string[]) { + this.roles = checkParent(roles); + }, + deleteRoles() { + this.roles = undefined; + }, + }, +}); diff --git a/apps/admin-panel/src/themeConfig.ts b/apps/admin-panel/src/themeConfig.ts new file mode 100644 index 0000000..7e45eb9 --- /dev/null +++ b/apps/admin-panel/src/themeConfig.ts @@ -0,0 +1,43 @@ +// Ported from legacy henza_admin/themeConfig.js +// Colors are byte-identical to legacy. The Vuesax registration at the bottom of the +// legacy file is dropped: Vuesax has no Vue 3 build (confirmed decision — ant-design-vue +// is the substitute). Everything else is carried over as-is. + +// MAIN COLORS +export const colors = { + primary: '#1c2d56', + secondary: '#836447', + third: '#f5cb55', + success: '#28C76F', + danger: '#EA5455', + warning: '#FF9F43', + dark: '#1E1E1E', +} as const; + +// Legacy also had 'horizontal', which the branch build selected and which +// Main.vue branched on to render HorizontalNavMenu. That component set (4 files, +// ~12KB of Vuexy) was never ported, so Main.vue always renders the vertical +// menu — meaning the branch build was silently claiming a layout that does not +// exist here. The union is narrowed to what the port can actually render; +// porting the horizontal layout is a separate piece of work. +export type MainLayoutType = 'vertical'; +export type NavbarType = 'floating' | 'static' | 'sticky' | 'hidden'; +export type FooterType = 'static' | 'sticky' | 'hidden'; +export type ThemeMode = 'light' | 'dark' | 'semi-dark'; + +const themeConfig = { + disableCustomizer: false, + disableThemeTour: true, + footerType: 'hidden' as FooterType, + hideScrollToTop: false, + mainLayoutType: 'vertical' as MainLayoutType, + navbarColor: '#fff', + navbarType: 'floating' as NavbarType, + routerTransition: 'zoom-fade', + rtl: true, + sidebarCollapsed: false, + theme: 'light' as ThemeMode, + userInfoLocalStorageKey: 'userInfo', +}; + +export default themeConfig; diff --git a/apps/admin-panel/src/types/moment-jalaali-shim.d.ts b/apps/admin-panel/src/types/moment-jalaali-shim.d.ts new file mode 100644 index 0000000..4be4301 --- /dev/null +++ b/apps/admin-panel/src/types/moment-jalaali-shim.d.ts @@ -0,0 +1,5 @@ +// moment-jalaali ships no type declarations of its own. +declare module 'moment-jalaali' { + import moment from 'moment'; + export default moment; +} diff --git a/apps/admin-panel/src/utils/filters.ts b/apps/admin-panel/src/utils/filters.ts new file mode 100644 index 0000000..7864b98 --- /dev/null +++ b/apps/admin-panel/src/utils/filters.ts @@ -0,0 +1,72 @@ +// Ported from legacy henza_admin/src/filters/filters.js +// +// Vue 3 removed `Vue.filter`, so these are plain functions imported where needed. +// Each body is carried over verbatim, including legacy's quirks: +// - priceUnit returns the input UNCHANGED when falsy (so 0 renders as "0", +// not "0 تومان") and appends a trailing space after 'تومان' +// - jdateTime uses 'hh:mm:ss' (12-hour) rather than 'HH:mm:ss' +import moment from 'moment-jalaali'; + +export function capitalize(value: string) { + if (!value) return ''; + value = value.toString(); + const arr = value.split(' '); + const capitalized_array: string[] = []; + arr.forEach((word) => { + const capitalized = word.charAt(0).toUpperCase() + word.slice(1); + capitalized_array.push(capitalized); + }); + return capitalized_array.join(' '); +} + +export function title(value: string, replacer = '_') { + if (!value) return ''; + value = value.toString(); + const arr = value.split(replacer); + const capitalized_array: string[] = []; + arr.forEach((word) => { + const capitalized = word.charAt(0).toUpperCase() + word.slice(1); + capitalized_array.push(capitalized); + }); + return capitalized_array.join(' '); +} + +export function truncate(value: string, limit: number) { + return value.substring(0, limit); +} + +/** legacy: thousands separators + ' تومان ' (note the trailing space) */ +export function priceUnit(val: unknown) { + if (val) { + return val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + ' تومان '; + } + return val; +} + +/** legacy: plain thousands separators, no unit */ +export function price(val: unknown) { + if (val) { + return val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + } + return val; +} + +/** Jalali date, legacy format 'jYYYY/jMM/jDD' */ +export function jdate(val: unknown) { + if (val) { + return moment(val as string).format('jYYYY/jMM/jDD'); + } + return val; +} + +/** Jalali date+time. Legacy used 12-hour 'hh' here — preserved. */ +export function jdateTime(val: unknown) { + if (val) { + return moment(val as string).format('jYYYY/jMM/jDD - hh:mm:ss'); + } + return val; +} + +export function none(val: T) { + return val; +} diff --git a/apps/admin-panel/src/utils/index.ts b/apps/admin-panel/src/utils/index.ts new file mode 100644 index 0000000..5d397b6 --- /dev/null +++ b/apps/admin-panel/src/utils/index.ts @@ -0,0 +1,19 @@ +// Ported from legacy henza_admin/src/utils/index.js +// +// Legacy: +// export function isBranch(){ +// console.log(process.env.NODE_ENV) +// return process.env.NODE_ENV=='branch' +// } +// +// Vite has no process.env at runtime; the equivalent build-time switch is the +// --mode flag, exposed as import.meta.env.MODE. Run `pnpm dev:branch` for branch mode. +// The legacy console.log is intentionally not carried over (debug noise, no behaviour). +export function isBranch(): boolean { + return import.meta.env.MODE === 'branch'; +} + +// Legacy stored the token under a different localStorage key per mode. +// See henza_admin/src/axios.js:6 and router.js:173-174,188. +export const tokenName = isBranch() ? 'branch_token' : 'token'; +export const userName = isBranch() ? 'branch_user' : 'user'; diff --git a/apps/admin-panel/src/views/DashboardAnalytics.vue b/apps/admin-panel/src/views/DashboardAnalytics.vue new file mode 100644 index 0000000..fcfcde1 --- /dev/null +++ b/apps/admin-panel/src/views/DashboardAnalytics.vue @@ -0,0 +1,64 @@ + + + diff --git a/apps/admin-panel/src/views/DashboardECommerce.vue b/apps/admin-panel/src/views/DashboardECommerce.vue new file mode 100644 index 0000000..10298d2 --- /dev/null +++ b/apps/admin-panel/src/views/DashboardECommerce.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/apps/admin-panel/src/views/DashboardView.vue b/apps/admin-panel/src/views/DashboardView.vue deleted file mode 100644 index f852f35..0000000 --- a/apps/admin-panel/src/views/DashboardView.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - - - diff --git a/apps/admin-panel/src/views/DashboardView.vue.d.ts b/apps/admin-panel/src/views/DashboardView.vue.d.ts deleted file mode 100644 index 7711b0c..0000000 --- a/apps/admin-panel/src/views/DashboardView.vue.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; -export default _default; diff --git a/apps/admin-panel/src/views/DashboardView.vue.js b/apps/admin-panel/src/views/DashboardView.vue.js deleted file mode 100644 index fc64e05..0000000 --- a/apps/admin-panel/src/views/DashboardView.vue.js +++ /dev/null @@ -1,97 +0,0 @@ -/// -import { onMounted, ref } from 'vue'; -import { useRouter } from 'vue-router'; -import { api } from '../api/client'; -import { useSessionStore } from '../stores/session'; -const customers = ref([]); -const loading = ref(true); -const error = ref(null); -const router = useRouter(); -const session = useSessionStore(); -onMounted(async () => { - try { - customers.value = await api.listCustomers(); - } - catch (e) { - error.value = e.message; - } - finally { - loading.value = false; - } -}); -function goToNewOrder(customerId) { - session.selectCustomer(customerId); - router.push({ name: 'order-new' }); -} -debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ -const __VLS_ctx = {}; -let __VLS_components; -let __VLS_directives; -// CSS variable injection -// CSS variable injection end -__VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({}); -if (__VLS_ctx.loading) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); -} -else if (__VLS_ctx.error) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ - ...{ class: "error" }, - }); - (__VLS_ctx.error); -} -else { - __VLS_asFunctionalElement(__VLS_intrinsicElements.table, __VLS_intrinsicElements.table)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.thead, __VLS_intrinsicElements.thead)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.tbody, __VLS_intrinsicElements.tbody)({}); - for (const [c] of __VLS_getVForSourceType((__VLS_ctx.customers))) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({ - key: (c.id), - }); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - (c.fullName); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - (c.mobile); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - (c.goldDebtGrams); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - (c.fiatDebtRial); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - (c.goldCreditLimitGrams); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ - ...{ onClick: (...[$event]) => { - if (!!(__VLS_ctx.loading)) - return; - if (!!(__VLS_ctx.error)) - return; - __VLS_ctx.goToNewOrder(c.id); - } }, - }); - } -} -/** @type {__VLS_StyleScopedClasses['error']} */ ; -var __VLS_dollars; -const __VLS_self = (await import('vue')).defineComponent({ - setup() { - return { - customers: customers, - loading: loading, - error: error, - goToNewOrder: goToNewOrder, - }; - }, -}); -export default (await import('vue')).defineComponent({ - setup() { - return {}; - }, -}); -; /* PartiallyEnd: #4569/main.vue */ diff --git a/apps/admin-panel/src/views/OrderDetailView.vue b/apps/admin-panel/src/views/OrderDetailView.vue deleted file mode 100644 index c01d479..0000000 --- a/apps/admin-panel/src/views/OrderDetailView.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/apps/admin-panel/src/views/OrderDetailView.vue.d.ts b/apps/admin-panel/src/views/OrderDetailView.vue.d.ts deleted file mode 100644 index 7711b0c..0000000 --- a/apps/admin-panel/src/views/OrderDetailView.vue.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; -export default _default; diff --git a/apps/admin-panel/src/views/OrderDetailView.vue.js b/apps/admin-panel/src/views/OrderDetailView.vue.js deleted file mode 100644 index d6ab307..0000000 --- a/apps/admin-panel/src/views/OrderDetailView.vue.js +++ /dev/null @@ -1,98 +0,0 @@ -/// -import { onMounted, ref } from 'vue'; -import { useRoute } from 'vue-router'; -import { api } from '../api/client'; -const route = useRoute(); -const order = ref(null); -const error = ref(null); -const STATUS_FLOW = [ - 'PLACED', - 'ADMIN_APPROVED', - 'SOURCING', - 'PREPARING', - 'DELIVERED_TO_AGENT', - 'DELIVERED_TO_CUSTOMER', -]; -async function load() { - order.value = await api.getOrder(route.params.id); -} -async function advance() { - if (!order.value) - return; - const currentIndex = STATUS_FLOW.indexOf(order.value.status); - const next = STATUS_FLOW[currentIndex + 1]; - if (!next) - return; - try { - order.value = await api.updateOrderStatus(order.value.id, next); - } - catch (e) { - error.value = e.message; - } -} -onMounted(load); -debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ -const __VLS_ctx = {}; -let __VLS_components; -let __VLS_directives; -// CSS variable injection -// CSS variable injection end -if (__VLS_ctx.order) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({}); - (__VLS_ctx.order.orderNumber); - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.strong, __VLS_intrinsicElements.strong)({}); - (__VLS_ctx.order.status); - if (__VLS_ctx.order.isSuspendedOverCreditLimit) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ - ...{ class: "error" }, - }); - } - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); - (__VLS_ctx.order.totalGoldWeightGrams); - (__VLS_ctx.order.wageWeightGrams); - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); - (__VLS_ctx.order.flatRialCharge); - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); - (__VLS_ctx.order.deliveredAt ?? '—'); - if (__VLS_ctx.STATUS_FLOW.indexOf(__VLS_ctx.order.status) < __VLS_ctx.STATUS_FLOW.length - 1) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ - ...{ onClick: (__VLS_ctx.advance) }, - }); - } - if (__VLS_ctx.error) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ - ...{ class: "error" }, - }); - (__VLS_ctx.error); - } - __VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.ul, __VLS_intrinsicElements.ul)({}); - for (const [e, i] of __VLS_getVForSourceType((__VLS_ctx.order.statusEvents))) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.li, __VLS_intrinsicElements.li)({ - key: (i), - }); - (e.toStatus); - (e.createdAt); - } -} -/** @type {__VLS_StyleScopedClasses['error']} */ ; -/** @type {__VLS_StyleScopedClasses['error']} */ ; -var __VLS_dollars; -const __VLS_self = (await import('vue')).defineComponent({ - setup() { - return { - order: order, - error: error, - STATUS_FLOW: STATUS_FLOW, - advance: advance, - }; - }, -}); -export default (await import('vue')).defineComponent({ - setup() { - return {}; - }, -}); -; /* PartiallyEnd: #4569/main.vue */ diff --git a/apps/admin-panel/src/views/OrderNewView.vue b/apps/admin-panel/src/views/OrderNewView.vue deleted file mode 100644 index 2def9bf..0000000 --- a/apps/admin-panel/src/views/OrderNewView.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - - - diff --git a/apps/admin-panel/src/views/OrderNewView.vue.d.ts b/apps/admin-panel/src/views/OrderNewView.vue.d.ts deleted file mode 100644 index 7711b0c..0000000 --- a/apps/admin-panel/src/views/OrderNewView.vue.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; -export default _default; diff --git a/apps/admin-panel/src/views/OrderNewView.vue.js b/apps/admin-panel/src/views/OrderNewView.vue.js deleted file mode 100644 index 9220806..0000000 --- a/apps/admin-panel/src/views/OrderNewView.vue.js +++ /dev/null @@ -1,163 +0,0 @@ -/// -import { onMounted, reactive, ref } from 'vue'; -import { useRouter } from 'vue-router'; -import { api } from '../api/client'; -import { useSessionStore } from '../stores/session'; -const session = useSessionStore(); -const router = useRouter(); -const products = ref([]); -const items = reactive([ - { productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 }, -]); -const submitting = ref(false); -const error = ref(null); -onMounted(async () => { - products.value = await api.listProducts(); -}); -function addItem() { - items.push({ productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 }); -} -function removeItem(index) { - items.splice(index, 1); -} -async function submit() { - if (!session.selectedCustomerId) { - error.value = 'Select a customer from the dashboard first.'; - return; - } - submitting.value = true; - error.value = null; - try { - const order = await api.createOrder({ - customerId: session.selectedCustomerId, - items: items.map((i) => ({ ...i })), - }); - router.push({ name: 'order-detail', params: { id: order.id } }); - } - catch (e) { - error.value = e.message; - } - finally { - submitting.value = false; - } -} -debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ -const __VLS_ctx = {}; -let __VLS_components; -let __VLS_directives; -// CSS variable injection -// CSS variable injection end -__VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({}); -if (!__VLS_ctx.session.selectedCustomerId) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ - ...{ class: "error" }, - }); -} -else { - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); - (__VLS_ctx.session.selectedCustomerId); -} -__VLS_asFunctionalElement(__VLS_intrinsicElements.table, __VLS_intrinsicElements.table)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.thead, __VLS_intrinsicElements.thead)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.tbody, __VLS_intrinsicElements.tbody)({}); -for (const [item, idx] of __VLS_getVForSourceType((__VLS_ctx.items))) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({ - key: (idx), - }); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.select, __VLS_intrinsicElements.select)({ - value: (item.productVariantId), - }); - __VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({ - value: "", - disabled: true, - }); - for (const [p] of __VLS_getVForSourceType((__VLS_ctx.products))) { - (p.id); - for (const [v] of __VLS_getVForSourceType((p.variants))) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({ - key: (v.id), - value: (v.id), - }); - (p.title); - (v.title); - (v.rashWeightGrams); - } - } - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({ - type: "number", - min: "1", - }); - (item.quantity); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({ - type: "number", - step: "0.1", - }); - (item.wagePercent); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({ - type: "number", - step: "0.1", - }); - (item.discountPercent); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({ - type: "number", - step: "1000", - }); - (item.flatRialCharge); - __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); - __VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ - ...{ onClick: (...[$event]) => { - __VLS_ctx.removeItem(idx); - } }, - }); -} -__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ - ...{ onClick: (__VLS_ctx.addItem) }, -}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.br)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.br)({}); -__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ - ...{ onClick: (__VLS_ctx.submit) }, - disabled: (__VLS_ctx.submitting), -}); -if (__VLS_ctx.error) { - __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ - ...{ class: "error" }, - }); - (__VLS_ctx.error); -} -/** @type {__VLS_StyleScopedClasses['error']} */ ; -/** @type {__VLS_StyleScopedClasses['error']} */ ; -var __VLS_dollars; -const __VLS_self = (await import('vue')).defineComponent({ - setup() { - return { - session: session, - products: products, - items: items, - submitting: submitting, - error: error, - addItem: addItem, - removeItem: removeItem, - submit: submit, - }; - }, -}); -export default (await import('vue')).defineComponent({ - setup() { - return {}; - }, -}); -; /* PartiallyEnd: #4569/main.vue */ diff --git a/apps/admin-panel/src/views/apps/category/Add.vue b/apps/admin-panel/src/views/apps/category/Add.vue new file mode 100644 index 0000000..d7a2a14 --- /dev/null +++ b/apps/admin-panel/src/views/apps/category/Add.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/apps/admin-panel/src/views/apps/category/CategoryTbl.ts b/apps/admin-panel/src/views/apps/category/CategoryTbl.ts new file mode 100644 index 0000000..6819143 --- /dev/null +++ b/apps/admin-panel/src/views/apps/category/CategoryTbl.ts @@ -0,0 +1,20 @@ +// Ported from legacy henza_admin/src/views/apps/category/CategoryTbl.js +// +// Column keys and Farsi titles preserved exactly, INCLUDING legacy's quirk that +// the text columns 'title' and 'parent.title' are declared .filterNumber(). +// The large commented-out block in the middle of the legacy file is not revived. +import XTbl, { Xtc } from '../../../components/x-table/model'; + +export function buildCategoryModel(): XTbl { + const tbl = new XTbl('admin/categories/list', 'لیست دسته بندی'); + tbl.add(new Xtc('id', 'شماره').filterNumber()); + tbl.add(new Xtc('image', 'تصویر').noSort().renderSlot('image')); + tbl.add(new Xtc('type_str', 'نوع نمایش')); + // legacy declares filterNumber() on these two text columns — preserved + tbl.add(new Xtc('title', 'عنوان دسته').filterNumber()); + tbl.add(new Xtc('parent.title', 'پدر دسته').filterNumber()); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildCategoryModel; diff --git a/apps/admin-panel/src/views/apps/category/Edit.vue b/apps/admin-panel/src/views/apps/category/Edit.vue new file mode 100644 index 0000000..d34f625 --- /dev/null +++ b/apps/admin-panel/src/views/apps/category/Edit.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/apps/category/List.vue b/apps/admin-panel/src/views/apps/category/List.vue new file mode 100644 index 0000000..e933411 --- /dev/null +++ b/apps/admin-panel/src/views/apps/category/List.vue @@ -0,0 +1,73 @@ + + + + diff --git a/apps/admin-panel/src/views/apps/customer/AddCustomer.vue b/apps/admin-panel/src/views/apps/customer/AddCustomer.vue new file mode 100644 index 0000000..a063a46 --- /dev/null +++ b/apps/admin-panel/src/views/apps/customer/AddCustomer.vue @@ -0,0 +1,30 @@ + + + + diff --git a/apps/admin-panel/src/views/apps/customer/EditBulkCustomer.vue b/apps/admin-panel/src/views/apps/customer/EditBulkCustomer.vue new file mode 100644 index 0000000..ac3ea2e --- /dev/null +++ b/apps/admin-panel/src/views/apps/customer/EditBulkCustomer.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/apps/customer/EditReagent.vue b/apps/admin-panel/src/views/apps/customer/EditReagent.vue new file mode 100644 index 0000000..371f0ee --- /dev/null +++ b/apps/admin-panel/src/views/apps/customer/EditReagent.vue @@ -0,0 +1,11 @@ + + + diff --git a/apps/admin-panel/src/views/apps/customer/EditSingleCustomer.vue b/apps/admin-panel/src/views/apps/customer/EditSingleCustomer.vue new file mode 100644 index 0000000..58899a7 --- /dev/null +++ b/apps/admin-panel/src/views/apps/customer/EditSingleCustomer.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue b/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue new file mode 100644 index 0000000..95a0599 --- /dev/null +++ b/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue new file mode 100644 index 0000000..b02ff8d --- /dev/null +++ b/apps/admin-panel/src/views/apps/customer/bulkCustomer.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/apps/admin-panel/src/views/apps/customer/singleCustomer.vue b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue new file mode 100644 index 0000000..50a9521 --- /dev/null +++ b/apps/admin-panel/src/views/apps/customer/singleCustomer.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/apps/admin-panel/src/views/apps/product/AddProduct.vue b/apps/admin-panel/src/views/apps/product/AddProduct.vue new file mode 100644 index 0000000..7393364 --- /dev/null +++ b/apps/admin-panel/src/views/apps/product/AddProduct.vue @@ -0,0 +1,373 @@ + + + + + diff --git a/apps/admin-panel/src/views/apps/product/ChangePercent.vue b/apps/admin-panel/src/views/apps/product/ChangePercent.vue new file mode 100644 index 0000000..330a004 --- /dev/null +++ b/apps/admin-panel/src/views/apps/product/ChangePercent.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/apps/admin-panel/src/views/apps/product/EditProduct.vue b/apps/admin-panel/src/views/apps/product/EditProduct.vue new file mode 100644 index 0000000..873ebe8 --- /dev/null +++ b/apps/admin-panel/src/views/apps/product/EditProduct.vue @@ -0,0 +1,16 @@ + + + diff --git a/apps/admin-panel/src/views/apps/product/ProductList.vue b/apps/admin-panel/src/views/apps/product/ProductList.vue new file mode 100644 index 0000000..93eee51 --- /dev/null +++ b/apps/admin-panel/src/views/apps/product/ProductList.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/apps/admin-panel/src/views/apps/product/productListModel.ts b/apps/admin-panel/src/views/apps/product/productListModel.ts new file mode 100644 index 0000000..47bd84c --- /dev/null +++ b/apps/admin-panel/src/views/apps/product/productListModel.ts @@ -0,0 +1,30 @@ +// Ported from legacy henza_admin/src/table/product.js +// +// Column keys, Farsi titles, filter types and the show_type value map are +// preserved exactly. The legacy file also carries a large commented-out +// ColCreator block above the live XTbl definition — not revived. +import XTbl, { Xtc } from '../../../components/x-table/model'; + +export function buildProductListModel(): XTbl { + const tbl = new XTbl('admin/products/list', 'لیست محصولات'); + tbl.add(new Xtc('id', 'کد').filterNumber()); + tbl.add(new Xtc('ProductImage', 'تصویر محصول').renderSlot('image').noSort()); + tbl.add(new Xtc('title', 'نام محصول').filterText()); + tbl.add(new Xtc('brand.title', 'برند').filterText()); + // legacy key is 'category.collection.title'; the mapping layer aliases it + tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText()); + tbl.add(new Xtc('product_code', 'کد محصول').filterText()); + tbl.add( + new Xtc('show_type', 'نوع نمایش') + .filterSelect({ + 1: 'سایت', + 2: 'گوشی', + 3: 'سایت و گوشی', + }) + .renderSlot('show_type'), + ); + tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort()); + return tbl; +} + +export default buildProductListModel; diff --git a/apps/admin-panel/src/views/auth/Login.vue b/apps/admin-panel/src/views/auth/Login.vue new file mode 100644 index 0000000..ebf509b --- /dev/null +++ b/apps/admin-panel/src/views/auth/Login.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/apps/admin-panel/src/views/branch/AddBranches.vue b/apps/admin-panel/src/views/branch/AddBranches.vue new file mode 100644 index 0000000..5419fcd --- /dev/null +++ b/apps/admin-panel/src/views/branch/AddBranches.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/apps/admin-panel/src/views/branch/EditBranches.vue b/apps/admin-panel/src/views/branch/EditBranches.vue new file mode 100644 index 0000000..02a80a8 --- /dev/null +++ b/apps/admin-panel/src/views/branch/EditBranches.vue @@ -0,0 +1,9 @@ + + + diff --git a/apps/admin-panel/src/views/branch/FacilityList.vue b/apps/admin-panel/src/views/branch/FacilityList.vue new file mode 100644 index 0000000..32ac31e --- /dev/null +++ b/apps/admin-panel/src/views/branch/FacilityList.vue @@ -0,0 +1,72 @@ + + + + diff --git a/apps/admin-panel/src/views/branch/ListBranch.ts b/apps/admin-panel/src/views/branch/ListBranch.ts new file mode 100644 index 0000000..078d344 --- /dev/null +++ b/apps/admin-panel/src/views/branch/ListBranch.ts @@ -0,0 +1,19 @@ +// Ported from legacy henza_admin/src/views/branch/ListBranch.js +// +// Legacy sets `_noFilter` and `_noDownload` — note `_noFilter` (singular), which +// is NOT the `_noFilters` flag XTable actually reads, so the filter bar stayed +// visible. Preserved: neither flag is set here either. +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildBranchListModel(): XTbl { + const tbl = new XTbl('admin/branches/list', 'لیست شعب'); + tbl.add(new Xtc('name', 'نام').filterText()); + tbl.add(new Xtc('address', 'آدرس').filterText()); + // legacy declares filterNumber() on these two text columns — preserved + tbl.add(new Xtc('phone', 'شماره تلفن').filterNumber()); + tbl.add(new Xtc('user.name', 'مدیر شعبه').filterNumber().noSort()); + tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('actions')); + return tbl; +} + +export default buildBranchListModel; diff --git a/apps/admin-panel/src/views/branch/ListBranches.vue b/apps/admin-panel/src/views/branch/ListBranches.vue new file mode 100644 index 0000000..1398718 --- /dev/null +++ b/apps/admin-panel/src/views/branch/ListBranches.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/admin-panel/src/views/branch/addCustomer.vue b/apps/admin-panel/src/views/branch/addCustomer.vue new file mode 100644 index 0000000..aeb6c88 --- /dev/null +++ b/apps/admin-panel/src/views/branch/addCustomer.vue @@ -0,0 +1,13 @@ + + + diff --git a/apps/admin-panel/src/views/branch/buyList.vue b/apps/admin-panel/src/views/branch/buyList.vue new file mode 100644 index 0000000..edae2fc --- /dev/null +++ b/apps/admin-panel/src/views/branch/buyList.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/apps/admin-panel/src/views/branch/dashboard.vue b/apps/admin-panel/src/views/branch/dashboard.vue new file mode 100644 index 0000000..db80781 --- /dev/null +++ b/apps/admin-panel/src/views/branch/dashboard.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/apps/admin-panel/src/views/branch/login.vue b/apps/admin-panel/src/views/branch/login.vue new file mode 100644 index 0000000..e451b80 --- /dev/null +++ b/apps/admin-panel/src/views/branch/login.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/apps/admin-panel/src/views/branch/order/create.vue b/apps/admin-panel/src/views/branch/order/create.vue new file mode 100644 index 0000000..05d57be --- /dev/null +++ b/apps/admin-panel/src/views/branch/order/create.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/apps/admin-panel/src/views/branch/orderListModel.ts b/apps/admin-panel/src/views/branch/orderListModel.ts new file mode 100644 index 0000000..bf3ded1 --- /dev/null +++ b/apps/admin-panel/src/views/branch/orderListModel.ts @@ -0,0 +1,19 @@ +// Ported from legacy henza_admin/src/views/branch/orderListModel.js — the +// branch's "فاکتور خرید": the allocations head office sent to this branch. +// +// Legacy carries several commented-out columns (customer name, total price, +// wage, and an order.tamin.state block). They stay absent. +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildBranchBuyListModel(): XTbl { + const tbl = new XTbl('admin/invoices/list', 'فاکتور خرید'); + tbl.add(new Xtc('id', 'شماره').filterNumber()); + tbl.add(new Xtc('count_item', 'تعداد آیتم ها').noSort()); + tbl.add(new Xtc('sum_weight', 'جمع وزن').noSort()); + tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort()); + tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('createdAt').filterDate().noSort()); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildBranchBuyListModel; diff --git a/apps/admin-panel/src/views/city/CityListModel.ts b/apps/admin-panel/src/views/city/CityListModel.ts new file mode 100644 index 0000000..e8ed860 --- /dev/null +++ b/apps/admin-panel/src/views/city/CityListModel.ts @@ -0,0 +1,17 @@ +// Ported from legacy henza_admin/src/views/city/CityListModel.js +// +// Note the 'level' column: legacy declares it but the cities payload has no such +// field, so the cell always rendered blank. Preserved — see the port plan. +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildCityListModel(): XTbl { + const tbl = new XTbl('admin/misc/cities/list', 'لیست شهر ها'); + tbl.add(new Xtc('title', 'نام شهر').filterKey('name').filterText()); + tbl.add(new Xtc('level', 'سطح').filterNumber().noSort()); + tbl.add(new Xtc('percent', 'درصد مشتری عمده').renderSlot('percent').noSort()); + tbl.add(new Xtc('percent_aux', 'درصد کارشناس فروش').renderSlot('percent_aux').noSort()); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildCityListModel; diff --git a/apps/admin-panel/src/views/city/cityList.vue b/apps/admin-panel/src/views/city/cityList.vue new file mode 100644 index 0000000..8fa3c53 --- /dev/null +++ b/apps/admin-panel/src/views/city/cityList.vue @@ -0,0 +1,120 @@ + + + + diff --git a/apps/admin-panel/src/views/city/createCity.vue b/apps/admin-panel/src/views/city/createCity.vue new file mode 100644 index 0000000..0901e6c --- /dev/null +++ b/apps/admin-panel/src/views/city/createCity.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/apps/admin-panel/src/views/customer/Comments/CommentListModel.ts b/apps/admin-panel/src/views/customer/Comments/CommentListModel.ts new file mode 100644 index 0000000..49f7399 --- /dev/null +++ b/apps/admin-panel/src/views/customer/Comments/CommentListModel.ts @@ -0,0 +1,29 @@ +// Ported from legacy henza_admin/src/views/customer/Comments/CommentListModel.js +// +// Column keys, Farsi titles and slots preserved exactly — including legacy's +// quirk that 'product.title' (a text column) is declared .filterNumber(), and +// that an 'sts' column is added once commented-out and once live. +import XTbl, { Xtc } from '../../../components/x-table/model'; +import { useUsersStore } from '../../../stores/users'; + +export function buildCommentListModel(): XTbl { + const users = useUsersStore(); + + const tbl = new XTbl('admin/comments/list', 'لیست دیدگاه ها'); + tbl.add(new Xtc('product_id', 'شناسه محصول').filterNumber().hidden()); + // legacy declares filterNumber() on a title column — preserved + tbl.add(new Xtc('product.title', 'نام محصول').filterNumber().renderSlot('productName')); + tbl.add(new Xtc('customer.id', 'شناسه نویسنده دیدگاه').filterNumber().hidden()); + tbl.add(new Xtc('customer', 'نام نویسنده ').filterText().renderSlot('customer')); + tbl.add(new Xtc('reply', 'پاسخ').renderSlot('reply').noSort()); + tbl.add(new Xtc('sts', 'وضعیت').renderSlot('sts').noSort()); + + if (users.roles && users.roles.includes('comment.state')) { + tbl.add(new Xtc('changeState', 'تغییر وضعیت').renderSlot('changeState').noSort()); + } + + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildCommentListModel; diff --git a/apps/admin-panel/src/views/customer/Comments/Comments.vue b/apps/admin-panel/src/views/customer/Comments/Comments.vue new file mode 100644 index 0000000..1381aa2 --- /dev/null +++ b/apps/admin-panel/src/views/customer/Comments/Comments.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/apps/admin-panel/src/views/customer/CustomerDetail.vue b/apps/admin-panel/src/views/customer/CustomerDetail.vue new file mode 100644 index 0000000..d8cfe26 --- /dev/null +++ b/apps/admin-panel/src/views/customer/CustomerDetail.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/apps/admin-panel/src/views/customer/omde/BankerList.vue b/apps/admin-panel/src/views/customer/omde/BankerList.vue new file mode 100644 index 0000000..ae4a35b --- /dev/null +++ b/apps/admin-panel/src/views/customer/omde/BankerList.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/customer/omde/OmdeArchivedList.vue b/apps/admin-panel/src/views/customer/omde/OmdeArchivedList.vue new file mode 100644 index 0000000..2f893c0 --- /dev/null +++ b/apps/admin-panel/src/views/customer/omde/OmdeArchivedList.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/customer/omde/OmdeCustomerList.vue b/apps/admin-panel/src/views/customer/omde/OmdeCustomerList.vue new file mode 100644 index 0000000..72b3437 --- /dev/null +++ b/apps/admin-panel/src/views/customer/omde/OmdeCustomerList.vue @@ -0,0 +1,414 @@ + + + + diff --git a/apps/admin-panel/src/views/customer/omde/OmdeList.vue b/apps/admin-panel/src/views/customer/omde/OmdeList.vue new file mode 100644 index 0000000..50144c3 --- /dev/null +++ b/apps/admin-panel/src/views/customer/omde/OmdeList.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/customer/omde/OmdeNonVerifiedList.vue b/apps/admin-panel/src/views/customer/omde/OmdeNonVerifiedList.vue new file mode 100644 index 0000000..dab5cc1 --- /dev/null +++ b/apps/admin-panel/src/views/customer/omde/OmdeNonVerifiedList.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/customer/omde/ShopkeeperList.vue b/apps/admin-panel/src/views/customer/omde/ShopkeeperList.vue new file mode 100644 index 0000000..de306bb --- /dev/null +++ b/apps/admin-panel/src/views/customer/omde/ShopkeeperList.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/customer/omde/ViewOmdeCustomer.vue b/apps/admin-panel/src/views/customer/omde/ViewOmdeCustomer.vue new file mode 100644 index 0000000..f2b6d83 --- /dev/null +++ b/apps/admin-panel/src/views/customer/omde/ViewOmdeCustomer.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/admin-panel/src/views/customer/omde/omdeListModel.ts b/apps/admin-panel/src/views/customer/omde/omdeListModel.ts new file mode 100644 index 0000000..c722be5 --- /dev/null +++ b/apps/admin-panel/src/views/customer/omde/omdeListModel.ts @@ -0,0 +1,70 @@ +// Ported from legacy henza_admin/src/views/customer/omde/*Model.js +// +// The five legacy model files (OmdeListModel, BankerListModel, +// shopkeeperListModel, OmdeListNonVerifiedModel, OmdeArchiveListModel) are +// byte-identical except for ONE line — the endpoint url, plus a different title +// on two of them. Verified by diff. They are built here from one factory. +import XTbl, { Xtc } from '../../../components/x-table/model'; +import { useUsersStore } from '../../../stores/users'; + +/** Endpoint + title per legacy file. */ +export const OMDE_SOURCES = { + omde: { url: 'admin/customers-omde/list', title: 'لیست مشتریان های عمده' }, + banker: { url: 'admin/customers-omde/banker/list', title: 'لیست بنکداران' }, + shopkeeper: { url: 'admin/customers-omde/shopkeeper/list', title: 'لیست مغازه داران حضوری' }, + // NOTE both of these carry the SAME title as the plain omde list in legacy — + // only their url differs. Preserved rather than "corrected". + nonVerified: { url: 'admin/customers-omde/non-verified/list', title: 'لیست مشتریان های عمده' }, + archived: { url: 'admin/customers-omde/archived/list', title: 'لیست مشتریان های عمده' }, +} as const; + +export type OmdeSourceKey = keyof typeof OMDE_SOURCES; + +export function buildOmdeListModel(source: OmdeSourceKey): XTbl { + const users = useUsersStore(); + + // legacy: fetch the city list once if the filter map is empty + if (Object.keys(users.cityFilters).length < 1) { + users.getCities(); + } + + const { url, title } = OMDE_SOURCES[source]; + const tbl = new XTbl(url, title); + + tbl.add(new Xtc('id', 'کد').filterNumber()); + tbl.add(new Xtc('fname', 'نام').filterText().hidden()); + tbl.add(new Xtc('lname', 'نام خانوادگی').filterText().hidden()); + tbl.add(new Xtc('name', 'نام و نام خانوادگی').filterText()); + tbl.add(new Xtc('mobile', 'موبایل').filterText().hidden().noSort()); + tbl.add( + new Xtc('city_id', 'شهر') + .hidden() + .renderSlot('city') + .filterKey('city_id') + .filterSelect(users.cityFilters), + ); + tbl.add(new Xtc('reagent.name', 'کارشناس فروش').filterText().noSort()); + tbl.add(new Xtc('gram_level', 'سطح اعتبار').filterNumber().noSort()); + tbl.add(new Xtc('percent', 'درصد').filterNumber().noSort()); + tbl.add(new Xtc('remain', 'بدهی گرمی').filterNumber().noSort()); + tbl.add(new Xtc('rial_remain', 'بدهی ریالی').filterNumber().noSort()); + tbl.add(new Xtc('caj', 'تاریخ ثبت').filterDate().noSort()); + tbl.add(new Xtc('uaj', 'بروزرسانی').hidden().filterDate().noSort()); + tbl.add(new Xtc('sts_str', 'وضعیت ثبت سفارش').renderSlot('sts').noSort()); + tbl.add( + new Xtc('active', 'وضعیت حساب کاربری').renderSlot('active').noSort().filterSelect({ + '1': 'فعال', + '-1': 'غیر فعال', + }), + ); + tbl.add(new Xtc('phone', 'تلفن').filterText().hidden().noSort()); + tbl.add(new Xtc('address', 'آدرس').filterText().hidden().noSort()); + tbl.add(new Xtc('store_name', 'نام مغازه').filterText().hidden().noSort()); + tbl.add(new Xtc('zone', 'منطقه').filterText().hidden().noSort()); + tbl.add(new Xtc('pasazh_name', 'نام پاساژ').filterText().hidden().noSort()); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + + return tbl; +} + +export default buildOmdeListModel; diff --git a/apps/admin-panel/src/views/customer/reagentList/ReagentList.vue b/apps/admin-panel/src/views/customer/reagentList/ReagentList.vue new file mode 100644 index 0000000..42a6c10 --- /dev/null +++ b/apps/admin-panel/src/views/customer/reagentList/ReagentList.vue @@ -0,0 +1,159 @@ + + + + diff --git a/apps/admin-panel/src/views/customer/reagentList/ReagentListModel.ts b/apps/admin-panel/src/views/customer/reagentList/ReagentListModel.ts new file mode 100644 index 0000000..cac3fe9 --- /dev/null +++ b/apps/admin-panel/src/views/customer/reagentList/ReagentListModel.ts @@ -0,0 +1,47 @@ +// Ported from legacy henza_admin/src/views/customer/reagentList/ReagentListModel.js +// +// Column keys, Farsi titles and filter types preserved exactly, INCLUDING the +// legacy quirk that 'name' is added twice (once visible as 'نام و نام خانوادگی' +// and once hidden as 'نام'), which makes two columns share one dataIndex. +import XTbl, { Xtc } from '../../../components/x-table/model'; +import { useUsersStore } from '../../../stores/users'; + +export function buildReagentListModel(): XTbl { + const users = useUsersStore(); + if (Object.keys(users.cityFilters).length < 1) { + users.getCities(); + } + + const tbl = new XTbl('admin/customers-omde/reagent/list', 'لیست کارشناسان فروش'); + tbl.add(new Xtc('id', 'کد').filterNumber()); + tbl.add(new Xtc('lname', 'نام خانوادگی').filterText().hidden()); + tbl.add(new Xtc('name', 'نام و نام خانوادگی').filterText()); + // legacy adds 'name' a second time, hidden — preserved + tbl.add(new Xtc('name', 'نام').filterText().hidden()); + tbl.add(new Xtc('mobile', 'موبایل').filterText().hidden().noSort()); + tbl.add( + new Xtc('city_id', 'شهر') + .hidden() + .renderSlot('city') + .filterKey('city_id') + .filterSelect(users.cityFilters), + ); + tbl.add(new Xtc('gram_level', 'سطح اعتبار').filterNumber().noSort()); + tbl.add(new Xtc('percent', 'درصد').filterNumber().noSort()); + tbl.add(new Xtc('remain', 'بدهی گرمی').filterNumber().noSort()); + tbl.add(new Xtc('rial_remain', 'بدهی ریالی').filterNumber().noSort()); + tbl.add(new Xtc('grams_sell', 'میزان فروش').filterNumber().noSort()); + tbl.add(new Xtc('caj', 'تاریخ ثبت').filterDate().noSort()); + tbl.add(new Xtc('uaj', 'بروزرسانی').hidden().filterDate().noSort()); + tbl.add(new Xtc('sts_str', 'وضعیت ثبت سفارش').renderSlot('sts').noSort()); + tbl.add( + new Xtc('active', 'وضعیت حساب کاربری').renderSlot('active').noSort().filterSelect({ + '1': 'فعال', + '-1': 'غیر فعال', + }), + ); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildReagentListModel; diff --git a/apps/admin-panel/src/views/customer/single-customer/SingleCustomer.vue b/apps/admin-panel/src/views/customer/single-customer/SingleCustomer.vue new file mode 100644 index 0000000..fe8bb2e --- /dev/null +++ b/apps/admin-panel/src/views/customer/single-customer/SingleCustomer.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/apps/admin-panel/src/views/customer/single-customer/SingleCustomerListModel.ts b/apps/admin-panel/src/views/customer/single-customer/SingleCustomerListModel.ts new file mode 100644 index 0000000..e7e395f --- /dev/null +++ b/apps/admin-panel/src/views/customer/single-customer/SingleCustomerListModel.ts @@ -0,0 +1,16 @@ +// Ported from legacy henza_admin/src/views/customer/single-customer/SingleCustomerList.js +import XTbl, { Xtc } from '../../../components/x-table/model'; + +export function buildSingleCustomerListModel(): XTbl { + const tbl = new XTbl('admin/customers-omde/single/list', 'لیست مشتریان تک'); + tbl.add(new Xtc('id', 'کد').filterNumber()); + tbl.add(new Xtc('fname', 'نام').filterKey('fullName').filterText()); + tbl.add(new Xtc('lname', 'نام خانوادگی').filterKey('fullName').filterText().noSort()); + tbl.add(new Xtc('phone', 'تلفن تماس').filterKey('mobile').filterText().noSort()); + tbl.add(new Xtc('email', 'ایمیل').filterText().noSort()); + tbl.add(new Xtc('branch.name', 'نام شعبه').filterText().noSort()); + tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('op')); + return tbl; +} + +export default buildSingleCustomerListModel; diff --git a/apps/admin-panel/src/views/customer/single-customer/ViewSingleCustomer.vue b/apps/admin-panel/src/views/customer/single-customer/ViewSingleCustomer.vue new file mode 100644 index 0000000..a6e88f0 --- /dev/null +++ b/apps/admin-panel/src/views/customer/single-customer/ViewSingleCustomer.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/admin-panel/src/views/finical/addFinDoc.vue b/apps/admin-panel/src/views/finical/addFinDoc.vue new file mode 100644 index 0000000..d6a64da --- /dev/null +++ b/apps/admin-panel/src/views/finical/addFinDoc.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/apps/admin-panel/src/views/finical/list.vue b/apps/admin-panel/src/views/finical/list.vue new file mode 100644 index 0000000..2ca8934 --- /dev/null +++ b/apps/admin-panel/src/views/finical/list.vue @@ -0,0 +1,120 @@ + + + + diff --git a/apps/admin-panel/src/views/finical/list_model.ts b/apps/admin-panel/src/views/finical/list_model.ts new file mode 100644 index 0000000..f058a6e --- /dev/null +++ b/apps/admin-panel/src/views/finical/list_model.ts @@ -0,0 +1,38 @@ +// Ported from legacy henza_admin/src/views/finical/list_model.js +// +// Column keys, Farsi titles and filter types preserved exactly — INCLUDING two +// legacy quirks: the numeric columns 'price' and 'value' are declared +// .filterDate(), and the commented-out 'title' column stays absent. +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildDocumentListModel(): XTbl { + const tbl = new XTbl('admin/documents/list', 'لیست اسناد'); + tbl.add(new Xtc('id', 'شماره سفارش').filterNumber()); + tbl.add(new Xtc('type_str', 'نوع سند').filterText()); + // legacy declares filterDate() on these two numeric columns — preserved + tbl.add(new Xtc('price', 'پول').filterDate().noSort()); + tbl.add(new Xtc('value', 'وزن نهایی (گرم)').filterDate().noSort()); + tbl.add(new Xtc('to', 'به').filterText().hidden()); + tbl.add(new Xtc('customer.name', 'نام و نام خانوادگی مشتری').filterText()); + tbl.add(new Xtc('note', 'توضیحات').filterText()); + tbl.add(new Xtc('ayar', 'عیار').filterNumber().noSort()); + tbl.add(new Xtc('lab', 'آزمایشگاه').filterText().noSort()); + tbl.add(new Xtc('recive_date', 'تاریخ دریافت').filterDate().noSort()); + tbl.add(new Xtc('account_name', 'نام حساب').filterText().hidden()); + tbl.add(new Xtc('bank_name', 'بانک').filterText().hidden()); + tbl.add(new Xtc('caj', 'تاریخ ثبت').filterDate().noSort()); + tbl.add(new Xtc('uaj', 'بروزرسانی').hidden().filterDate().noSort()); + tbl.add( + new Xtc('confirmed', 'وضعیت تایید سند').renderSlot('confirmed').noSort().filterSelect({ + 0: 'تایید نشده', + 1: 'تایید شده', + }), + ); + tbl.add(new Xtc('price_per_gram', 'مظنه').hidden().filterText().noSort()); + tbl.add(new Xtc('original_value', 'وزن طلای داده شده').hidden().filterText().noSort()); + tbl.add(new Xtc('ang', 'انگ').hidden().filterText().noSort()); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildDocumentListModel; diff --git a/apps/admin-panel/src/views/invoice/AddInvoice.ts b/apps/admin-panel/src/views/invoice/AddInvoice.ts new file mode 100644 index 0000000..c49afa1 --- /dev/null +++ b/apps/admin-panel/src/views/invoice/AddInvoice.ts @@ -0,0 +1,19 @@ +// Ported from legacy henza_admin/src/views/invoice/AddInvoice.js — the product +// picker grid shown at the top of the invoice form. +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildInvoiceProductModel(): XTbl { + const tbl = new XTbl('admin/products/list', 'لیست محصولات'); + tbl.add(new Xtc('id', 'کد').filterNumber()); + tbl.add(new Xtc('ProductImage', 'تصویر محصول').renderSlot('image').noSort()); + tbl.add(new Xtc('title', 'نام محصول').filterText()); + tbl.add(new Xtc('min_gram', 'وزن').filterNumber().noSort()); + tbl.add(new Xtc('brand.title', 'برند').filterText()); + tbl.add(new Xtc('category.collection.title', 'دسته بندی').filterText()); + tbl.add(new Xtc('product_code', 'کد محصول').filterText()); + tbl.add(new Xtc('max_gram', 'حداکثر وزن').filterNumber().hidden().noSort()); + tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('op')); + return tbl; +} + +export default buildInvoiceProductModel; diff --git a/apps/admin-panel/src/views/invoice/AddInvoice.vue b/apps/admin-panel/src/views/invoice/AddInvoice.vue new file mode 100644 index 0000000..c811d0d --- /dev/null +++ b/apps/admin-panel/src/views/invoice/AddInvoice.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/apps/admin-panel/src/views/invoice/EditInvoice.vue b/apps/admin-panel/src/views/invoice/EditInvoice.vue new file mode 100644 index 0000000..caa652c --- /dev/null +++ b/apps/admin-panel/src/views/invoice/EditInvoice.vue @@ -0,0 +1,14 @@ + + + diff --git a/apps/admin-panel/src/views/invoice/ListInvoice.ts b/apps/admin-panel/src/views/invoice/ListInvoice.ts new file mode 100644 index 0000000..5ba724b --- /dev/null +++ b/apps/admin-panel/src/views/invoice/ListInvoice.ts @@ -0,0 +1,18 @@ +// Ported from legacy henza_admin/src/views/invoice/ListInvoice.js +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildInvoiceListModel(): XTbl { + const tbl = new XTbl('admin/invoices/list', 'لیست فاکتورها'); + // legacy keeps a commented-out duplicate of this column above it — not revived + tbl.add(new Xtc('id', 'ردیف').filterNumber()); + tbl.add(new Xtc('name', 'نام مشتری').filterKey('branch.name').filterText()); + tbl.add(new Xtc('sum_weight', 'جمع کل سفارش').filterNumber().noSort()); + tbl.add(new Xtc('count_item', 'تعداد آیتم ها').filterNumber().noSort()); + tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort()); + tbl.add(new Xtc('allocator_user.name', 'ثبت کننده').renderSlot('user').noSort()); + tbl.add(new Xtc('note', 'توضیحات').filterText().noSort()); + tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('actions')); + return tbl; +} + +export default buildInvoiceListModel; diff --git a/apps/admin-panel/src/views/invoice/ListInvoice.vue b/apps/admin-panel/src/views/invoice/ListInvoice.vue new file mode 100644 index 0000000..56277b9 --- /dev/null +++ b/apps/admin-panel/src/views/invoice/ListInvoice.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/apps/admin-panel/src/views/manage/Const.vue b/apps/admin-panel/src/views/manage/Const.vue new file mode 100644 index 0000000..ac7e165 --- /dev/null +++ b/apps/admin-panel/src/views/manage/Const.vue @@ -0,0 +1,372 @@ + + + + + diff --git a/apps/admin-panel/src/views/manage/report.vue b/apps/admin-panel/src/views/manage/report.vue new file mode 100644 index 0000000..a73dc10 --- /dev/null +++ b/apps/admin-panel/src/views/manage/report.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/apps/admin-panel/src/views/manage/wallet.vue b/apps/admin-panel/src/views/manage/wallet.vue new file mode 100644 index 0000000..9c92414 --- /dev/null +++ b/apps/admin-panel/src/views/manage/wallet.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/apps/admin-panel/src/views/newsletters/newsletter.ts b/apps/admin-panel/src/views/newsletters/newsletter.ts new file mode 100644 index 0000000..9e50223 --- /dev/null +++ b/apps/admin-panel/src/views/newsletters/newsletter.ts @@ -0,0 +1,16 @@ +// Ported from legacy henza_admin/src/views/newsletters/newsletter.js +// +// Legacy's url is 'fashion/table' while the view's own delete call posts to +// '/ddf' — two unrelated-looking endpoint names for one screen. Both map to the +// newsletter list here. +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildNewsletterModel(): XTbl { + const tbl = new XTbl('admin/misc/newsletter/list', 'لیست خبرنامه'); + tbl.add(new Xtc('name', 'نام').filterKey('email').filterText()); + tbl.add(new Xtc('mobile', 'شماره موبایل').filterNumber()); + tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('actions')); + return tbl; +} + +export default buildNewsletterModel; diff --git a/apps/admin-panel/src/views/newsletters/newsletters.vue b/apps/admin-panel/src/views/newsletters/newsletters.vue new file mode 100644 index 0000000..2002fda --- /dev/null +++ b/apps/admin-panel/src/views/newsletters/newsletters.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/apps/admin-panel/src/views/notify/create.vue b/apps/admin-panel/src/views/notify/create.vue new file mode 100644 index 0000000..7c11b31 --- /dev/null +++ b/apps/admin-panel/src/views/notify/create.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/apps/admin-panel/src/views/notify/notifyTbl.ts b/apps/admin-panel/src/views/notify/notifyTbl.ts new file mode 100644 index 0000000..3aa5591 --- /dev/null +++ b/apps/admin-panel/src/views/notify/notifyTbl.ts @@ -0,0 +1,15 @@ +// Ported from legacy henza_admin/src/views/notify/notifyTbl.js +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildNotifyModel(): XTbl { + const tbl = new XTbl('admin/misc/notify/list', 'لیست نوتیفیکیشن های دریافتی'); + tbl.add(new Xtc('id', 'شماره')); + tbl.add(new Xtc('title', 'عنوان')); + // legacy column key is 'note' but the payload carries the body as `text` + tbl.add(new Xtc('text', 'متن').noSort()); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + tbl.noFilters(); + return tbl; +} + +export default buildNotifyModel; diff --git a/apps/admin-panel/src/views/notify/user.vue b/apps/admin-panel/src/views/notify/user.vue new file mode 100644 index 0000000..7427140 --- /dev/null +++ b/apps/admin-panel/src/views/notify/user.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/apps/admin-panel/src/views/offersIgnore/offersIgnore-add-edit.vue b/apps/admin-panel/src/views/offersIgnore/offersIgnore-add-edit.vue new file mode 100644 index 0000000..90326ba --- /dev/null +++ b/apps/admin-panel/src/views/offersIgnore/offersIgnore-add-edit.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/apps/admin-panel/src/views/order/List.vue b/apps/admin-panel/src/views/order/List.vue new file mode 100644 index 0000000..3963919 --- /dev/null +++ b/apps/admin-panel/src/views/order/List.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/apps/admin-panel/src/views/order/MakeOrder.vue b/apps/admin-panel/src/views/order/MakeOrder.vue new file mode 100644 index 0000000..c07b66c --- /dev/null +++ b/apps/admin-panel/src/views/order/MakeOrder.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/apps/admin-panel/src/views/order/MakeOrderModel.ts b/apps/admin-panel/src/views/order/MakeOrderModel.ts new file mode 100644 index 0000000..a2551a6 --- /dev/null +++ b/apps/admin-panel/src/views/order/MakeOrderModel.ts @@ -0,0 +1,37 @@ +// Ported from legacy henza_admin/src/views/order/MakeOrderModel.js +// +// Column keys, Farsi titles and filter types preserved exactly, including +// legacy's commented-out columns (total_price, items, branch.name) which stay +// absent. The gate on this table's status column is 'order.tamin.state' in +// legacy — NOT 'order.makeOrder.state' as the page name would suggest. Kept. +import XTbl, { Xtc } from '../../components/x-table/model'; +import { useUsersStore } from '../../stores/users'; + +export function buildMakeOrderModel(): XTbl { + const users = useUsersStore(); + + const tbl = new XTbl('admin/pre-orders/list', 'سفارش های ساختنی'); + tbl.add(new Xtc('customer.id', 'شماره').filterKey('id').filterNumber()); + tbl.add(new Xtc('customer.fname', 'نام مشتری').filterKey('customer.fullName').filterText()); + tbl.add(new Xtc('customer.lname', 'نام خانوادگی').filterKey('customer.fullName').filterText()); + tbl.add(new Xtc('color', 'رنگ محصول').filterKey('colorName').filterText()); + tbl.add(new Xtc('size', 'سایز محصول').filterKey('sizeSpec').filterText()); + tbl.add(new Xtc('color_mina', 'رنگ مینا').filterKey('minaColor').filterText()); + tbl.add(new Xtc('color_stone', 'رنگ سنگ').filterKey('stoneColor').filterText()); + tbl.add(new Xtc('sum', 'تعداد').filterText()); + tbl.add(new Xtc('time_creat_fa', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort()); + tbl.add( + new Xtc('time_update_fa', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort(), + ); + tbl.add(new Xtc('customer.name', 'ثبت کننده').renderSlot('name').noSort()); + + // legacy gate: 'order.tamin.state' (not makeOrder) — preserved verbatim + if (users.roles && users.roles.includes('order.tamin.state')) { + tbl.add(new Xtc('sts_str_fa', 'وضعیت').renderSlot('sts').noSort()); + } + + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildMakeOrderModel; diff --git a/apps/admin-panel/src/views/order/OmdeList.vue b/apps/admin-panel/src/views/order/OmdeList.vue new file mode 100644 index 0000000..7e3628c --- /dev/null +++ b/apps/admin-panel/src/views/order/OmdeList.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/apps/admin-panel/src/views/order/OmdeListModel.ts b/apps/admin-panel/src/views/order/OmdeListModel.ts new file mode 100644 index 0000000..7c9a649 --- /dev/null +++ b/apps/admin-panel/src/views/order/OmdeListModel.ts @@ -0,0 +1,56 @@ +// Ported from legacy henza_admin/src/views/order/OmdeListModel.js +// +// Column keys, Farsi titles, filter types, the status/confirmed value maps and +// the 'order.omde.state' gate are preserved exactly. Changes: the url is the new +// clean REST path (legacy used the absolute 'https://henzagold.com/oapi/admin/ +// major/order/table'), and roles/cities come from Pinia instead of Vuex. +import XTbl, { Xtc } from '../../components/x-table/model'; +import { useUsersStore } from '../../stores/users'; + +export function buildOmdeListModel(): XTbl { + const users = useUsersStore(); + + // legacy: fetch the city list once if the filter map is empty + if (Object.keys(users.cityFilters).length < 1) { + users.getCities(); + } + + const tbl = new XTbl('admin/orders/wholesale/list', 'لیست سفارش های عمده'); + tbl.add(new Xtc('id', 'شماره').filterNumber()); + tbl.add(new Xtc('customer.name', 'مشتری').filterKey('customer.fullName').filterText()); + tbl.add( + new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort(), + ); + tbl.add(new Xtc('input', 'جمع کل سفارش').filterKey('totalGoldWeightGrams').filterText()); + tbl.add(new Xtc('discount', 'جمع کل تخفیف (درصد)').filterText()); + // legacy kept the .filterSelect(cityFilters) call commented out — preserved. + tbl.add(new Xtc('customer.city_id', 'شهر').hidden().renderSlot('city')); + tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort()); + tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort()); + tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort()); + + if (users.roles && users.roles.includes('order.omde.state')) { + tbl.add( + new Xtc('sts', 'وضعیت').renderSlot('sts_str').noSort().filterKey('status').filterSelect({ + 0: 'معلق', + 1: 'ثبت سفارش', + 2: 'تایید مدیر سیستم', + 3: 'در حال تامین', + 4: 'در حال آماده سازی ', + 5: 'تحویل مسئول فروش', + 6: 'تحویل مشتری ', + }), + ); + } + + tbl.add( + new Xtc('confirmed', 'وضعیت تایید مشتری').renderSlot('confirmed_str').noSort().filterSelect({ + 1: 'تایید شده', + 2: 'در انتظار تایید', + }), + ); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildOmdeListModel; diff --git a/apps/admin-panel/src/views/order/TaminList.vue b/apps/admin-panel/src/views/order/TaminList.vue new file mode 100644 index 0000000..96bfae8 --- /dev/null +++ b/apps/admin-panel/src/views/order/TaminList.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/apps/admin-panel/src/views/order/TaminListModel.ts b/apps/admin-panel/src/views/order/TaminListModel.ts new file mode 100644 index 0000000..53a2793 --- /dev/null +++ b/apps/admin-panel/src/views/order/TaminListModel.ts @@ -0,0 +1,37 @@ +// Ported from legacy henza_admin/src/views/order/TaminListModel.js +// +// Note legacy's title string for this table is also 'لیست سفارش های عمده' +// (same as OmdeListModel) — preserved rather than "corrected". +import XTbl, { Xtc } from '../../components/x-table/model'; +import { useUsersStore } from '../../stores/users'; + +export function buildTaminListModel(): XTbl { + const users = useUsersStore(); + + const tbl = new XTbl('admin/orders/tamin/list', 'لیست سفارش های عمده'); + tbl.add(new Xtc('id', 'شماره').filterNumber()); + tbl.add(new Xtc('customer.name', 'مشتری').filterKey('customer.fullName').filterText()); + tbl.add(new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort()); + tbl.add(new Xtc('input', 'ورودی').filterKey('totalGoldWeightGrams').filterText()); + tbl.add(new Xtc('wage', 'اجرت').filterKey('wageWeightGrams').filterText().noSort()); + tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort()); + tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort()); + tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort()); + + if (users.roles && users.roles.includes('order.tamin.state')) { + // legacy called .filterSelect() with NO argument here, leaving the dropdown + // empty. Preserved exactly — see the note on filterSelect in x-table/model.ts. + tbl.add(new Xtc('sts', 'وضعیت').renderSlot('sts_str').noSort().filterSelect()); + } + + tbl.add( + new Xtc('confirmed', 'وضعیت تایید مشتری').renderSlot('confirmed_str').noSort().filterSelect({ + 1: 'تایید شده', + 2: 'در انتظار تایید', + }), + ); + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildTaminListModel; diff --git a/apps/admin-panel/src/views/order/create.vue b/apps/admin-panel/src/views/order/create.vue new file mode 100644 index 0000000..c6f9d85 --- /dev/null +++ b/apps/admin-panel/src/views/order/create.vue @@ -0,0 +1,424 @@ + + + + + diff --git a/apps/admin-panel/src/views/order/orderListModel.ts b/apps/admin-panel/src/views/order/orderListModel.ts new file mode 100644 index 0000000..1a9be42 --- /dev/null +++ b/apps/admin-panel/src/views/order/orderListModel.ts @@ -0,0 +1,33 @@ +// Ported from legacy henza_admin/src/views/order/orderListModel.js +// +// Column keys, Farsi titles, filter types, slots and the conditional +// 'order.tamin.state' block are preserved exactly. Only two things changed: +// - the url is the new clean REST path instead of the absolute legacy one +// ('https://henzagold.com/oapi/admin/single-order') +// - roles come from the Pinia users store instead of Vuex +import XTbl, { Xtc } from '../../components/x-table/model'; +import { useUsersStore } from '../../stores/users'; + +export function buildOrderListModel(): XTbl { + const tbl = new XTbl('admin/orders/list', 'لیست سفارش های تک فروشی'); + tbl.add(new Xtc('id', 'شماره').filterNumber()); + tbl.add(new Xtc('customer.fname', 'نام مشتری').filterKey('customer.fullName').filterText()); + tbl.add(new Xtc('customer.lname', 'نام خانوادگی').filterKey('customer.fullName').filterText()); + tbl.add(new Xtc('total_price', 'جمع کل سفارش').renderSlot('total_price').filterText()); + tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort()); + tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort()); + tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort()); + tbl.add(new Xtc('user', 'ثبت کننده').renderSlot('user').noSort()); + + // legacy: gated on store.state.users.roles.includes('order.tamin.state') + const users = useUsersStore(); + if (users.roles && users.roles.includes('order.tamin.state')) { + tbl.add(new Xtc('sts_str', 'وضعیت').renderSlot('sts').noSort()); + tbl.add(new Xtc('branch.name', 'نام شعبه').filterText().noSort()); + } + + tbl.add(new Xtc('opt', 'عملیات').noSort().renderSlot('opt')); + return tbl; +} + +export default buildOrderListModel; diff --git a/apps/admin-panel/src/views/pages/Error404.vue b/apps/admin-panel/src/views/pages/Error404.vue new file mode 100644 index 0000000..500421b --- /dev/null +++ b/apps/admin-panel/src/views/pages/Error404.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/admin-panel/src/views/pages/Error500.vue b/apps/admin-panel/src/views/pages/Error500.vue new file mode 100644 index 0000000..c090f41 --- /dev/null +++ b/apps/admin-panel/src/views/pages/Error500.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/admin-panel/src/views/pages/Maintenance.vue b/apps/admin-panel/src/views/pages/Maintenance.vue new file mode 100644 index 0000000..15ed448 --- /dev/null +++ b/apps/admin-panel/src/views/pages/Maintenance.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/admin-panel/src/views/pages/NotAuthorized.vue b/apps/admin-panel/src/views/pages/NotAuthorized.vue new file mode 100644 index 0000000..d6ca3af --- /dev/null +++ b/apps/admin-panel/src/views/pages/NotAuthorized.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/admin-panel/src/views/promotion/PromotionForm.vue b/apps/admin-panel/src/views/promotion/PromotionForm.vue new file mode 100644 index 0000000..c2f8822 --- /dev/null +++ b/apps/admin-panel/src/views/promotion/PromotionForm.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/apps/admin-panel/src/views/promotion/autopromotion.vue b/apps/admin-panel/src/views/promotion/autopromotion.vue new file mode 100644 index 0000000..6e2b9f4 --- /dev/null +++ b/apps/admin-panel/src/views/promotion/autopromotion.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/apps/admin-panel/src/views/promotion/promotionListModel.ts b/apps/admin-panel/src/views/promotion/promotionListModel.ts new file mode 100644 index 0000000..7ac487a --- /dev/null +++ b/apps/admin-panel/src/views/promotion/promotionListModel.ts @@ -0,0 +1,17 @@ +// Ported from legacy henza_admin/src/table/promotion.js +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildPromotionListModel(): XTbl { + const tbl = new XTbl('admin/promotions/list', 'لیست تخفیف ها'); + tbl.add(new Xtc('id', 'کد').filterNumber()); + tbl.add(new Xtc('title', 'نام پروموشن').filterText()); + tbl.add(new Xtc('promotion_type_str', 'نوع پروموشن').noSort()); + tbl.add(new Xtc('platform', 'پلتفرم نمایش ').noSort()); + tbl.add(new Xtc('status', 'وضعیت').renderSlot('sts').noSort()); + tbl.add(new Xtc('start', 'تاریخ شروع').renderSlot('startDate').noSort()); + tbl.add(new Xtc('end', 'تاریخ پایان ').renderSlot('endDate').noSort()); + tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort()); + return tbl; +} + +export default buildPromotionListModel; diff --git a/apps/admin-panel/src/views/promotion/promotion_add_edit.vue b/apps/admin-panel/src/views/promotion/promotion_add_edit.vue new file mode 100644 index 0000000..ce42396 --- /dev/null +++ b/apps/admin-panel/src/views/promotion/promotion_add_edit.vue @@ -0,0 +1,13 @@ + + + diff --git a/apps/admin-panel/src/views/promotion/promotion_list.vue b/apps/admin-panel/src/views/promotion/promotion_list.vue new file mode 100644 index 0000000..4390512 --- /dev/null +++ b/apps/admin-panel/src/views/promotion/promotion_list.vue @@ -0,0 +1,103 @@ + + + + diff --git a/apps/admin-panel/src/views/promotion/promotion_tak_add_edit.vue b/apps/admin-panel/src/views/promotion/promotion_tak_add_edit.vue new file mode 100644 index 0000000..baab69c --- /dev/null +++ b/apps/admin-panel/src/views/promotion/promotion_tak_add_edit.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/reg.vue b/apps/admin-panel/src/views/reg.vue new file mode 100644 index 0000000..f8f77e7 --- /dev/null +++ b/apps/admin-panel/src/views/reg.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/apps/admin-panel/src/views/report/order_item.vue b/apps/admin-panel/src/views/report/order_item.vue new file mode 100644 index 0000000..7b5372c --- /dev/null +++ b/apps/admin-panel/src/views/report/order_item.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/apps/admin-panel/src/views/report/product.vue b/apps/admin-panel/src/views/report/product.vue new file mode 100644 index 0000000..1bb3390 --- /dev/null +++ b/apps/admin-panel/src/views/report/product.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/apps/admin-panel/src/views/sms/create.vue b/apps/admin-panel/src/views/sms/create.vue new file mode 100644 index 0000000..ab7c15b --- /dev/null +++ b/apps/admin-panel/src/views/sms/create.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/apps/admin-panel/src/views/sms/smsTbl.ts b/apps/admin-panel/src/views/sms/smsTbl.ts new file mode 100644 index 0000000..d8b8743 --- /dev/null +++ b/apps/admin-panel/src/views/sms/smsTbl.ts @@ -0,0 +1,16 @@ +// The sent-SMS log. Legacy read it from `POST sms/table` inline in sms/create.vue +// rather than through a table model file. +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildSmsModel(): XTbl { + const tbl = new XTbl('admin/misc/sms/list', 'لیست پیامک ها'); + tbl.add(new Xtc('id', 'شماره')); + tbl.add(new Xtc('text', 'متن پیامک').filterKey('body').filterText()); + tbl.add(new Xtc('mobile', 'گیرندگان').noSort()); + tbl.add(new Xtc('sent_count', 'تعداد').noSort()); + tbl.add(new Xtc('sender', 'ارسال کننده').noSort()); + tbl.add(new Xtc('caj', 'تاریخ').filterKey('createdAt').filterDate().noSort()); + return tbl; +} + +export default buildSmsModel; diff --git a/apps/admin-panel/src/views/statics/index.vue b/apps/admin-panel/src/views/statics/index.vue new file mode 100644 index 0000000..507fc85 --- /dev/null +++ b/apps/admin-panel/src/views/statics/index.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/apps/admin-panel/src/views/user/ListUser.vue b/apps/admin-panel/src/views/user/ListUser.vue new file mode 100644 index 0000000..c24b43c --- /dev/null +++ b/apps/admin-panel/src/views/user/ListUser.vue @@ -0,0 +1,98 @@ + + + + diff --git a/apps/admin-panel/src/views/user/Permission.vue b/apps/admin-panel/src/views/user/Permission.vue new file mode 100644 index 0000000..32ed7e1 --- /dev/null +++ b/apps/admin-panel/src/views/user/Permission.vue @@ -0,0 +1,218 @@ + + + + + + + diff --git a/apps/admin-panel/src/views/user/UserCreate.vue b/apps/admin-panel/src/views/user/UserCreate.vue new file mode 100644 index 0000000..7b583f1 --- /dev/null +++ b/apps/admin-panel/src/views/user/UserCreate.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/apps/admin-panel/src/views/user/UserEdit.vue b/apps/admin-panel/src/views/user/UserEdit.vue new file mode 100644 index 0000000..ff31364 --- /dev/null +++ b/apps/admin-panel/src/views/user/UserEdit.vue @@ -0,0 +1,12 @@ + + + diff --git a/apps/admin-panel/src/views/user/myData.vue b/apps/admin-panel/src/views/user/myData.vue new file mode 100644 index 0000000..c8e5aac --- /dev/null +++ b/apps/admin-panel/src/views/user/myData.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/admin-panel/src/views/user/permissionConst.ts b/apps/admin-panel/src/views/user/permissionConst.ts new file mode 100644 index 0000000..4ba733b --- /dev/null +++ b/apps/admin-panel/src/views/user/permissionConst.ts @@ -0,0 +1,872 @@ +// Ported from legacy henza_admin/src/views/user/permissionConst.js +// +// Converted MECHANICALLY (see scratchpad/convert_tree.js) so all 181 Farsi +// permission labels survive byte-exact. 22 top-level groups, up to 4 levels deep. +// +// This tree is the vocabulary the Permission screen offers. Note it is NOT the +// same set as the rids the app enforces — see the audit in +// docs/ADMIN-PORT-PLAN.md: +// - 144 rids are actually enforced (58 by routes, 100 by $hasPermission, overlapping) +// - 176 distinct keys appear in this tree +// - 36 tree keys are never checked anywhere (dead entries, kept for fidelity) +// - 4 enforced rids are MISSING from this tree, so they cannot be granted +// from the UI at all: buy.order.list, newsletters, +// persons.create.customer.tak, persons.list.sellers.view +// All four quirks are preserved rather than "fixed". + +export interface PermissionNode { + title: string; + key: string; + children?: PermissionNode[]; +} + +export const treeData: PermissionNode[] = [ + { + "title": "پیشخوان", + "key": "dashboard", + "children": [ + { + "title": "آمار عمده فروشی", + "key": "dashboard.omde" + }, + { + "title": "آمار تک فروشی", + "key": "dashboard.tak" + } + ] + }, + { + "title": "اشخاص", + "key": "persons", + "children": [ + { + "title": "اضافه کردن شخص", + "key": "persons.create", + "children": [ + { + "title": "افزودن کارشناس فروش", + "key": "persons.create.seller" + }, + { + "title": "افزودن مشتری عمده", + "key": "persons.create.client" + }, + { + "title": "افزودن مشتری تک", + "key": "persons.create.single" + }, + ] + }, + { + "title": "لیست کارشناسان فروش", + "key": "persons.list.sellers", + "children": [ + { + "title": "ویرایش اطلاعات کارشناس فروش", + "key": "persons.list.sellers.edit" + }, + { + "title": "مشاهده سفارشات کارشناس فروش", + "key": "persons.list.sellers.view.orders" + }, + { + "title": "مشاهده اسناد مالی کارشناس فروش", + "key": "persons.list.sellers.view.documents" + }, + { + "title": "توقف سفارش کارشناس فروش", + "key": "persons.list.sellers.unable.order" + }, + { + "title": "فعال سازی سفارش کارشناس فروش", + "key": "persons.list.sellers.able.order" + }, + { + "title": "فعال سازی حساب کاربری کارشناس فروش", + "key": "persons.list.sellers.able" + }, + { + "title": "غیر فعال سازی حساب کاربری کارشناس فروش", + "key": "persons.list.sellers.unable" + } + ] + }, + { + "title": "لیست مشتریان عمده", + "key": "persons.list.client.omde", + "children": [ + { + "title": "مشاهده اطلاعات مشتریان عمده", + "key": "persons.list.client.omde.view" + }, + { + "title": "ویرایش اطلاعات مشتری عمده", + "key": "persons.list.client.omde.edit" + }, + { + "title": "مشاهده سفارشات مشتری عمده", + "key": "persons.list.client.omde.view.orders" + }, + { + "title": "مشاهده اسناد مالی مشتری عمده", + "key": "persons.list.client.omde.view.documents" + }, + { + "title": "توقف سفارش مشتری عمده", + "key": "persons.list.client.omde.unable.order" + }, + { + "title": "فعال سازی سفارش مشتری عمده", + "key": "persons.list.client.omde.able.order" + }, + { + "title": "فعال سازی مشتری عمده", + "key": "persons.list.client.omde.able" + }, + { + "title": "غیر فعال سازی مشتری عمده", + "key": "persons.list.client.omde.unable" + } + ] + }, + { + "title": "لیست مشتریان تک", + "key": "persons.list.client.single", + "children": [ + { + "title": "مشاهده اطلاعات مشتریان تک", + "key": "persons.list.client.single.view" + }, + { + "title": "ویرایش اطلاعات مشتری تک", + "key": "persons.list.client.single.edit" + }, + { + "title": "مشاهده اطلاعات مشتری تک", + "key": "persons.list.client.single.view" + }, + ] + } + ] + }, + { + "title": "شعب", + "key": "branch", + "children": + [ + { + "title": "افزودن شعب", + "key": "branch.create" + }, + { + "title": " لیست شعب", + "key": "branch.list", + } + ] + }, + { + "title": "کارگاه", + "key": "workshop", + "children": + [ + { + "title": "افزودن کارگاه", + "key": "workshop.create" + }, + { + "title": " لیست کارگاه ها", + "key": "workshop.list", + } + ] + }, + { + "title": "محصولات", + "key": "product", + "children": + [ + { + "title": "افزودن محصول", + "key": "product.create" + }, + { + "title": "لیست محصولات", + "key": "product.list", + "children": + [ + { + "title": "مشاهده لیست محصولات", + "key": "product.list.view" + }, + { + "title": "ویرایش اطلاعات محصول", + "key": "product.list.edit" + }, + { + "title": "مشاهده اطلاعات جامع محصول", + "key": "product.list.view" + }, + { + "title": "تنوع های محصول", + "key": "product.list.variants", + "children": + [ + { + "title": "مشاهده تنوع های محصول", + "key": "product.list.variants.view", + }, + { + "title": "ویرایش تنوع محصول", + "key": "product.list.variants.edit", + }, + { + "title": "حذف تنوع محصول", + "key": "product.list.variants.remove", + }, + { + "title": "افزودن تنوع محصول", + "key": "product.list.variants.add", + } + ] + }, + { + "title": "حذف محصول", + "key": "product.list.remove" + }, + { + "title": "تغییر دسته ای", + "key": "product.list.group.edit" + } + ] + }, + { + "title": "تغییر درصد محصولات", + "key": "product.percent" + } + ] + }, + { + "title": "سفارشات", + "key": "order", + "children": + [ + { + "title": "لیست سفارشات", + "key": "order.list", + "children": + [ + { + "title": "مشاهده لیست سفارشات", + "key": "order.list.view" + }, + { + "title": "وضعیت سفارش", + "key": "order.list.sts", + "children": + [ + { + "title": "مشاهده وضعیت سفارش", + "key": "order.list.sts.view", + }, + { + "title": "حذف وضعیت", + "key": "order.list.sts.remove", + }, + { + "title": "ثبت وضعیت", + "key": "order.list.sts.submit", + } + ] + }, + { + "title": "مشاهده سفارش", + "key": "order.list.view" + }, + { + "title": "ویرایش سفارش", + "key": "order.list.view.edit", + }, + { + "title": "دریافت pdf", + "key": "order.list.pdf", + } + ] + }, + { + "title": "سفارشات عمده", + "key": "order.omde", + "children": + [ + { + "title": "وضعیت سفارش", + "key": "order.omde.state", + "children": + [ + { + "title": "مشاهده وضعیت سفارش", + "key": "order.omde.state.view" + }, + { + "title": "ثبت وضعیت سفارش", + "key": "order.omde.state.submit" + }, + { + "title": "حذف وضعیت سفارش", + "key": "order.omde.state.remove" + } + ] + }, + { + "title": "مشاهده سفارش", + "key": "order.omde.view", + }, + { + "title": "ویرایش سفارش", + "key": "order.omde.view.edit" + }, + { + "title": "حذف آیتم از سفارش", + "key": "order.omde.view.remove" + }, + { + "title": "حذف سفارش", + "key": "order.omde.remove", + }, + { + "title": "دریافت pdf سفارش", + "key": "order.omde.get", + } + ] + }, + { + "title": "سفارشات ساختنی", + "key": "order.makeOrder", + "children": + [ + { + "title": "وضعیت سفارش", + "key": "order.makeOrder.state", + "children": + [ + { + "title": "مشاهده وضعیت سفارش", + "key": "order.makeOrder.state.view" + }, + { + "title": "ثبت وضعیت سفارش", + "key": "order.makeOrder.state.submit" + }, + { + "title": "حذف وضعیت سفارش", + "key": "order.makeOrder.state.remove" + } + ] + }, + { + "title": "مشاهده سفارش", + "key": "order.makeOrder.view", + }, + { + "title": "ویرایش سفارش", + "key": "order.makeOrder.view.edit" + }, + { + "title": "حذف آیتم از سفارش", + "key": "order.makeOrder.view.remove" + }, + { + "title": "حذف سفارش", + "key": "order.makeOrder.remove", + }, + { + "title": "دریافت pdf سفارش", + "key": "order.makeOrder.get", + } + ] + }, + { + "title": "لیست تامین", + "key": "order.tamin", + "children": + [ + { + "title": "مشاهده لیست تامین", + "key": "order.tamin.view" + }, + { + "title": "وضعیت سفارش", + "key": "order.tamin.state", + "children": + [ + { + "title": "مشاهده وضعیت سفارش", + "key": "order.tamin.state.view" + }, + { + "title": "ثبت وضعیت سفارش", + "key": "order.tamin.state.submit" + }, + { + "title": "حذف وضعیت سفارش", + "key": "order.tamin.state.remove" + } + ] + }, + { + "title": "مشاهده سفارش", + "key": "order.tamin.view" + }, + { + "title": "ویرایش سفارش", + "key": "order.tamin.view.edit" + } + ] + }, + { + "title": "ثبت سفارش تک", + "key": "order.create", + } + ] + }, + { + "title": "مدیران", + "key": "user", + "children": + [ + { + "title": "افزودن مدیر", + "key": "user.create" + }, + { + "title": "لیست مدیران", + "key": "user.list", + "children": + [ + { + "title": "مشاهده لیست مدیران", + "key": "user.list.view" + }, + { + "title": "ویرایش مدیر", + "key": "user.list.edit" + }, + { + "title": "حذف مدیر", + "key": "user.list.remove" + }, + { + "title": "افزودن مدیر", + "key": "user.list.create" + } + ] + }, + { + "title": "دسترسی ها", + "key": "user.permission", + "children": + [ + { + "title": "ویرایش دسترسی", + "key": "user.permission.edit" + }, + { + "title": "افزودن دسترسی", + "key": "user.permission.create" + } + ] + } + ] + }, + { + "title": "دسته بندی ها", + "key": "collection", + "children": + [ + { + "title": "افزودن دسته بندی", + "key": "collection.create" + }, + { + "title": " لیست دسته بندی ها", + "key": "collection.list", + "children": + [ + { + "title": "مشاهده لیست دسته بندی ها", + "key": "collection.list.view" + }, + { + "title": "ویرایش دسته", + "key": "collection.list.edit" + }, + { + "title": "حذف دسته", + "key": "collection.list.remove" + } + ] + } + ] + }, + { + "title": "اسناد مالی", + "key": "document", + "children": + [ + { + "title": " لیست اسناد مالی", + "key": "document.list", + "children": + [ + { + "title": "مشاهده لیست اسناد مالی", + "key": "document.list.view" + }, + { + "title": "ویرایش سند مالی", + "key": "document.list.edit" + }, + { + "title": "حذف سند مالی", + "key": "document.list.remove" + }, + { + "title": "رد کردن سند مالی", + "key": "document.list.deny" + }, + { + "title": "تأیید کردن سند مالی", + "key": "document.list.confirm" + }, + { + "title": "دریافت pdf سند مالی", + "key": "document.list.get", + } + ] + }, + { + "title": "وارد کردن سند مالی مشتری", + "key": "document.create" + } + ] + }, + { + "title": "استثناعات تخفیف", + "key": "offersIgnore", + }, + { + "title": "تخفیفات", + "key": "offers", + "children": + [ + { + "title": " لیست تخفیفات", + "key": "offers.list", + "children": + [ + { + "title": "مشاهده لیست تخفیفات", + "key": "offers.list.view" + }, + { + "title": "ویرایش تخفیف عمده", + "key": "offers.list.edit" + }, + { + "title": "ویرایش تخفیف تک", + "key": "offers.list.tak.edit" + }, + { + "title": "حذف تخفیف عمده", + "key": "offers.list.remove" + }, + { + "title": "حذف تخفیف تک", + "key": "offers.list.tak.remove" + } + ] + }, + { + "title": "تعریف تخفیف عمده", + "key": "offers.create" + }, + { + "title": "تعریف تخفیف تک", + "key": "offers.create.tak" + }, + { + "title": "تخفیف اتوماتیک", + "key": "offers.autopromotion" + } + ] + }, + { + "title": "فاکتور انتقال", + "key": "invoice", + "children": + [ + { + "title": "افزودن فاکتور", + "key": "invoice.create" + }, + { + "title": " لیست فاکتورها", + "key": "invoice.list", + } + ] + }, + { + "title": "گزارشات", + "key": "report", + "children": + [ + { + "title": "گزارش تجمیعی سفارشات و اسناد مالی", + "key": "report.order.item" + }, + { + "title": "گزارش محصولات", + "key": "report.product" + }, + ] + }, + { + "title": "شهرها", + "key": "cities", + "children": + [ + { + "title": "افزودن شهر", + "key": "cities.create" + }, + { + "title": "لیست شهرها", + "key": "cities.list", + "children": + [ + { + "title": "مشاهده لیست شهرها", + "key": "cities.list.view" + }, + { + "title": "حذف شهر", + "key": "cities.list.remove" + }, + { + "title": "مشاهده شهر", + "key": "cities.list.view" + }, + { + "title": "ویرایش شهر", + "key": "cities.list.view.edit" + } + ] + } + ] + }, + { + "title": "دیدگاه ها", + "key": "comment", + "children": [ + { + "title": "مشاهده لیست دیدگاه ها", + "key": "comment.view.all" + }, + { + "title": "تغییر وضعیت دیدگاه", + "key": "comment.state" + }, + { + "title": "مشاهده دیدگاه", + "key": "comment.view" + }, + { + "title": "ویرایش دیدگاه", + "key": "comment.view.edit", + }, + { + "title": "حذف دیدگاه", + "key": "comment.remove" + } + ] + }, + { + "title": "پیامک", + "key": "sms", + "children": + [ + { + "title": "ارسال پیامک", + "key": "sms.create", + "children": + [ + { + "title": "ارسال برای یک شماره تلفن", + "key": "sms.create.single" + }, + { + "title": "ارسال برای همه کاربران", + "key": "sms.create.all" + }, + { + "title": "ارسال برای کارشناسان فروش", + "key": "sms.create.reagent" + }, + { + "title": "ارسال برای مشتریان عمده", + "key": "sms.create.omde" + }, + { + "title": "ارسال برای چند شماره تلفن", + "key": "sms.create.multiple" + } + ], + }, + { + "title": "لیست پیامک های ارسال شده", + "key": "sms.list", + // "children": [], + }, + ] + }, + { + "title": "کیف پول", + "key": "wallet", + "children": + [ + { + "title": "لیست کیف پول های شارژ شده", + "key": "wallet.list", + }, + { + "title": "شارژ کیف پول", + "key": "wallet.charge", + }, + { + "title": "تغییر درصد شارژ کیف پول", + "key": "wallet.percent", + }, + ] + }, + { + "title": "نوتیفیکیشن", + "key": "notify", + "children": + [ + { + "title": "ارسال نوتیفیکیشن", + "key": "notify.create", + "children": + [ + { + "title": "ارسال برای کارشناسان فروش", + "key": "notify.create.multiple.reagent" + }, + { + "title": "ارسال برای یک مشتری", + "key": "notify.create.single.customer" + }, + { + "title": "ارسال برای مشتریان عمده", + "key": "notify.create.multiple.omde" + }, + { + "title": "ارسال برای همه مشتریان", + "key": "notify.create.multiple.customer" + }, + { + "title": "ارسال برای یک مدیر", + "key": "notify.create.single.admin" + }, + { + "title": "ارسال برای همه مدیران", + "key": "notify.create.multiple.admin" + } + ], + }, + { + "title": "لیست نوتیفیکیشن های دریافتی", + "key": "notify.me", + }, + ] + }, + { + "title": "پیکربندی", + "key": "config", + "children": [ + { + "title": "مدیریت ثابت ها", + "key": "config.statics", + "children": + [ + { + "title": "برند ها", + "key": "config.statics.brand", + "children": + [ + { + "title": "افزودن برند", + "key": "config.statics.brand.create" + }, + { + "title": "ویرایش برند", + "key": "config.statics.brand.edit" + }, + { + "title": "حذف برند", + "key": "config.statics.brand.remove" + }, + ] + }, + { + "title": "برچسب ها", + "key": "config.statics.tags", + "children": + [ + { + "title": "افزودن برچسب", + "key": "config.statics.tags.create" + }, + { + "title": "ویرایش برچسب", + "key": "config.statics.tags.edit" + }, + { + "title": "حذف برچسب", + "key": "config.statics.tags.remove" + }, + ] + } + ] + }, + { + "title": "مدیریت کیف پول", + "key": "config.wallet", + }, + { + "title": "گزارش", + "key": "config.report", + }, + ] + }, + { + "title": "آمار", + "key": "statics", + }, + { + "title": "خبرنامه", + "key": "newsletters.newsletters", + }, + { + "title": "سفارشی", + "key": "custom", + "children": [ + { + "title": "اسلایدر موبایل", + "key": "custom.config.statics.slider" + }, + { + "title": "برچسب بنر اول سایت", + "key": "custom.config.statics.tags.banner.ids" + }, + ] + } +] diff --git a/apps/admin-panel/src/views/user/userTbl.ts b/apps/admin-panel/src/views/user/userTbl.ts new file mode 100644 index 0000000..d946c97 --- /dev/null +++ b/apps/admin-panel/src/views/user/userTbl.ts @@ -0,0 +1,17 @@ +// Ported from legacy henza_admin/src/table/userTbl.js +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildUserListModel(): XTbl { + const tbl = new XTbl('admin/users/list', 'لیست مدیران'); + tbl.add(new Xtc('id', 'کد مدیر')); + // Legacy stored fname/lname separately; the schema has one fullName, which the + // API returns under BOTH keys so these two columns bind unchanged. + tbl.add(new Xtc('fname', 'نام').filterKey('fullName').filterText()); + tbl.add(new Xtc('lname', 'نام خانوادگی').filterKey('fullName').filterText()); + tbl.add(new Xtc('mobile', 'موبایل').filterText()); + tbl.add(new Xtc('ut', 'نوع کاربر').filterNumber().renderSlot('ut')); + tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('op')); + return tbl; +} + +export default buildUserListModel; diff --git a/apps/admin-panel/src/views/wallet/wallet.vue b/apps/admin-panel/src/views/wallet/wallet.vue new file mode 100644 index 0000000..d42fc98 --- /dev/null +++ b/apps/admin-panel/src/views/wallet/wallet.vue @@ -0,0 +1,43 @@ + + + + diff --git a/apps/admin-panel/src/views/wallet/walletTbl.ts b/apps/admin-panel/src/views/wallet/walletTbl.ts new file mode 100644 index 0000000..ed0bfd8 --- /dev/null +++ b/apps/admin-panel/src/views/wallet/walletTbl.ts @@ -0,0 +1,15 @@ +// Ported from legacy henza_admin/src/views/wallet/walletTbl.js +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildWalletModel(): XTbl { + const tbl = new XTbl('admin/documents/wallet/list', 'لیست کیف پول های شارژ شده'); + tbl.add(new Xtc('id', 'شماره')); + tbl.add(new Xtc('customer.name', 'نام مشتری').filterKey('customer.fullName').filterText()); + tbl.add(new Xtc('order.id', 'شماره سفارش').filterText()); + tbl.add(new Xtc('value', 'میزان شارژ').renderSlot('value')); + tbl.add(new Xtc('expire_at', 'تاریخ انقضا').noSort().renderSlot('time')); + tbl.noFilters(); + return tbl; +} + +export default buildWalletModel; diff --git a/apps/admin-panel/src/views/workshop/AddWorkshop.vue b/apps/admin-panel/src/views/workshop/AddWorkshop.vue new file mode 100644 index 0000000..fa8fc3a --- /dev/null +++ b/apps/admin-panel/src/views/workshop/AddWorkshop.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/apps/admin-panel/src/views/workshop/EditWorkshop.vue b/apps/admin-panel/src/views/workshop/EditWorkshop.vue new file mode 100644 index 0000000..0c0f421 --- /dev/null +++ b/apps/admin-panel/src/views/workshop/EditWorkshop.vue @@ -0,0 +1,9 @@ + + + diff --git a/apps/admin-panel/src/views/workshop/ListWorkshop.ts b/apps/admin-panel/src/views/workshop/ListWorkshop.ts new file mode 100644 index 0000000..a0b6594 --- /dev/null +++ b/apps/admin-panel/src/views/workshop/ListWorkshop.ts @@ -0,0 +1,13 @@ +// Ported from legacy henza_admin/src/views/workshop/ListWorkshop.js +import XTbl, { Xtc } from '../../components/x-table/model'; + +export function buildWorkshopListModel(): XTbl { + const tbl = new XTbl('admin/workshops/list', 'لیست کارگاه ها'); + tbl.add(new Xtc('name', 'نام').filterText()); + tbl.add(new Xtc('address', 'آدرس').filterText()); + tbl.add(new Xtc('phone', 'شماره تلفن').filterNumber()); + tbl.add(new Xtc('op', 'عملیات').noSort().renderSlot('actions')); + return tbl; +} + +export default buildWorkshopListModel; diff --git a/apps/admin-panel/src/views/workshop/ListWorkshop.vue b/apps/admin-panel/src/views/workshop/ListWorkshop.vue new file mode 100644 index 0000000..e1e08d4 --- /dev/null +++ b/apps/admin-panel/src/views/workshop/ListWorkshop.vue @@ -0,0 +1,17 @@ + + + diff --git a/apps/admin-panel/tsconfig.tsbuildinfo b/apps/admin-panel/tsconfig.tsbuildinfo deleted file mode 100644 index a147f8d..0000000 --- a/apps/admin-panel/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.vue","./src/app.vue.d.ts","./src/env.d.ts","./src/main.ts","./src/api/client.ts","./src/router/index.ts","./src/stores/session.ts","./src/views/dashboardview.vue","./src/views/dashboardview.vue.d.ts","./src/views/orderdetailview.vue","./src/views/orderdetailview.vue.d.ts","./src/views/ordernewview.vue","./src/views/ordernewview.vue.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/admin-panel/vite.config.ts b/apps/admin-panel/vite.config.ts index c269369..23912ed 100644 --- a/apps/admin-panel/vite.config.ts +++ b/apps/admin-panel/vite.config.ts @@ -1,12 +1,49 @@ +import { fileURLToPath, URL } from 'node:url'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; -export default defineConfig({ - plugins: [vue()], - server: { - port: 5173, - proxy: { - '/api': 'http://localhost:3000', +// Ported from legacy henza_admin/vue.config.js. +// The legacy build switched publicPath/outputDir on NODE_ENV=='branch'; in Vite the +// equivalent switch is the --mode flag (`pnpm dev:branch` / `pnpm build:branch`), +// which src/utils/index.ts reads via import.meta.env.MODE. +export default defineConfig(({ mode }) => { + const isBranch = mode === 'branch'; + + return { + // legacy: publicPath: isBranch ? '/branch/' : 'admin/' + base: isBranch ? '/branch/' : '/admin/', + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, }, - }, + css: { + preprocessorOptions: { + less: { + // Verbatim from legacy vue.config.js configureWebpack less-loader modifyVars. + modifyVars: { + 'primary-color': '#1c2d56', + 'secondary-color': '#836447', + 'third-color': '#f5cb55', + 'link-color': '#1DA57A', + 'border-radius-base': '6px', + }, + javascriptEnabled: true, + }, + }, + }, + build: { + // legacy: outputDir: isBranch ? 'branchDist/export' : 'dist/export' + outDir: isBranch ? 'branchDist/export' : 'dist/export', + emptyOutDir: true, + sourcemap: false, // legacy: productionSourceMap: false + }, + server: { + port: 5174, + proxy: { + '/api': 'http://localhost:3000', + }, + }, + }; }); diff --git a/apps/api/package.json b/apps/api/package.json index e4bb585..54e74f7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "nest start --watch", "build": "nest build", + "build:release": "pnpm exec prisma generate && node scripts/clean-dist.cjs && nest build && node scripts/bundle-release.cjs", "lint": "eslint \"src/**/*.ts\"", "test": "jest", "prisma:push": "prisma db push", @@ -19,17 +20,25 @@ "@gold-bank/shared-types": "workspace:*", "@nestjs/common": "^10.4.0", "@nestjs/core": "^10.4.0", + "@nestjs/jwt": "^12.0.1", + "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.0", "@prisma/client": "^5.19.0", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, "devDependencies": { "@nestjs/cli": "^10.4.5", + "@types/bcryptjs": "^3.0.0", "@types/express": "^4.17.21", "@types/node": "^20.14.0", + "@types/passport-jwt": "^4.0.1", + "esbuild": "^0.25.0", "prisma": "^5.19.0", "ts-node": "^10.9.2", "typescript": "^5.5.0" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0ad65e0..582e3f5 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -5,6 +5,8 @@ generator client { provider = "prisma-client-js" + // native = local Windows/macOS; debian-openssl-3.0.x = node:20-bookworm-slim (Arvan) + binaryTargets = ["native", "debian-openssl-3.0.x"] } datasource db { @@ -24,6 +26,16 @@ enum AdminRole { ACCOUNTANT } +/// Legacy Customer.type — what kind of wholesale customer this is. Values and +/// labels taken verbatim from bulkCustomer.vue's userTypes list. +enum CustomerKind { + SALES_AGENT // 1 کارشناس فروش + APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن + WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری + BANKER // 4 بنکدار + WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه +} + enum VerificationStep { UNVERIFIED STEP_ONE_SUBMITTED @@ -31,6 +43,14 @@ enum VerificationStep { VERIFIED } +/// Legacy kept retail and wholesale orders in two SEPARATE tables +/// (orders_orders vs orders). The redesigned schema has one Order model, so the +/// distinction lives here instead. +enum OrderChannel { + RETAIL // تک فروشی + WHOLESALE // عمده فروشی +} + enum OrderStatus { SUSPENDED // credit limit (goldCreditLimitGrams) exceeded at placement PLACED @@ -77,13 +97,17 @@ enum LedgerSourceType { // ───────────────────────────────────────────────────────────────────────── model City { - id String @id @default(cuid()) - name String - province String? - customers Customer[] - addresses CustomerAddress[] - branches Branch[] - preOrders PreOrder[] + id String @id @default(cuid()) + name String + province String? + /// Legacy cities.percent / percent_aux — per-city wage surcharges the city + /// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}. + percent Decimal? @db.Decimal(6, 3) + percentAux Decimal? @db.Decimal(6, 3) + customers Customer[] + addresses CustomerAddress[] + branches Branch[] + preOrders PreOrder[] } model AdminUser { @@ -92,6 +116,11 @@ model AdminUser { mobile String @unique passwordHash String role AdminRole + /// Legacy `users.ut`. Granular ACL lives here (Permission rows hang off UserType); + /// the coarse `role` enum above is retained for the business rules that already + /// switch on it. Nullable so existing seeded admins remain valid. + userTypeId Int? + userType UserType? @relation(fields: [userTypeId], references: [id]) branchId String? branch Branch? @relation(fields: [branchId], references: [id]) wagePercent Decimal? @db.Decimal(6, 3) // legacy Customer.percent, commission on referred orders @@ -99,11 +128,17 @@ model AdminUser { createdAt DateTime @default(now()) referredCustomers Customer[] @relation("ReferralAgent") + supervisedWorkshops Workshop[] @relation("WorkshopSupervisor") + branchAllocations BranchAllocation[] @relation("AllocationAuthor") postedLedgerEntries LedgerEntry[] orderStatusEvents OrderStatusEvent[] preOrderStatusEvents PreOrderStatusEvent[] + sentSmsMessages SmsMessage[] @relation("SmsAuthor") + sentNotifications Notification[] @relation("NotificationAuthor") + uploadedFiles UploadedFile[] @relation("FileUploader") @@index([role]) + @@index([userTypeId]) } model Customer { @@ -115,6 +150,20 @@ model Customer { cityId String? city City? @relation(fields: [cityId], references: [id]) + /// Legacy Customer.type — distinguishes بنکدار from مغازه دار, which the + /// BankerList / ShopkeeperList screens filter on. + kind CustomerKind @default(APP_SHOPKEEPER) + + /// Legacy contact + shop columns, shown on the wholesale customer form and as + /// (hidden-by-default) columns in its grid. + phone String? + address String? + storeName String? + pasazhName String? + zone String? + /// Legacy mcode — the operator-assigned customer code. + memberCode String? + referredByAgentId String? referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id]) @@ -129,15 +178,33 @@ model Customer { businessLicenseUrl String? shopImageUrl String? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + /// Legacy `image_id` — the profile photo (عکس پروفایل) on the sales-agent + /// form. Legacy stored an FK to its Image table and read it back as a nested + /// `image: { id, image }`, so this is a relation, not a bare URL string. + avatarFileId String? + avatarFile UploadedFile? @relation("CustomerAvatar", fields: [avatarFileId], references: [id]) + + /// Legacy had TWO independent switches on a customer: + /// sts -> may they place orders (this field) + /// active -> is the account enabled (isActive, below) + /// The port previously drove both from isActive; they are separate again. + canPlaceOrders Boolean @default(true) + + isActive Boolean @default(true) + /// Legacy archive flag — the archived list filtered on this, not on isActive. + isArchived Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt addresses CustomerAddress[] orders Order[] preOrders PreOrder[] ledgerEntries LedgerEntry[] walletBonuses WalletBonus[] + comments Comment[] + promotions Promotion[] + notifications NotificationRecipient[] + activityLogs CustomerActivityLog[] @@index([referredByAgentId]) } @@ -153,19 +220,31 @@ model CustomerAddress { } model Branch { - id String @id @default(cuid()) - name String - cityId String? - city City? @relation(fields: [cityId], references: [id]) - adminUsers AdminUser[] - orders Order[] + id String @id @default(cuid()) + name String + /// Legacy Branch.address / phone — both are columns in the branch grid. + address String? + phone String? + /// Legacy GET branch/active/{id} toggles this. + isActive Boolean @default(true) + cityId String? + city City? @relation(fields: [cityId], references: [id]) + adminUsers AdminUser[] + orders Order[] + allocations BranchAllocation[] } model Workshop { - id String @id @default(cuid()) - name String - isActive Boolean @default(true) - preOrders PreOrder[] + id String @id @default(cuid()) + name String + /// Legacy Workshop.address / phone — both are columns in the workshop grid. + address String? + phone String? + /// The supervisor created alongside the workshop by the legacy Add form. + supervisorId String? + supervisor AdminUser? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id]) + isActive Boolean @default(true) + preOrders PreOrder[] } // ───────────────────────────────────────────────────────────────────────── @@ -173,18 +252,29 @@ model Workshop { // ───────────────────────────────────────────────────────────────────────── model Category { - id String @id @default(cuid()) - title String - parentId String? - parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) - children Category[] @relation("CategoryTree") - products Product[] + id String @id @default(cuid()) + title String + /// Legacy Category.type — which storefront the category belongs to: + /// 1 سایت | 2 اپلیکشن موبایل. Drives the "type_str" grid column. + type Int @default(1) + /// Legacy Category.image / icon — the grid renders the image, the storefront the icon. + imageUrl String? + iconUrl String? + /// Legacy meta tag + description, used for SEO on the site categories. + metaTag String? + description String? + parentId String? + parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) + children Category[] @relation("CategoryTree") + products Product[] + promotions Promotion[] } model Brand { - id String @id @default(cuid()) - title String - products Product[] + id String @id @default(cuid()) + title String + products Product[] + promotions Promotion[] } /// Curated product groupings — replaces legacy "Ciny/CinyProduct" (a tagging @@ -214,12 +304,19 @@ model Product { category Category? @relation(fields: [categoryId], references: [id]) brandId String? brand Brand? @relation(fields: [brandId], references: [id]) + /// Legacy Product.product_code — the human-facing SKU shown in the grid. + productCode String? @unique + /// Legacy Product.show_type: 1 site, 2 mobile, 3 both. + showType Int @default(3) isActive Boolean @default(true) createdAt DateTime @default(now()) - variants ProductVariant[] - images ProductImage[] - collections CollectionProduct[] + variants ProductVariant[] + images ProductImage[] + collections CollectionProduct[] + comments Comment[] + promotions Promotion[] + allocationItems BranchAllocationItem[] } model ProductImage { @@ -240,6 +337,12 @@ model ProductVariant { rashWeightGrams Decimal @db.Decimal(14, 4) /// Default making-charge percentage applied on top of rash weight. defaultWagePercent Decimal @db.Decimal(6, 3) + /// Legacy ProductVariant.qty — on-hand stock. The cart refuses a variant with + /// qty 0 unless it is flagged pre-order, so both fields are required for that + /// guard to work (see apps/admin-panel/src/stores/cart.ts). + qty Int @default(0) + /// Legacy is_pre_order: orderable even at zero stock. + isPreOrder Boolean @default(false) isActive Boolean @default(true) orderItems OrderItem[] @@ -250,13 +353,15 @@ model ProductVariant { // ───────────────────────────────────────────────────────────────────────── model Order { - id String @id @default(cuid()) - orderNumber String @unique + id String @id @default(cuid()) + orderNumber String @unique customerId String - customer Customer @relation(fields: [customerId], references: [id]) + customer Customer @relation(fields: [customerId], references: [id]) branchId String? - branch Branch? @relation(fields: [branchId], references: [id]) - status OrderStatus @default(PLACED) + branch Branch? @relation(fields: [branchId], references: [id]) + /// Which sales channel placed this order — see OrderChannel. + channel OrderChannel @default(RETAIL) + status OrderStatus @default(PLACED) /// Total gold weight incl. wage across all items ("allGram" / order.w). totalGoldWeightGrams Decimal @db.Decimal(14, 4) @@ -389,6 +494,22 @@ model LedgerEntry { postedByAdminUserId String? postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id]) + /// Instrument metadata from the legacy "cash" document form. Which fields are + /// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and + /// the pre-normalization weight, a cheque carries the account and bank, and a + /// transfer carries its destination. + /// Legacy columns: lab, ang, account_name, bank_name, to, original_value, recive_date. + labName String? + hallmark String? + accountName String? + bankName String? + transferTo String? + /// Weight as handed over, BEFORE karat normalization (legacy original_value). + originalValue Decimal? @db.Decimal(14, 4) + /// Rial amount for cash/cheque documents (legacy price). + priceRial Decimal? @db.Decimal(20, 2) + receivedAt DateTime? + note String? createdAt DateTime @default(now()) @@ -421,3 +542,316 @@ model WalletBonus { @@index([customerId, isActive]) } + +// ───────────────────────────────────────────────────────────────────────── +// Admin ACL — faithful port of the legacy granular permission model. +// +// Legacy (henza_server): +// Schema::create('permission', function (Blueprint $table) { +// $table->string('rid'); +// $table->integer('ut'); +// $table->primary(['rid' , 'ut']); +// }); +// +// Permissions attach to the USER TYPE (`ut`), never to an individual user, so +// every admin of a given type shares one permission set. `ut` pointed at a row +// in the legacy `config_static` lookup table; UserType is that table, narrowed +// to the admin-user-type rows it was actually used for. +// +// A `rid` is a dotted permission id such as "persons.list.client.omde". The +// frontend expands each stored rid into its ancestors at runtime via +// checkParent() — see apps/admin-panel/src/stores/users.ts. Only the 58 leaf +// rids the routers actually gate are seeded; ancestors are derived, not stored. +// ───────────────────────────────────────────────────────────────────────── + +model UserType { + /// Legacy `permission.ut` / `users.ut` — an integer, kept as the PK so seeded + /// rows keep their legacy identity rather than getting new cuids. + id Int @id + title String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + permissions Permission[] + adminUsers AdminUser[] + + @@map("user_type") +} + +model Permission { + /// Dotted permission id, e.g. "order.list", "persons.list.client.omde". + rid String + userTypeId Int + userType UserType @relation(fields: [userTypeId], references: [id], onDelete: Cascade) + + /// Legacy's composite primary key (rid, ut), preserved exactly. + @@id([rid, userTypeId]) + @@index([userTypeId]) + @@map("permission") +} + +// ───────────────────────────────────────────────────────────────────────── +// Product comments / reviews — legacy `comment` table (App\Models\Comment), +// moderated from views/customer/Comments/Comments.vue. +// +// Legacy fillable: product_id, comment, reply_to, sts, customer_id +// Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده'] +// which is represented here as an enum rather than a magic integer. +// ───────────────────────────────────────────────────────────────────────── + +enum CommentStatus { + PENDING // 1 — در انتظار تایید + APPROVED // 2 — تایید شده + REJECTED // 3 — رد شده +} + +model Comment { + id String @id @default(cuid()) + productId String + product Product @relation(fields: [productId], references: [id]) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + body String + status CommentStatus @default(PENDING) + + /// Legacy `reply_to`: a staff reply threads under the comment it answers. + replyToId String? + replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id]) + replies Comment[] @relation("CommentReplies") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([productId]) + @@index([customerId]) + @@index([status]) +} + +// ───────────────────────────────────────────────────────────────────────── +// Settings — replaces legacy `config_static`, a key/value table the admin +// panel read and wrote through endpoints like /wallet/config and +// /const/__list__ut. Values are stored as JSON so a setting can hold a scalar, +// an object, or the dynamic option lists legacy kept under `__list__*` keys. +// ───────────────────────────────────────────────────────────────────────── + +model Setting { + key String @id + value Json + updatedAt DateTime @updatedAt +} + +// ───────────────────────────────────────────────────────────────────────── +// Promotions / discounts — legacy views/promotion/*, served by +// PromotionController (`product/variant/promotion`). The redesigned schema had +// no promotion concept at all; this models what the legacy forms actually +// collect. +// ───────────────────────────────────────────────────────────────────────── + +/// Legacy Promotion.type — what the discount is scoped to. +enum PromotionScope { + PRODUCT // 1 محصولات + CATEGORY // 2 دسته بندی + CUSTOMER // 3 مشتری + BRAND // 4 برند +} + +/// Legacy Promotion.use_only — how often a customer may redeem it. +enum PromotionUsage { + ONCE // 1 فقط یکبار + UNTIL_END_DATE // 2 تا تاریخ پایان +} + +model Promotion { + id String @id @default(cuid()) + title String + description String? + scope PromotionScope @default(PRODUCT) + usage PromotionUsage @default(ONCE) + /// Percentage off, 0-100 (legacy discount_percent). + discountPercent Decimal @db.Decimal(6, 3) + isActive Boolean @default(true) + /// Which storefront it shows on, mirroring Product.showType (legacy platform). + platform Int @default(3) + + startsAt DateTime? + endsAt DateTime? + + /// Exactly one of these is populated, per `scope`. + productId String? + product Product? @relation(fields: [productId], references: [id]) + categoryId String? + category Category? @relation(fields: [categoryId], references: [id]) + customerId String? + customer Customer? @relation(fields: [customerId], references: [id]) + brandId String? + brand Brand? @relation(fields: [brandId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([scope, isActive]) +} + +/// Legacy "ignore" table (views/offersIgnore) — specific products, categories, +/// customers or brands excluded from the automatic discount engine. +/// Legacy posts { type, item_id[] }: "type" reuses the promotion scope codes +/// (1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند) and "itemId" is the excluded row. +model PromotionExclusion { + id String @id @default(cuid()) + type Int + itemId String + createdAt DateTime @default(now()) + + @@unique([type, itemId]) + @@index([type]) +} + +// ───────────────────────────────────────────────────────────────────────── +// Branch stock allocation — legacy views/invoice/* ("فاکتور انتقال"), served by +// BranchController as `branch/product`. Despite the screen being called an +// invoice, it is not a sales document: it records which products head office +// allocated to a branch, and who allocated them. +// ───────────────────────────────────────────────────────────────────────── + +model BranchAllocation { + id String @id @default(cuid()) + branchId String + branch Branch @relation(fields: [branchId], references: [id]) + + /// Legacy `allocator_user` — the admin who filed the allocation. + allocatorAdminUserId String? + allocatorAdminUser AdminUser? @relation("AllocationAuthor", fields: [allocatorAdminUserId], references: [id]) + + note String? + createdAt DateTime @default(now()) + + items BranchAllocationItem[] + + @@index([branchId]) +} + +model BranchAllocationItem { + id String @id @default(cuid()) + allocationId String + allocation BranchAllocation @relation(fields: [allocationId], references: [id], onDelete: Cascade) + productId String + product Product @relation(fields: [productId], references: [id]) + /// Weight snapshot at allocation time, so later catalog edits do not rewrite history. + weightGrams Decimal? @db.Decimal(14, 4) + + @@unique([allocationId, productId]) +} + +// ───────────────────────────────────────────────────────────────────────── +// Messaging + audit — legacy views/sms, views/notify, views/newsletters and +// views/statics. None of these had a model in the redesigned schema. +// ───────────────────────────────────────────────────────────────────────── + +/// Who an SMS or notification was aimed at. Legacy's sms/create offered these +/// audiences, each gated by its own rid (sms.create.all, .omde, .single, ...). +enum MessageAudience { + SINGLE_NUMBER // یک شماره + MULTIPLE_NUMBERS // چند شماره + ALL_CUSTOMERS // همه + WHOLESALE_CUSTOMERS // عمده + RETAIL_CUSTOMERS // تک + SALES_AGENTS // کارشناسان فروش +} + +model SmsMessage { + id String @id @default(cuid()) + audience MessageAudience + /// Comma-separated recipients for the SINGLE/MULTIPLE audiences. + recipients String? + body String + /// How many numbers the send actually resolved to. + sentCount Int @default(0) + + sentByAdminUserId String? + sentByAdminUser AdminUser? @relation("SmsAuthor", fields: [sentByAdminUserId], references: [id]) + + createdAt DateTime @default(now()) + + @@index([createdAt]) +} + +model Notification { + id String @id @default(cuid()) + title String + body String + audience MessageAudience @default(ALL_CUSTOMERS) + + sentByAdminUserId String? + sentByAdminUser AdminUser? @relation("NotificationAuthor", fields: [sentByAdminUserId], references: [id]) + + createdAt DateTime @default(now()) + + recipients NotificationRecipient[] + + @@index([createdAt]) +} + +/// Per-customer delivery + read state — legacy `GET notify/read/{id}`. +model NotificationRecipient { + id String @id @default(cuid()) + notificationId String + notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + readAt DateTime? + + @@unique([notificationId, customerId]) + @@index([customerId, readAt]) +} + +/// Legacy views/newsletters — the mailing-list signups collected by the site. +model NewsletterSubscriber { + id String @id @default(cuid()) + email String? + mobile String? + createdAt DateTime @default(now()) + + @@unique([email]) + @@unique([mobile]) +} + +/// Legacy views/statics — the customer activity log the "آمار" screen reads +/// (`log/list/customer`, `log/ago/{customerId}`). +model CustomerActivityLog { + id String @id @default(cuid()) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + /// Free-form action key, e.g. 'login', 'order.placed', 'profile.updated'. + action String + detail String? + createdAt DateTime @default(now()) + + @@index([customerId, createdAt]) +} + +// ───────────────────────────────────────────────────────────────────────── +// Uploads — replaces the legacy MyUpload / SingleUploadInput widgets, which +// POSTed to a file endpoint and stored the returned URL on the owning record. +// Ports of those forms currently take a URL by hand; this gives them a real +// destination to upload to. +// ───────────────────────────────────────────────────────────────────────── + +model UploadedFile { + id String @id @default(cuid()) + /// Public URL the stored file is served from. + url String + originalName String + mimeType String + sizeBytes Int + /// Free-form tag for what the file belongs to, e.g. 'product', 'category', + /// 'customer.nationalId'. Kept loose so a new form needs no schema change. + purpose String? + + uploadedByAdminUserId String? + uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id]) + + createdAt DateTime @default(now()) + customerAvatars Customer[] @relation("CustomerAvatar") + + @@index([purpose]) +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index daf293b..e2733b4 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,9 +1,27 @@ -import { AdminRole, PrismaClient, VerificationStep } from '@prisma/client'; +import { + AdminRole, + Customer, + CustomerKind, + OrderChannel, + PrismaClient, + VerificationStep, +} from '@prisma/client'; +import * as bcrypt from 'bcryptjs'; +import { ALL_RIDS } from '../src/modules/auth/acl.constants'; const prisma = new PrismaClient(); -// Placeholder — replace with a real bcrypt hash once the auth module lands. -const PLACEHOLDER_PASSWORD_HASH = 'placeholder-hash-not-for-production'; +// Real bcrypt hash of DEV_PASSWORD, so seeded accounts can actually log in. +// Development credentials only — never seed these into a real deployment. +const DEV_PASSWORD = 'admin1234'; +const PLACEHOLDER_PASSWORD_HASH = bcrypt.hashSync(DEV_PASSWORD, 10); + +// A single number authorized to sign in to BOTH apps for local development, +// with its password (admin panel) equal to the number itself. DEV-ONLY: this +// file is never run against a real deployment — only by hand via +// `pnpm prisma:seed` / `npx prisma db seed` against a local database. +const DEV_TEST_PHONE = '09128081392'; +const DEV_TEST_PHONE_PASSWORD_HASH = bcrypt.hashSync(DEV_TEST_PHONE, 10); // Served from the user-app's own public/ folder. This is the same placeholder // image the legacy app used for missing/failed product imagery (no-items.png). @@ -36,68 +54,167 @@ async function main() { create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' }, }); + // ── Admin user types + permissions (legacy `permission(rid, ut)`) ──── + // + // In legacy these lived in the dynamic `config_static` lookup under the key + // `__list__ut` and were created from the UI (views/user/Permission.vue), so + // there is no legacy seeder to copy. Id 85 is seeded because Permission.vue + // hardcodes it as the default selection (`setFieldsValue({ut:"85"})`). + // + // Only leaf rids are stored; ancestors are derived at runtime by + // expandWithParents() / checkParent(), exactly as legacy did. + const userTypes: { id: number; title: string; rids: readonly string[] }[] = [ + // Full access — every rid the routers gate. + { id: 85, title: 'مدیر ارشد', rids: ALL_RIDS }, + { + id: 86, + title: 'کارشناس فروش', + rids: [ + 'order.list', 'order.create', 'order.omde', 'order.makeOrder', + 'persons.list.client.omde', 'persons.list.client.omde.view', + 'persons.list.client.single', 'persons.list.client.single.view', + 'persons.create', 'product.list', 'dashboard.tak', 'comment', + ], + }, + { + id: 87, + title: 'حسابدار', + rids: [ + 'document.list', 'document.create', 'document.list.edit', + 'invoice.list', 'invoice.create', 'wallet', 'config.wallet', + 'report.order.item', 'report.product', 'config.report', 'dashboard.omde', + ], + }, + { + id: 88, + title: 'سرپرست کارگاه', + rids: ['workshop.list', 'workshop.create', 'order.tamin', 'order.list', 'product.list'], + }, + { + id: 89, + title: 'کاربر شعبه', + rids: ['buy.order.list', 'order.create', 'persons.create', 'branch.list'], + }, + ]; + + for (const ut of userTypes) { + await prisma.userType.upsert({ + where: { id: ut.id }, + update: { title: ut.title }, + create: { id: ut.id, title: ut.title }, + }); + await prisma.permission.createMany({ + data: ut.rids.map((rid) => ({ rid, userTypeId: ut.id })), + skipDuplicates: true, + }); + } + // ── Admin users (sales agents + one super admin) ──────────────────── + + // DEV-ONLY test login, per explicit request: authorized in BOTH the admin + // panel and the user-app, password = the phone number itself. Super admin + // so it can reach every screen while testing. Do not carry this into a real + // deployment's seed data. + await prisma.adminUser.upsert({ + where: { mobile: DEV_TEST_PHONE }, + update: { passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, userTypeId: 85, branchId: null }, + create: { + mobile: DEV_TEST_PHONE, + fullName: 'کاربر تست توسعه', + passwordHash: DEV_TEST_PHONE_PASSWORD_HASH, + role: AdminRole.SUPER_ADMIN, + userTypeId: 85, + branchId: null, + }, + }); + await prisma.adminUser.upsert({ where: { mobile: '09120000001' }, - update: {}, + update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 85, branchId: null }, create: { mobile: '09120000001', fullName: 'مدیر ارشد سیستم', passwordHash: PLACEHOLDER_PASSWORD_HASH, role: AdminRole.SUPER_ADMIN, - branchId: branchTehran.id, + userTypeId: 85, + // Head office: deliberately branch-less. Legacy scoped every list by + // branch_id whenever the user had one, so head-office staff must have none + // or they would see only a single branch's data. + branchId: null, }, }); const agentReza = await prisma.adminUser.upsert({ where: { mobile: '09121234567' }, - update: {}, + update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 86 }, create: { mobile: '09121234567', fullName: 'رضا احمدی', passwordHash: PLACEHOLDER_PASSWORD_HASH, role: AdminRole.SALES_EXPERT, + userTypeId: 86, branchId: branchTehran.id, wagePercent: 1.5, }, }); const agentSara = await prisma.adminUser.upsert({ where: { mobile: '09129876543' }, - update: {}, + update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 86 }, create: { mobile: '09129876543', fullName: 'سارا کریمی', passwordHash: PLACEHOLDER_PASSWORD_HASH, role: AdminRole.SALES_EXPERT, + userTypeId: 86, branchId: branchMashhad.id, wagePercent: 1.2, }, }); await prisma.adminUser.upsert({ where: { mobile: '09123334444' }, - update: {}, + update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 88 }, create: { mobile: '09123334444', fullName: 'هومن یزدانی', passwordHash: PLACEHOLDER_PASSWORD_HASH, role: AdminRole.WORKSHOP_SUPERVISOR, + userTypeId: 88, }, }); await prisma.adminUser.upsert({ where: { mobile: '09125556666' }, - update: {}, + update: { passwordHash: PLACEHOLDER_PASSWORD_HASH, userTypeId: 87, branchId: null }, create: { mobile: '09125556666', fullName: 'فرزانه محمودی', passwordHash: PLACEHOLDER_PASSWORD_HASH, role: AdminRole.ACCOUNTANT, - branchId: branchTehran.id, + userTypeId: 87, + branchId: null, }, }); // ── Customers ──────────────────────────────────────────────────────── const customerData = [ + { + // DEV-ONLY test login, per explicit request: same number as the admin + // panel account above, so it can also sign in to the user-app (OTP step + // matches on mobile against a real seeded customer — see LoginView.vue). + // Fully verified so no onboarding step blocks it. Not for a real deployment. + id: 'customer-dev-test', + kind: CustomerKind.APP_SHOPKEEPER, + memberCode: 'DEV-TEST', + fullName: 'کاربر تست توسعه', + mobile: DEV_TEST_PHONE, + nationalCode: '0000000000', + cityId: tehran.id, + referredByAgentId: null, + goldCreditLimitGrams: '1000', + verificationStep: VerificationStep.VERIFIED, + }, { id: 'customer-1', + kind: CustomerKind.BANKER, + storeName: 'طلای محمدی', pasazhName: 'پاساژ بزرگ', zone: 'بازار', memberCode: 'M-1001', phone: '02133445566', address: 'تهران، بازار بزرگ، پلاک ۱۲', fullName: 'علی محمدی', mobile: '09151112233', nationalCode: '0012345678', @@ -108,6 +225,8 @@ async function main() { }, { id: 'customer-2', + kind: CustomerKind.APP_SHOPKEEPER, + storeName: 'گالری حسینی', zone: 'ونک', memberCode: 'M-1002', phone: '02188776655', fullName: 'مریم حسینی', mobile: '09151112234', nationalCode: '0012345679', @@ -118,6 +237,8 @@ async function main() { }, { id: 'customer-3', + kind: CustomerKind.WALK_IN_SHOPKEEPER, + storeName: 'زرگری نوروزی', pasazhName: 'پاساژ رضا', zone: 'طرق', memberCode: 'M-1003', fullName: 'جواد نوروزی', mobile: '09151112235', nationalCode: '0012345680', @@ -128,6 +249,8 @@ async function main() { }, { id: 'customer-4', + kind: CustomerKind.APP_SHOPKEEPER, + memberCode: 'M-1004', fullName: 'نرگس ابراهیمی', mobile: '09151112236', nationalCode: '0012345681', @@ -135,9 +258,14 @@ async function main() { referredByAgentId: null, goldCreditLimitGrams: '30', verificationStep: VerificationStep.UNVERIFIED, + // archived, so the "لیست بایگانی" grid has a row and the type lists + // correctly exclude it + isArchived: true, }, { id: 'customer-5', + kind: CustomerKind.BANKER, + storeName: 'صادقی و پسران', zone: 'بازار', memberCode: 'M-1005', phone: '02155443322', fullName: 'کامران صادقی', mobile: '09151112237', nationalCode: '0012345682', @@ -148,6 +276,8 @@ async function main() { }, { id: 'customer-6', + kind: CustomerKind.WALK_IN_SHOPKEEPER, + storeName: 'طلاسرای قاسمی', zone: 'ائل گلی', memberCode: 'M-1006', fullName: 'زهرا قاسمی', mobile: '09151112238', nationalCode: '0012345683', @@ -158,12 +288,25 @@ async function main() { }, ]; - const customers = []; + const customers: Customer[] = []; for (const c of customerData) { customers.push( await prisma.customer.upsert({ where: { id: c.id }, - update: {}, + // Re-assert the DESCRIPTIVE fields on an existing row so a re-seed + // backfills columns added after that row was first created. Balances, + // verification state and credit limits are deliberately NOT touched — + // those are live data a re-seed must never overwrite. + update: { + kind: c.kind, + phone: 'phone' in c ? c.phone : null, + address: 'address' in c ? c.address : null, + storeName: 'storeName' in c ? c.storeName : null, + pasazhName: 'pasazhName' in c ? c.pasazhName : null, + zone: 'zone' in c ? c.zone : null, + memberCode: c.memberCode, + isArchived: 'isArchived' in c ? c.isArchived : false, + }, create: { id: c.id, fullName: c.fullName, @@ -174,11 +317,25 @@ async function main() { referredByAgentId: c.referredByAgentId, goldCreditLimitGrams: c.goldCreditLimitGrams, verificationStep: c.verificationStep, + kind: c.kind, + phone: 'phone' in c ? c.phone : null, + address: 'address' in c ? c.address : null, + storeName: 'storeName' in c ? c.storeName : null, + pasazhName: 'pasazhName' in c ? c.pasazhName : null, + zone: 'zone' in c ? c.zone : null, + memberCode: c.memberCode, + isArchived: 'isArchived' in c ? c.isArchived : false, }, }), ); } - const [customer1, customer2, , , customer5] = customers; + // Looked up by id rather than array position — a positional destructure here + // silently breaks (wrong customer, no error) the moment a row is added or + // reordered above, as happened when the dev-test login row was inserted first. + const byId = (id: string) => customers.find((c) => c.id === id)!; + const customer1 = byId('customer-1'); + const customer2 = byId('customer-2'); + const customer5 = byId('customer-5'); // ── Catalog: categories, brands, products, variants ──────────────── const [ringsCategory, braceletsCategory, necklacesCategory, earringsCategory, coinsCategory] = await Promise.all([ @@ -331,6 +488,56 @@ async function main() { data: { karatPerMille: 750, pricePerGramRial: '61500000', source: 'seed-illustrative' }, }); + // A RETAIL order, so the single-sale grid (legacy `POST single-order`) has a + // row of its own. Legacy kept the two channels in separate tables; here they + // are separated by Order.channel. + const existingRetail = await prisma.order.findUnique({ + where: { orderNumber: 'ORD-SEED-0002' }, + }); + if (!existingRetail) { + const rash = '2.400'; + const wage = 7.5; + const computed = (parseFloat(rash) * (1 + wage / 100)).toFixed(4); + await prisma.order.create({ + data: { + orderNumber: 'ORD-SEED-0002', + customerId: customer2.id, + branchId: branchTehran.id, + channel: OrderChannel.RETAIL, + status: 'PLACED', + totalGoldWeightGrams: computed, + wageWeightGrams: (parseFloat(computed) - parseFloat(rash)).toFixed(4), + flatRialCharge: '0', + items: { + create: [ + { + productVariantId: 'variant-ring-size-7', + quantity: 1, + rashWeightGramsSnapshot: rash, + wagePercentSnapshot: wage.toString(), + discountPercentSnapshot: '0', + computedWeightGrams: computed, + flatRialCharge: '0', + }, + ], + }, + statusEvents: { create: [{ toStatus: 'PLACED' }] }, + }, + }); + } + + // Order.channel was added after these demo rows first existed, so a re-seed + // corrects the channel on the two named seed orders. Scoped to those exact + // order numbers — it must never rewrite real orders. + await prisma.order.updateMany({ + where: { orderNumber: 'ORD-SEED-0001' }, + data: { channel: OrderChannel.WHOLESALE }, + }); + await prisma.order.updateMany({ + where: { orderNumber: 'ORD-SEED-0002' }, + data: { channel: OrderChannel.RETAIL }, + }); + // ── A delivered order + posted debt, so the dashboard shows real numbers ── const existingDemoOrder = await prisma.order.findUnique({ where: { orderNumber: 'ORD-SEED-0001' } }); if (!existingDemoOrder) { @@ -346,6 +553,8 @@ async function main() { orderNumber: 'ORD-SEED-0001', customerId: customer1.id, branchId: branchTehran.id, + // customer1 is a بنکدار, so this belongs to the wholesale grid + channel: OrderChannel.WHOLESALE, status: 'DELIVERED_TO_CUSTOMER', totalGoldWeightGrams: computedWeight, wageWeightGrams: (parseFloat(computedWeight) - parseFloat(rashWeight)).toFixed(4), @@ -420,6 +629,94 @@ async function main() { await prisma.customer.update({ where: { id: customer2.id }, data: { goldDebtGrams: '8.2500', fiatDebtRial: '2000000' } }); await prisma.customer.update({ where: { id: customer5.id }, data: { goldDebtGrams: '0', fiatDebtRial: '0' } }); + // ── Pre-orders (legacy "سفارش های ساختنی" / custom orders) ────────── + // Design requests an admin prices and later converts into a real Order. + // Seeded so the MakeOrder grid has rows to render. + const preOrderSeeds = [ + { + id: 'preorder-0001', + customerId: customer2.id, + cityId: tehran.id, + status: 'PLACED' as const, + colorName: 'رزگلد', + sizeSpec: '۱۶', + stoneColor: 'سفید', + minaColor: 'آبی', + description: 'انگشتر طرح سنتی با نگین سفید', + estimatedWeightGrams: '4.5000', + }, + { + id: 'preorder-0002', + customerId: customer5.id, + cityId: mashhad.id, + status: 'ADMIN_APPROVED' as const, + colorName: 'زرد', + sizeSpec: '۱۸', + stoneColor: 'قرمز', + minaColor: 'سبز', + description: 'دستبند مینا کاری', + estimatedWeightGrams: '9.2500', + }, + ]; + for (const po of preOrderSeeds) { + await prisma.preOrder.upsert({ + where: { id: po.id }, + update: {}, + create: po, + }); + } + + // ── Product comments (legacy `comment` table) ─────────────────────── + // Seeded across all three moderation states so the Comments grid and its + // status filter have something to show. + const allProducts = await prisma.product.findMany({ take: 3 }); + if (allProducts.length > 0) { + const commentSeeds = [ + { + id: 'comment-0001', + productId: allProducts[0].id, + customerId: customer2.id, + body: 'کیفیت ساخت عالی بود، ممنون از ارسال سریع.', + status: 'APPROVED' as const, + }, + { + id: 'comment-0002', + productId: allProducts[0].id, + customerId: customer5.id, + body: 'آیا این مدل در سایز بزرگ‌تر هم موجود می‌شود؟', + status: 'PENDING' as const, + }, + { + id: 'comment-0003', + productId: allProducts[Math.min(1, allProducts.length - 1)].id, + customerId: customer2.id, + body: 'وزن با آنچه نوشته شده کمی تفاوت داشت.', + status: 'REJECTED' as const, + }, + ]; + for (const c of commentSeeds) { + await prisma.comment.upsert({ where: { id: c.id }, update: {}, create: c }); + } + } + + // ── Variant stock + product codes (columns added in Batch 6) ──────── + // Without stock every variant reads as out-of-stock, which the cart guard + // rejects, so the order builder would have nothing to add. + const allVariants = await prisma.productVariant.findMany(); + for (const [i, v] of allVariants.entries()) { + await prisma.productVariant.update({ + where: { id: v.id }, + data: { qty: 5 + (i % 4), isPreOrder: i % 5 === 0 }, + }); + } + const codedProducts = await prisma.product.findMany({ orderBy: { title: 'asc' } }); + for (const [i, p] of codedProducts.entries()) { + await prisma.product.update({ + where: { id: p.id }, + data: { productCode: `P-${String(i + 1).padStart(4, '0')}` }, + }); + } + console.log('Seed complete:', { cities: 4, branches: 2, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1fd0712..e800112 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,8 +5,51 @@ import { CustomersModule } from './modules/customers/customers.module'; import { OrdersModule } from './modules/orders/orders.module'; import { AccountingModule } from './modules/accounting/accounting.module'; import { PricingModule } from './modules/pricing/pricing.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { AdminTableModule } from './modules/admin-table/admin-table.module'; +import { AdminOrdersModule } from './modules/admin-orders/admin-orders.module'; +import { AdminCitiesModule } from './modules/admin-cities/admin-cities.module'; +import { AdminPreOrdersModule } from './modules/admin-preorders/admin-preorders.module'; +import { AdminWorkshopsModule } from './modules/admin-workshops/admin-workshops.module'; +import { AdminCustomersModule } from './modules/admin-customers/admin-customers.module'; +import { AdminCommentsModule } from './modules/admin-comments/admin-comments.module'; +import { AdminProductsModule } from './modules/admin-products/admin-products.module'; +import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module'; +import { AdminDocumentsModule } from './modules/admin-documents/admin-documents.module'; +import { AdminPromotionsModule } from './modules/admin-promotions/admin-promotions.module'; +import { AdminUsersModule } from './modules/admin-users/admin-users.module'; +import { AdminBranchesModule } from './modules/admin-branches/admin-branches.module'; +import { AdminInvoicesModule } from './modules/admin-invoices/admin-invoices.module'; +import { AdminMiscModule } from './modules/admin-misc/admin-misc.module'; +import { AdminUploadsModule } from './modules/admin-uploads/admin-uploads.module'; +import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module'; @Module({ - imports: [PrismaModule, CatalogModule, CustomersModule, OrdersModule, AccountingModule, PricingModule], + imports: [ + PrismaModule, + AuthModule, + AdminTableModule, + AdminOrdersModule, + AdminCitiesModule, + AdminPreOrdersModule, + AdminWorkshopsModule, + AdminCustomersModule, + AdminCommentsModule, + AdminProductsModule, + AdminCategoriesModule, + AdminDocumentsModule, + AdminPromotionsModule, + AdminUsersModule, + AdminBranchesModule, + AdminInvoicesModule, + AdminMiscModule, + AdminUploadsModule, + AdminDashboardModule, + CatalogModule, + CustomersModule, + OrdersModule, + AccountingModule, + PricingModule, + ], }) export class AppModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index bd1c143..f205620 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,15 +1,25 @@ import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { join } from 'node:path'; import { AppModule } from './app.module'; import { PrismaExceptionFilter } from './prisma/prisma-exception.filter'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useGlobalFilters(new PrismaExceptionFilter()); app.enableCors(); - await app.listen(process.env.PORT ?? 3000); + // Uploaded files, mounted OUTSIDE the /api prefix so the URLs stored on + // UploadedFile.url ("/uploads/") resolve directly. + app.useStaticAssets(process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads'), { + prefix: '/uploads/', + index: false, + dotfiles: 'deny', + }); + // Bind all interfaces explicitly so the container is reachable from nginx. + await app.listen(process.env.PORT ?? 3000, '0.0.0.0'); } bootstrap(); diff --git a/apps/api/src/modules/accounting/accounting.controller.ts b/apps/api/src/modules/accounting/accounting.controller.ts index 90df990..b6286e7 100644 --- a/apps/api/src/modules/accounting/accounting.controller.ts +++ b/apps/api/src/modules/accounting/accounting.controller.ts @@ -2,7 +2,9 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { LedgerService } from './ledger.service'; import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto'; import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto'; +import { Public } from '../auth/guards'; +@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes @Controller('accounting') export class AccountingController { constructor(private readonly ledger: LedgerService) {} diff --git a/apps/api/src/modules/admin-branches/admin-branches.controller.ts b/apps/api/src/modules/admin-branches/admin-branches.controller.ts new file mode 100644 index 0000000..3890829 --- /dev/null +++ b/apps/api/src/modules/admin-branches/admin-branches.controller.ts @@ -0,0 +1,56 @@ +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'; + +/** + * Legacy endpoints (BranchController + WorkshopController): + * POST branch/table + * POST branch, workshop -> create + * GET/PUT branch/{id}, workshop/{id} + * GET branch/active/{id}, workshop/active/{id} -> toggle + * GET user/list -> the branch-manager picker + */ +@Controller('admin/branches') +export class AdminBranchesController { + constructor(private readonly branches: AdminBranchesService) {} + + // ── branches ────────────────────────────────────────────────────── + @Post('list') + @HttpCode(200) + @RequirePermission('branch.list') + listBranches(@Body() query: TableQueryDto) { + return this.branches.listBranches(query); + } + + @Get('user-list') + @RequirePermission('branch.create') + userList() { + return this.branches.userList(); + } + + @Post() + @HttpCode(200) + @RequirePermission('branch.create') + createBranch(@Body() data: Record) { + return this.branches.createBranch(data); + } + + @Get('active/:id') + @RequirePermission('branch.list') + toggleBranchActive(@Param('id') id: string) { + return this.branches.toggleBranchActive(id); + } + + @Get(':id') + @RequirePermission('branch.list') + findBranch(@Param('id') id: string) { + return this.branches.findBranch(id); + } + + @Put(':id') + @RequirePermission('branch.list') + updateBranch(@Param('id') id: string, @Body() data: Record) { + return this.branches.updateBranch(id, data); + } +} diff --git a/apps/api/src/modules/admin-branches/admin-branches.module.ts b/apps/api/src/modules/admin-branches/admin-branches.module.ts new file mode 100644 index 0000000..86bcbaf --- /dev/null +++ b/apps/api/src/modules/admin-branches/admin-branches.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminBranchesController } from './admin-branches.controller'; +import { AdminBranchesService } from './admin-branches.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminBranchesController], + providers: [AdminBranchesService], + // The workshops module reuses this service — branches and workshops are + // near-identical screens and share their logic. + exports: [AdminBranchesService], +}) +export class AdminBranchesModule {} diff --git a/apps/api/src/modules/admin-branches/admin-branches.service.ts b/apps/api/src/modules/admin-branches/admin-branches.service.ts new file mode 100644 index 0000000..e96b18d --- /dev/null +++ b/apps/api/src/modules/admin-branches/admin-branches.service.ts @@ -0,0 +1,361 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { AdminRole, Prisma } from '@prisma/client'; +import * as bcrypt from 'bcryptjs'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Branches and workshops — legacy views/branch/* and views/workshop/*, served by + * BranchController and WorkshopController: + * POST branch/table | workshop/table -> the grids + * POST branch | workshop -> create + * GET branch/{id} | workshop/{id} -> one row + * PUT branch/{id} | workshop/{id} -> update + * GET branch/active/{id} | workshop/active/{id} -> toggle active + * + * The two screens differ in how their manager is handled, and that difference is + * preserved: a BRANCH picks an existing admin (`user_id`), while a WORKSHOP + * creates its supervisor inline from three fields (user_fname/lname/phone). + */ +@Injectable() +export class AdminBranchesService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + // ── Branches ─────────────────────────────────────────────────────── + + /** Legacy `POST branch/table`. */ + async listBranches(query: TableQueryDto) { + this.table.assertNotExport(query); + + const aliases: Record = { 'user.name': 'adminUsers.some.fullName' }; + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['name', 'address', 'phone'], + allowed: ['id', 'name', 'address', 'phone', 'isActive', 'cityId', 'city.name'], + defaultOrderBy: { name: 'asc' }, + }); + void aliases; + + const [rows, total] = await Promise.all([ + this.prisma.branch.findMany({ + where: where as Prisma.BranchWhereInput, + orderBy: orderBy as Prisma.BranchOrderByWithRelationInput, + skip, + take, + include: { + city: true, + adminUsers: { where: { isActive: true }, take: 1 }, + _count: { select: { adminUsers: true, orders: true } }, + }, + }), + this.prisma.branch.count({ where: where as Prisma.BranchWhereInput }), + ]); + + return this.table.respond( + rows.map((b) => ({ + id: b.id, + name: b.name, + address: b.address, + phone: b.phone, + active: b.isActive ? 1 : 0, + is_active: b.isActive, + city_id: b.cityId, + city: b.city ? { id: b.city.id, title: b.city.name } : null, + // legacy column key is `user.name` — the branch manager + user: b.adminUsers[0] + ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } + : null, + users_count: b._count.adminUsers, + orders_count: b._count.orders, + })), + total, + query, + page, + limit, + ); + } + + async findBranch(id: string) { + const b = await this.prisma.branch.findUnique({ + where: { id }, + include: { city: true, adminUsers: { where: { isActive: true }, take: 1 } }, + }); + if (!b) throw new NotFoundException('شعبه یافت نشد'); + return { + id: b.id, + name: b.name, + address: b.address, + phone: b.phone, + is_active: b.isActive, + city_id: b.cityId, + user_id: b.adminUsers[0]?.id ?? null, + user: b.adminUsers[0] ? { id: b.adminUsers[0].id, name: b.adminUsers[0].fullName } : null, + }; + } + + /** Legacy `POST branch` — the manager is an EXISTING admin picked by id. */ + async createBranch(data: Record) { + const name = String(data.name ?? '').trim(); + if (!name) throw new BadRequestException('نام شعبه الزامی است'); + + const branch = await this.prisma.branch.create({ + data: { + name, + address: (data.address as string) || null, + phone: (data.phone as string) || null, + cityId: (data.city_id as string) || null, + }, + }); + + if (data.user_id) { + await this.assignBranchManager(branch.id, String(data.user_id)); + } + return this.findBranch(branch.id); + } + + /** Legacy `PUT branch/{id}`. */ + async updateBranch(id: string, data: Record) { + const existing = await this.prisma.branch.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('شعبه یافت نشد'); + + const patch: Prisma.BranchUpdateInput = {}; + if (data.name !== undefined) patch.name = String(data.name); + if (data.address !== undefined) patch.address = (data.address as string) || null; + if (data.phone !== undefined) patch.phone = (data.phone as string) || null; + if (data.city_id !== undefined) { + patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true }; + } + + await this.prisma.branch.update({ where: { id }, data: patch }); + + if (data.user_id !== undefined) { + await this.assignBranchManager(id, (data.user_id as string) || null); + } + return this.findBranch(id); + } + + /** + * Moves the named admin onto this branch and clears whoever was on it before, + * so a branch keeps exactly one manager. Legacy did the same through its + * `->update(['branch_id' => null])` / `->update(['branch_id' => $id])` pair. + */ + private async assignBranchManager(branchId: string, userId: string | null) { + await this.prisma.$transaction(async (tx) => { + await tx.adminUser.updateMany({ where: { branchId }, data: { branchId: null } }); + if (userId) { + const user = await tx.adminUser.findUnique({ where: { id: userId } }); + if (!user) throw new NotFoundException('کاربر انتخاب‌شده یافت نشد'); + await tx.adminUser.update({ where: { id: userId }, data: { branchId } }); + } + }); + } + + /** Legacy `GET branch/active/{id}` — a toggle. */ + async toggleBranchActive(id: string) { + const b = await this.prisma.branch.findUnique({ where: { id } }); + if (!b) throw new NotFoundException('شعبه یافت نشد'); + return this.prisma.branch.update({ + where: { id }, + data: { isActive: !b.isActive }, + }); + } + + // ── Workshops ────────────────────────────────────────────────────── + + /** Legacy `POST workshop/table`. */ + async listWorkshops(query: TableQueryDto) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['name', 'address', 'phone'], + allowed: ['id', 'name', 'address', 'phone', 'isActive'], + defaultOrderBy: { name: 'asc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.workshop.findMany({ + where: where as Prisma.WorkshopWhereInput, + orderBy: orderBy as Prisma.WorkshopOrderByWithRelationInput, + skip, + take, + include: { supervisor: true, _count: { select: { preOrders: true } } }, + }), + this.prisma.workshop.count({ where: where as Prisma.WorkshopWhereInput }), + ]); + + return this.table.respond( + rows.map((w) => this.workshopRow(w)), + total, + query, + page, + limit, + ); + } + + private workshopRow( + w: Prisma.WorkshopGetPayload<{ + include: { supervisor: true; _count: { select: { preOrders: true } } }; + }>, + ) { + return { + id: w.id, + name: w.name, + address: w.address, + phone: w.phone, + active: w.isActive ? 1 : 0, + is_active: w.isActive, + user: w.supervisor ? { id: w.supervisor.id, name: w.supervisor.fullName } : null, + user_id: w.supervisorId, + preorders_count: w._count.preOrders, + }; + } + + async findWorkshop(id: string) { + const w = await this.prisma.workshop.findUnique({ + where: { id }, + include: { supervisor: true, _count: { select: { preOrders: true } } }, + }); + if (!w) throw new NotFoundException('کارگاه یافت نشد'); + + const row = this.workshopRow(w); + // The edit form splits the supervisor back into its three fields. + const parts = String(w.supervisor?.fullName ?? '').trim().split(' '); + return { + ...row, + workshop_name: w.name, + workshop_address: w.address, + workshop_phone: w.phone, + user_fname: parts[0] ?? '', + user_lname: parts.slice(1).join(' '), + user_phone: w.supervisor?.mobile ?? '', + }; + } + + /** + * Legacy `POST workshop` — creates the workshop AND its supervisor together, + * from user_fname / user_lname / user_phone. + */ + async createWorkshop(data: Record) { + const name = String(data.workshop_name ?? data.name ?? '').trim(); + if (!name) throw new BadRequestException('نام کارگاه الزامی است'); + + const supervisorId = await this.upsertSupervisor(data, null); + + const w = await this.prisma.workshop.create({ + data: { + name, + address: (data.workshop_address as string) || (data.address as string) || null, + phone: (data.workshop_phone as string) || (data.phone as string) || null, + supervisorId, + }, + }); + return this.findWorkshop(w.id); + } + + /** Legacy `PUT workshop/{id}`. */ + async updateWorkshop(id: string, data: Record) { + const existing = await this.prisma.workshop.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('کارگاه یافت نشد'); + + const patch: Prisma.WorkshopUpdateInput = {}; + if (data.workshop_name !== undefined || data.name !== undefined) { + patch.name = String(data.workshop_name ?? data.name); + } + if (data.workshop_address !== undefined || data.address !== undefined) { + patch.address = ((data.workshop_address ?? data.address) as string) || null; + } + if (data.workshop_phone !== undefined || data.phone !== undefined) { + patch.phone = ((data.workshop_phone ?? data.phone) as string) || null; + } + + const supervisorId = await this.upsertSupervisor(data, existing.supervisorId); + if (supervisorId !== undefined) { + patch.supervisor = supervisorId + ? { connect: { id: supervisorId } } + : { disconnect: true }; + } + + await this.prisma.workshop.update({ where: { id }, data: patch }); + return this.findWorkshop(id); + } + + /** + * Creates or updates the workshop's supervisor from the inline fields. + * Returns the id to link, or `undefined` when the form sent no supervisor data + * at all (so an update leaves the existing link alone). + */ + private async upsertSupervisor( + data: Record, + currentId: string | null, + ): Promise { + const fname = String(data.user_fname ?? '').trim(); + const lname = String(data.user_lname ?? '').trim(); + const phone = String(data.user_phone ?? '').trim(); + + if (!fname && !lname && !phone) return undefined; + if (!phone) throw new BadRequestException('موبایل سرپرست کارگاه الزامی است'); + + const fullName = `${fname} ${lname}`.trim(); + + if (currentId) { + const current = await this.prisma.adminUser.findUnique({ where: { id: currentId } }); + if (current) { + if (phone !== current.mobile) { + const clash = await this.prisma.adminUser.findUnique({ where: { mobile: phone } }); + if (clash && clash.id !== currentId) { + throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است'); + } + } + await this.prisma.adminUser.update({ + where: { id: currentId }, + data: { fullName, mobile: phone }, + }); + return currentId; + } + } + + // Reuse an existing admin with that mobile rather than colliding on the + // unique constraint. + const existing = await this.prisma.adminUser.findUnique({ where: { mobile: phone } }); + if (existing) return existing.id; + + const created = await this.prisma.adminUser.create({ + data: { + fullName, + mobile: phone, + // No password is set: the supervisor cannot sign in until one is issued. + passwordHash: '', + role: AdminRole.WORKSHOP_SUPERVISOR, + }, + }); + return created.id; + } + + /** Legacy `GET workshop/active/{id}` — a toggle. */ + async toggleWorkshopActive(id: string) { + const w = await this.prisma.workshop.findUnique({ where: { id } }); + if (!w) throw new NotFoundException('کارگاه یافت نشد'); + return this.prisma.workshop.update({ + where: { id }, + data: { isActive: !w.isActive }, + }); + } + + /** The admin picker the branch form uses — legacy `GET user/list`. */ + async userList() { + const rows = await this.prisma.adminUser.findMany({ + where: { isActive: true }, + orderBy: { fullName: 'asc' }, + select: { id: true, fullName: true, mobile: true, branchId: true }, + }); + return rows.map((u) => ({ + id: u.id, + name: u.fullName, + mobile: u.mobile, + branch_id: u.branchId, + })); + } +} diff --git a/apps/api/src/modules/admin-categories/admin-categories.controller.ts b/apps/api/src/modules/admin-categories/admin-categories.controller.ts new file mode 100644 index 0000000..d70beb9 --- /dev/null +++ b/apps/api/src/modules/admin-categories/admin-categories.controller.ts @@ -0,0 +1,92 @@ +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'; + +/** + * Legacy endpoints (CategoryController + the collection screens): + * POST category -> the grid (a read expressed as POST, legacy's shape) + * POST category/ -> create + * PUT category/{id} -> update + * DELETE category/{id} -> delete + * GET ciny -> collections ("سینی") + */ +@Controller('admin/categories') +export class AdminCategoriesController { + constructor(private readonly categories: AdminCategoriesService) {} + + @Post('list') + @HttpCode(200) + @RequirePermission('collection.list') + list(@Body() query: TableQueryDto) { + return this.categories.list(query); + } + + @Get('tree') + @RequirePermission('collection.list') + tree(@Query('type') type?: string) { + return this.categories.tree(type ? Number(type) : undefined); + } + + @Get('type-options') + @RequirePermission('collection.list') + typeOptions() { + return this.categories.typeOptions(); + } + + // ── collections ("سینی") ───────────────────────────────────────── + @Post('collections/list') + @HttpCode(200) + @RequirePermission('collection.list') + listCollections(@Body() query: TableQueryDto) { + return this.categories.listCollections(query); + } + + @Post('collections') + @HttpCode(200) + @RequirePermission('collection.create') + createCollection(@Body() body: { title: string; description?: string; is_active?: boolean }) { + return this.categories.createCollection(body); + } + + @Put('collections/:id') + @RequirePermission('collection.list.edit') + updateCollection( + @Param('id') id: string, + @Body() body: { title?: string; description?: string; is_active?: boolean }, + ) { + return this.categories.updateCollection(id, body); + } + + @Delete('collections/:id') + @RequirePermission('collection.list.edit') + removeCollection(@Param('id') id: string) { + return this.categories.removeCollection(id); + } + + // ── categories ──────────────────────────────────────────────────── + @Post() + @HttpCode(200) + @RequirePermission('collection.create') + create(@Body() data: Record) { + return this.categories.create(data); + } + + @Get(':id') + @RequirePermission('collection.list') + findOne(@Param('id') id: string) { + return this.categories.findOne(id); + } + + @Put(':id') + @RequirePermission('collection.list.edit') + update(@Param('id') id: string, @Body() data: Record) { + return this.categories.update(id, data); + } + + @Delete(':id') + @RequirePermission('collection.list.edit') + remove(@Param('id') id: string) { + return this.categories.remove(id); + } +} diff --git a/apps/api/src/modules/admin-categories/admin-categories.module.ts b/apps/api/src/modules/admin-categories/admin-categories.module.ts new file mode 100644 index 0000000..1603023 --- /dev/null +++ b/apps/api/src/modules/admin-categories/admin-categories.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminCategoriesController } from './admin-categories.controller'; +import { AdminCategoriesService } from './admin-categories.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminCategoriesController], + providers: [AdminCategoriesService], +}) +export class AdminCategoriesModule {} diff --git a/apps/api/src/modules/admin-categories/admin-categories.service.ts b/apps/api/src/modules/admin-categories/admin-categories.service.ts new file mode 100644 index 0000000..d5c65c2 --- /dev/null +++ b/apps/api/src/modules/admin-categories/admin-categories.service.ts @@ -0,0 +1,287 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Category + Collection administration — legacy views/apps/category/* + * (CategoryController: `POST category`, `POST category/`, `PUT category/{id}`, + * `DELETE category/{id}`) and the "سینی" collection screens. + * + * Legacy Category.type: 1 سایت | 2 اپلیکشن موبایل. + */ +const TYPE_LABELS: Record = { + 1: 'سایت', + 2: 'اپلیکشن موبایل', +}; + +@Injectable() +export class AdminCategoriesService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'title', + 'type', + 'parentId', + 'parent.title', + // legacy filter keys + 'type_str', + ]; + + /** Legacy `POST category` — the category grid. */ + async list(query: TableQueryDto) { + this.table.assertNotExport(query); + + const aliased: TableQueryDto = { + ...query, + filters: (query.filters ?? []).map((f) => + f.field === 'type_str' ? { ...f, field: 'type', value: Number(f.value) } : f, + ), + }; + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['title'], + allowed: AdminCategoriesService.ALLOWED, + defaultOrderBy: { title: 'asc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.category.findMany({ + where: where as Prisma.CategoryWhereInput, + orderBy: orderBy as Prisma.CategoryOrderByWithRelationInput, + skip, + take, + include: { parent: true, _count: { select: { products: true, children: true } } }, + }), + this.prisma.category.count({ where: where as Prisma.CategoryWhereInput }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + private toRow( + c: Prisma.CategoryGetPayload<{ + include: { parent: true; _count: { select: { products: true; children: true } } }; + }>, + ) { + return { + id: c.id, + title: c.title, + type: c.type, + type_str: TYPE_LABELS[c.type] ?? '-', + image: c.imageUrl, + icon: c.iconUrl, + meta: c.metaTag, + body_html: c.description, + parent_id_id: c.parentId, + parent: c.parent ? { id: c.parent.id, title: c.parent.title } : null, + products_count: c._count.products, + children_count: c._count.children, + }; + } + + async findOne(id: string) { + const c = await this.prisma.category.findUnique({ + where: { id }, + include: { parent: true, _count: { select: { products: true, children: true } } }, + }); + if (!c) throw new NotFoundException('دسته بندی یافت نشد'); + return this.toRow(c); + } + + /** The parent picker — legacy loaded the full list and built a tree client-side. */ + async tree(type?: number) { + const rows = await this.prisma.category.findMany({ + where: type ? { type } : {}, + orderBy: { title: 'asc' }, + include: { parent: true }, + }); + return rows.map((c) => ({ + id: c.id, + title: c.title, + type: c.type, + parent_id_id: c.parentId, + })); + } + + /** Legacy `POST category/`. */ + async create(data: Record) { + const title = String(data.title ?? '').trim(); + if (!title) throw new BadRequestException('عنوان دسته الزامی است'); + + return this.prisma.category.create({ + data: { + title, + type: data.type !== undefined ? Number(data.type) : 1, + imageUrl: (data.image as string) || null, + iconUrl: (data.icon as string) || null, + metaTag: (data.meta as string) || null, + description: (data.body_html as string) || null, + parentId: (data.parent_id_id as string) || null, + }, + }); + } + + /** Legacy `PUT category/{id}`. */ + async update(id: string, data: Record) { + const existing = await this.prisma.category.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('دسته بندی یافت نشد'); + + // A category cannot be its own parent, nor a descendant's child — that would + // create a cycle in the tree and hang any recursive render. Legacy had no + // such guard. + const newParent = data.parent_id_id as string | undefined; + if (newParent) { + if (newParent === id) { + throw new BadRequestException('یک دسته نمی‌تواند پدر خودش باشد'); + } + if (await this.isDescendant(newParent, id)) { + throw new BadRequestException('دسته‌ی انتخاب‌شده زیرمجموعه‌ی همین دسته است'); + } + } + + const patch: Prisma.CategoryUpdateInput = {}; + if (data.title !== undefined) patch.title = String(data.title); + if (data.type !== undefined) patch.type = Number(data.type); + if (data.image !== undefined) patch.imageUrl = (data.image as string) || null; + if (data.icon !== undefined) patch.iconUrl = (data.icon as string) || null; + if (data.meta !== undefined) patch.metaTag = (data.meta as string) || null; + if (data.body_html !== undefined) patch.description = (data.body_html as string) || null; + if (data.parent_id_id !== undefined) { + patch.parent = newParent ? { connect: { id: newParent } } : { disconnect: true }; + } + + return this.prisma.category.update({ where: { id }, data: patch }); + } + + /** Walks up from `candidate` to see whether `ancestorId` is above it. */ + private async isDescendant(candidate: string, ancestorId: string): Promise { + let cursor: string | null = candidate; + const seen = new Set(); + while (cursor && !seen.has(cursor)) { + seen.add(cursor); + const row: { parentId: string | null } | null = await this.prisma.category.findUnique({ + where: { id: cursor }, + select: { parentId: true }, + }); + if (!row?.parentId) return false; + if (row.parentId === ancestorId) return true; + cursor = row.parentId; + } + return false; + } + + /** Legacy `DELETE category/{id}`. */ + async remove(id: string) { + const c = await this.prisma.category.findUnique({ + where: { id }, + include: { _count: { select: { products: true, children: true } } }, + }); + if (!c) throw new NotFoundException('دسته بندی یافت نشد'); + + // Refuses while anything still hangs off it. Legacy deleted regardless, + // which orphaned products and left child categories pointing at a missing + // parent. Recorded in docs/ADMIN-PORT-PLAN.md. + if (c._count.products > 0) { + throw new BadRequestException( + `حذف ممکن نیست: ${c._count.products} محصول در این دسته قرار دارد.`, + ); + } + if (c._count.children > 0) { + throw new BadRequestException( + `حذف ممکن نیست: این دسته ${c._count.children} زیردسته دارد.`, + ); + } + + await this.prisma.category.delete({ where: { id } }); + return { success: true }; + } + + typeOptions() { + return Object.entries(TYPE_LABELS).map(([value, title]) => ({ value: Number(value), title })); + } + + // ── Collections ("سینی") ─────────────────────────────────────────── + + /** Legacy `GET ciny` / the collection grid. */ + async listCollections(query: TableQueryDto) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['title'], + allowed: ['id', 'title', 'isActive'], + defaultOrderBy: { title: 'asc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.collection.findMany({ + where: where as Prisma.CollectionWhereInput, + orderBy: orderBy as Prisma.CollectionOrderByWithRelationInput, + skip, + take, + include: { _count: { select: { products: true } } }, + }), + this.prisma.collection.count({ where: where as Prisma.CollectionWhereInput }), + ]); + + return this.table.respond( + rows.map((c) => ({ + id: c.id, + title: c.title, + description: c.description, + is_active: c.isActive, + products_count: c._count.products, + })), + total, + query, + page, + limit, + ); + } + + async createCollection(data: { title: string; description?: string; is_active?: boolean }) { + if (!data.title?.trim()) throw new BadRequestException('عنوان سینی الزامی است'); + return this.prisma.collection.create({ + data: { + title: data.title.trim(), + description: data.description ?? null, + isActive: data.is_active ?? true, + }, + }); + } + + async updateCollection( + id: string, + data: { title?: string; description?: string; is_active?: boolean }, + ) { + const existing = await this.prisma.collection.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('سینی یافت نشد'); + + return this.prisma.collection.update({ + where: { id }, + data: { + ...(data.title !== undefined ? { title: data.title } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.is_active !== undefined ? { isActive: data.is_active } : {}), + }, + }); + } + + async removeCollection(id: string) { + const c = await this.prisma.collection.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('سینی یافت نشد'); + + // The join rows are this collection's own membership list, so clearing them + // removes no product — unlike the category case above. + await this.prisma.$transaction([ + this.prisma.collectionProduct.deleteMany({ where: { collectionId: id } }), + this.prisma.collection.delete({ where: { id } }), + ]); + return { success: true }; + } +} diff --git a/apps/api/src/modules/admin-cities/admin-cities.controller.ts b/apps/api/src/modules/admin-cities/admin-cities.controller.ts new file mode 100644 index 0000000..ad729cc --- /dev/null +++ b/apps/api/src/modules/admin-cities/admin-cities.controller.ts @@ -0,0 +1,22 @@ +import { Controller, HttpCode, Post } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +/** + * Legacy `POST cities/list` — the city lookup the table filters use. + * Returns `{ rows }`, matching what the users store's setCities() reads. + * + * Deliberately NOT permission-gated: legacy called this from table models at + * module load, before any rid check, and it exposes nothing sensitive. + */ +@Controller('admin/cities') +export class AdminCitiesController { + constructor(private readonly prisma: PrismaService) {} + + @Post('list') + @HttpCode(200) + async list() { + const cities = await this.prisma.city.findMany({ orderBy: { name: 'asc' } }); + // legacy shape: { id, title } + return { rows: cities.map((c) => ({ id: c.id, title: c.name, province: c.province })) }; + } +} diff --git a/apps/api/src/modules/admin-cities/admin-cities.module.ts b/apps/api/src/modules/admin-cities/admin-cities.module.ts new file mode 100644 index 0000000..426a3e8 --- /dev/null +++ b/apps/api/src/modules/admin-cities/admin-cities.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminCitiesController } from './admin-cities.controller'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminCitiesController], +}) +export class AdminCitiesModule {} diff --git a/apps/api/src/modules/admin-comments/admin-comments.controller.ts b/apps/api/src/modules/admin-comments/admin-comments.controller.ts new file mode 100644 index 0000000..66692e3 --- /dev/null +++ b/apps/api/src/modules/admin-comments/admin-comments.controller.ts @@ -0,0 +1,59 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common'; +import { AdminCommentsService } from './admin-comments.service'; +import { TableQueryDto } from '../admin-table/table-query.dto'; +import { RequirePermission } from '../auth/guards'; + +/** + * Legacy endpoints (CommentController, routes/admin.php:149-152): + * POST comment/table -> the moderation grid + * POST comment/sts -> approve / reject + * POST comment -> staff reply + * DELETE comment/{id} + * + * All gated on the legacy rid `comment`. + */ +@Controller('admin/comments') +export class AdminCommentsController { + constructor(private readonly comments: AdminCommentsService) {} + + @Post('list') + @HttpCode(200) + @RequirePermission('comment') + list(@Body() query: TableQueryDto) { + return this.comments.list(query); + } + + @Get('status-options') + @RequirePermission('comment') + statusOptions() { + return this.comments.statusOptions(); + } + + @Post('sts') + @HttpCode(200) + @RequirePermission('comment') + changeStatus(@Body() body: { id: string; sts: number }) { + return this.comments.changeStatus(body.id, body.sts); + } + + @Post() + @HttpCode(200) + @RequirePermission('comment') + create( + @Body() body: { product_id: string; customer_id: string; comment: string; reply_to?: string }, + ) { + return this.comments.create(body); + } + + @Get(':id') + @RequirePermission('comment') + findOne(@Param('id') id: string) { + return this.comments.findOne(id); + } + + @Delete(':id') + @RequirePermission('comment') + remove(@Param('id') id: string) { + return this.comments.remove(id); + } +} diff --git a/apps/api/src/modules/admin-comments/admin-comments.module.ts b/apps/api/src/modules/admin-comments/admin-comments.module.ts new file mode 100644 index 0000000..42dc46d --- /dev/null +++ b/apps/api/src/modules/admin-comments/admin-comments.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminCommentsController } from './admin-comments.controller'; +import { AdminCommentsService } from './admin-comments.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminCommentsController], + providers: [AdminCommentsService], +}) +export class AdminCommentsModule {} diff --git a/apps/api/src/modules/admin-comments/admin-comments.service.ts b/apps/api/src/modules/admin-comments/admin-comments.service.ts new file mode 100644 index 0000000..b2e289d --- /dev/null +++ b/apps/api/src/modules/admin-comments/admin-comments.service.ts @@ -0,0 +1,203 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CommentStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Product comment moderation — legacy views/customer/Comments/Comments.vue, + * served by CommentController (`POST comment/table`, `POST comment/sts`, + * `POST comment`, `DELETE comment/{id}`). + * + * Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده']. + * The numeric codes are preserved on the wire (`sts`) so the ported view's + * status dropdown posts unchanged, while the DB stores an enum. + */ +const STATUS_BY_CODE: Record = { + 1: CommentStatus.PENDING, + 2: CommentStatus.APPROVED, + 3: CommentStatus.REJECTED, +}; + +const CODE_BY_STATUS: Record = { + PENDING: 1, + APPROVED: 2, + REJECTED: 3, +}; + +const STATUS_LABELS: Record = { + PENDING: 'در انتظار تایید', + APPROVED: 'تایید شده', + REJECTED: 'رد شده', +}; + +@Injectable() +export class AdminCommentsService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'status', + 'createdAt', + 'updatedAt', + 'body', + 'customer.fullName', + 'customer.mobile', + 'product.title', + // legacy filter keys + 'sts', + 'comment', + 'caj', + ]; + + private static readonly FIELD_ALIASES: Record = { + sts: 'status', + comment: 'body', + caj: 'createdAt', + uaj: 'updatedAt', + 'customer.name': 'customer.fullName', + 'product.name': 'product.title', + }; + + /** Legacy `POST comment/table`. */ + async list(query: TableQueryDto) { + this.table.assertNotExport(query); + + const mapField = (f?: string) => + f ? (AdminCommentsService.FIELD_ALIASES[f] ?? f) : f; + + const aliased: TableQueryDto = { + ...query, + filters: (query.filters ?? []).map((f) => { + const mapped = { ...f, field: mapField(f.field) }; + // legacy sent the numeric status code + if (mapped.field === 'status' && mapped.value !== undefined) { + mapped.value = STATUS_BY_CODE[Number(mapped.value)] ?? mapped.value; + } + return mapped; + }), + order: query.order + ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` + : undefined, + }; + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['body', 'customer.fullName', 'product.title'], + allowed: AdminCommentsService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.comment.findMany({ + where: where as Prisma.CommentWhereInput, + orderBy: orderBy as Prisma.CommentOrderByWithRelationInput, + skip, + take, + include: { customer: true, product: true, replyTo: true, replies: true }, + }), + this.prisma.comment.count({ where: where as Prisma.CommentWhereInput }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + private toRow( + c: Prisma.CommentGetPayload<{ + include: { customer: true; product: true; replyTo: true; replies?: true }; + }> & { replies?: { id: string; body: string }[] }, + ) { + return { + id: c.id, + comment: c.body, + body: c.body, + sts: CODE_BY_STATUS[c.status], + sts_str: STATUS_LABELS[c.status], + status: c.status, + reply_to: c.replyToId, + // legacy view reads: text.length > 0 && text[0].comment !== null + reply: (c.replies ?? []).map((r) => ({ id: r.id, comment: r.body })), + product_id: c.productId, + customer_id: c.customerId, + product: { id: c.product.id, title: c.product.title, name: c.product.title }, + customer: { + id: c.customer.id, + name: c.customer.fullName, + fname: c.customer.fullName, + lname: '', + mobile: c.customer.mobile, + }, + caj: c.createdAt, + uaj: c.updatedAt, + }; + } + + async findOne(id: string) { + const c = await this.prisma.comment.findUnique({ + where: { id }, + include: { customer: true, product: true, replyTo: true, replies: true }, + }); + if (!c) throw new NotFoundException('دیدگاه یافت نشد'); + return this.toRow(c); + } + + /** + * Legacy `POST comment/sts` — moderate a comment. + * Payload is `{ id, sts }` where sts is 1/2/3. + */ + async changeStatus(id: string, sts: number) { + const c = await this.prisma.comment.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('دیدگاه یافت نشد'); + + const status = STATUS_BY_CODE[Number(sts)]; + if (!status) { + throw new NotFoundException('وضعیت نامعتبر است'); + } + return this.prisma.comment.update({ where: { id }, data: { status } }); + } + + /** Legacy `POST comment` — post a staff reply to a comment. */ + async create(data: { + product_id: string; + customer_id: string; + comment: string; + reply_to?: string; + }) { + return this.prisma.comment.create({ + data: { + productId: data.product_id, + customerId: data.customer_id, + body: data.comment, + replyToId: data.reply_to ?? null, + // A staff reply is visible immediately; legacy did the same. + status: CommentStatus.APPROVED, + }, + }); + } + + /** Legacy `DELETE comment/{id}`. */ + async remove(id: string) { + const c = await this.prisma.comment.findUnique({ + where: { id }, + include: { replies: true }, + }); + if (!c) throw new NotFoundException('دیدگاه یافت نشد'); + + // Remove replies first so the self-relation does not block the delete. + await this.prisma.$transaction([ + this.prisma.comment.deleteMany({ where: { replyToId: id } }), + this.prisma.comment.delete({ where: { id } }), + ]); + return { success: true }; + } + + /** Farsi labels for the status dropdown, keyed by legacy's numeric codes. */ + statusOptions() { + return Object.entries(STATUS_LABELS).map(([status, title]) => ({ + value: CODE_BY_STATUS[status as CommentStatus], + title, + })); + } +} diff --git a/apps/api/src/modules/admin-customers/admin-customers.controller.ts b/apps/api/src/modules/admin-customers/admin-customers.controller.ts new file mode 100644 index 0000000..234e138 --- /dev/null +++ b/apps/api/src/modules/admin-customers/admin-customers.controller.ts @@ -0,0 +1,176 @@ +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 type { AuthenticatedAdmin } from '../auth/jwt.strategy'; + +/** + * Wholesale customer lists. Legacy endpoints, one per near-identical view: + * POST customer -> OmdeList + * POST banker -> BankerList + * POST shopkeeper -> ShopkeeperList + * POST major/verify/table -> OmdeNonVerifiedList + * POST major/archive/table -> OmdeArchivedList + * POST customer/toggle-active, customer/toggle-sts, major/verify + * GET major/archive/{id}, major/dashboard/{id} + */ +@Controller('admin/customers-omde') +export class AdminCustomersController { + constructor(private readonly customers: AdminCustomersService) {} + + private list(query: TableQueryDto, scope: OmdeScope) { + return this.customers.list(query, scope); + } + + @Post('list') + @HttpCode(200) + @RequirePermission('persons.list.client.omde') + listAll(@Body() query: TableQueryDto) { + return this.list(query, 'all'); + } + + @Post('banker/list') + @HttpCode(200) + @RequirePermission('persons.list.client.omde') + listBanker(@Body() query: TableQueryDto) { + return this.list(query, 'banker'); + } + + @Post('shopkeeper/list') + @HttpCode(200) + @RequirePermission('persons.list.client.omde') + listShopkeeper(@Body() query: TableQueryDto) { + return this.list(query, 'shopkeeper'); + } + + @Post('non-verified/list') + @HttpCode(200) + @RequirePermission('persons.list.client.omde') + listNonVerified(@Body() query: TableQueryDto) { + return this.list(query, 'non_verified'); + } + + @Post('archived/list') + @HttpCode(200) + @RequirePermission('persons.list.client.omde') + listArchived(@Body() query: TableQueryDto) { + return this.list(query, 'archived'); + } + + /** Legacy POST single-customer — the retail customer list. */ + @Post('single/list') + @HttpCode(200) + @RequirePermission('persons.list.client.single') + listSingle(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) { + return this.customers.listSingle(query, admin.branchId ?? undefined); + } + + /** + * Legacy `GET query/reagent` — the sales-expert picker used by the customer + * form. Returns a plain array of {id, name}, as legacy did. + */ + @Get('query/reagent') + @RequirePermission('persons.create') + queryReagents() { + return this.customers.queryReagents(); + } + + /** Legacy POST wallet/charge. */ + @Post('wallet/charge') + @HttpCode(200) + @RequirePermission('wallet') + chargeWallet(@Body() body: { customer_id: string; value: string | number }) { + return this.customers.chargeWallet(body); + } + + @Post('create') + @HttpCode(200) + @RequirePermission('persons.create') + create(@Body() data: Record) { + return this.customers.create(data); + } + + @Put(':id') + @RequirePermission('persons.list.client.omde.edit') + update(@Param('id') id: string, @Body() data: Record) { + return this.customers.update(id, data); + } + + /** Legacy POST reagent — the sales-expert list. */ + @Post('reagent/list') + @HttpCode(200) + @RequirePermission('persons.list.sellers') + listReagents(@Body() query: TableQueryDto) { + return this.customers.listReagents(query); + } + + /** Legacy `GET single/order/{id}` — that customer's orders. */ + @Get(':id/orders') + @RequirePermission('persons.list.client.omde.view.orders') + customerOrders(@Param('id') id: string) { + return this.customers.customerOrders(id); + } + + /** Legacy `GET single/report/{id}` — that customer's ledger documents. */ + @Get(':id/documents') + @RequirePermission('persons.list.client.omde.view.documents') + customerDocuments(@Param('id') id: string) { + return this.customers.customerDocuments(id); + } + + /** Legacy `GET single/wallet/{id}` — that customer's wallet bonuses. */ + @Get(':id/wallet') + @RequirePermission('wallet') + customerWallet(@Param('id') id: string) { + return this.customers.customerWallet(id); + } + + @Get('dashboard/:id') + @RequirePermission('persons.list.client.omde.view') + dashboard(@Param('id') id: string) { + return this.customers.dashboard(id); + } + + @Get('archive/:id') + @RequirePermission('persons.list.client.omde.edit') + toggleArchive(@Param('id') id: string) { + return this.customers.toggleArchive(id); + } + + @Post('toggle-active') + @HttpCode(200) + @RequirePermission('persons.list.client.omde.edit') + toggleActive(@Body() body: { id: string }) { + return this.customers.toggleActive(body.id); + } + + @Post('toggle-sts') + @HttpCode(200) + @RequirePermission('persons.list.client.omde.edit') + toggleSts(@Body() body: { id: string }) { + return this.customers.toggleSts(body.id); + } + + @Post('verify') + @HttpCode(200) + @RequirePermission('persons.list.client.omde.edit') + verify( + @Body() + body: { customer_id: string; sts_verify_step_one?: number; sts_verify_step_two?: number }, + ) { + return this.customers.verify(body); + } + + @Post('credit') + @HttpCode(200) + @RequirePermission('persons.list.client.omde.edit') + updateCredit(@Body() body: { id: string; gram_level?: string; percent?: string }) { + return this.customers.updateCredit(body.id, body); + } + + @Get(':id') + @RequirePermission('persons.list.client.omde.view') + findOne(@Param('id') id: string) { + return this.customers.findOne(id); + } +} diff --git a/apps/api/src/modules/admin-customers/admin-customers.module.ts b/apps/api/src/modules/admin-customers/admin-customers.module.ts new file mode 100644 index 0000000..49c886d --- /dev/null +++ b/apps/api/src/modules/admin-customers/admin-customers.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminCustomersController } from './admin-customers.controller'; +import { AdminCustomersService } from './admin-customers.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminCustomersController], + providers: [AdminCustomersService], + exports: [AdminCustomersService], +}) +export class AdminCustomersModule {} diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.ts b/apps/api/src/modules/admin-customers/admin-customers.service.ts new file mode 100644 index 0000000..54a91e2 --- /dev/null +++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts @@ -0,0 +1,719 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { CustomerKind, Prisma, VerificationStep } from '@prisma/client'; +import * as bcrypt from 'bcryptjs'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Wholesale ("عمده") customer lists — legacy views/customer/omde/*. + * + * Legacy shipped FIVE near-identical 615-line views (OmdeList, BankerList, + * ShopkeeperList, OmdeNonVerifiedList, OmdeArchivedList) whose table models + * differed by exactly ONE line: the endpoint url. Verified by diff: + * ShopkeeperList vs OmdeList = 4 differing lines (title + model import only). + * + * They are served here by one method with a `scope`, matching the five legacy + * endpoints: customer, banker, shopkeeper, major/verify/table, major/archive/table. + */ +/** + * Legacy Customer::$TYPES, verbatim from henza_server app/Models/Customer.php:85 + * (including its leading-space quirks). Kept so rows still carry the numeric + * `type` and Farsi `type_str` the ported grids bind to. + */ +export const CUSTOMER_TYPE_STR: Record = { + SALES_AGENT: ' کارشناس فروش', + APP_SHOPKEEPER: 'مغازه دار اپلیکیشن', + WALK_IN_SHOPKEEPER: ' مغازه دار حضوری', + BANKER: 'بنکدار', + WORKSHOP_SUPERVISOR: 'سرپرست کارگاه', +}; + +const LEGACY_TYPE_ID: Record = { + SALES_AGENT: 1, + APP_SHOPKEEPER: 2, + WALK_IN_SHOPKEEPER: 3, + BANKER: 4, + WORKSHOP_SUPERVISOR: 5, +}; + +/** Inverse of kindToLegacyType — legacy forms post the numeric `type`. */ +export const legacyTypeToKind = (t: unknown): CustomerKind | undefined => + (Object.keys(LEGACY_TYPE_ID) as CustomerKind[]).find( + (k) => LEGACY_TYPE_ID[k] === Number(t), + ); + +export const kindToLegacyType = (k: CustomerKind): number => LEGACY_TYPE_ID[k]; + +export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'archived'; + +/** + * Legacy exposed verification as two numeric steps. Confirmed from the view's + * own switch statements and its accept/reject payloads (OmdeList.vue:445-487): + * + * 1 = در انتظار (pending) -> HelpCircleIcon + * 2 = accepted -> posted by acceptStepOne/acceptStepTwo + * 3 = rejected -> posted by rejectStepOne/rejectStepTwo + * + * NOTE the template gates the second step and the order/account switches on + * `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them. + * The new schema models one ordered enum, so these numbers are derived from it. + */ +function verifyStepOne(step: VerificationStep): number { + switch (step) { + case VerificationStep.UNVERIFIED: + return 3; // rejected / not yet submitted + case VerificationStep.STEP_ONE_SUBMITTED: + return 1; // pending review + default: + // STEP_TWO_SUBMITTED and VERIFIED both imply step one was accepted + return 2; + } +} + +function verifyStepTwo(step: VerificationStep): number { + switch (step) { + case VerificationStep.STEP_TWO_SUBMITTED: + return 1; // pending review + case VerificationStep.VERIFIED: + return 2; // accepted + default: + return 3; // rejected / not reached + } +} + +@Injectable() +export class AdminCustomersService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'fullName', + 'mobile', + 'nationalCode', + 'cityId', + 'isActive', + 'createdAt', + 'updatedAt', + 'verificationStep', + 'goldCreditLimitGrams', + 'goldDebtGrams', + 'fiatDebtRial', + 'referredByAgent.fullName', + // legacy filter keys, accepted so the ported table models work unchanged + 'name', + 'city_id', + 'active', + 'gram_level', + 'remain', + 'rial_remain', + ]; + + /** Translates legacy filter field names onto real schema paths. */ + private static readonly FIELD_ALIASES: Record = { + name: 'fullName', + fname: 'fullName', + lname: 'fullName', + city_id: 'cityId', + active: 'isActive', + gram_level: 'goldCreditLimitGrams', + remain: 'goldDebtGrams', + rial_remain: 'fiatDebtRial', + caj: 'createdAt', + uaj: 'updatedAt', + 'reagent.name': 'referredByAgent.fullName', + }; + + private applyAliases(query: TableQueryDto): TableQueryDto { + const mapField = (f?: string) => + f ? (AdminCustomersService.FIELD_ALIASES[f] ?? f) : f; + + return { + ...query, + filters: (query.filters ?? []).map((f) => { + const mapped = { ...f, field: mapField(f.field) }; + // legacy sent active as '1' / '-1'; the schema stores a boolean + if (mapped.field === 'isActive' && mapped.value !== undefined) { + mapped.value = String(mapped.value) === '1'; + } + return mapped; + }), + order: query.order + ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` + : undefined, + }; + } + + /** The per-scope WHERE clause, one per legacy endpoint. */ + /** + * Verified against the legacy server (henza_server), not inferred from the + * views — AdminController::getBanker / getShopkeeper / getCustomer and + * MajorCustomerController::indexArchive / indexVerify: + * + * customer -> type=2 AND is_archive=0 (مغازه دار اپلیکیشن) + * banker -> type=4 AND is_archive=0 (بنکدار) + * shopkeeper -> type=3 AND is_archive=0 (مغازه دار حضوری) + * major/verify/table -> is_archive=0 AND (step_one=1 OR step_two=1) + * major/archive/table -> is_archive=1 (no type filter) + * + * NOTE every non-archive list also excludes archived rows, so archiving a + * customer removes them from ALL type lists at once. + */ + private scopeWhere(scope: OmdeScope): Prisma.CustomerWhereInput { + switch (scope) { + case 'banker': + return { kind: CustomerKind.BANKER, isArchived: false }; + case 'shopkeeper': + // type 3 only — "لیست مغازه داران حضوری". App shopkeepers (type 2) are + // the main omde list, not this one. + return { kind: CustomerKind.WALK_IN_SHOPKEEPER, isArchived: false }; + case 'non_verified': + return { + isArchived: false, + verificationStep: { + in: [VerificationStep.STEP_ONE_SUBMITTED, VerificationStep.STEP_TWO_SUBMITTED], + }, + }; + case 'archived': + return { isArchived: true }; + case 'all': + default: + // legacy getCustomer is type=2 + is_archive=0 — not an unfiltered list. + return { kind: CustomerKind.APP_SHOPKEEPER, isArchived: false }; + } + } + + async list(query: TableQueryDto, scope: OmdeScope = 'all') { + this.table.assertNotExport(query); + const aliased = this.applyAliases(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['fullName', 'mobile', 'nationalCode'], + allowed: AdminCustomersService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const scoped: Prisma.CustomerWhereInput = { + AND: [where as Prisma.CustomerWhereInput, this.scopeWhere(scope)], + }; + + const [rows, total] = await Promise.all([ + this.prisma.customer.findMany({ + where: scoped, + orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput, + skip, + take, + include: { city: true, referredByAgent: true, avatarFile: true }, + }), + this.prisma.customer.count({ where: scoped }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + /** Legacy field names preserved so the ported table models bind unchanged. */ + private toRow( + c: Prisma.CustomerGetPayload<{ + include: { city: true; referredByAgent: true; avatarFile: true }; + }>, + ) { + return { + id: c.id, + // Legacy stored fname/lname separately; the new schema has one fullName. + name: c.fullName, + fname: c.fullName, + lname: '', + mobile: c.mobile, + national_code: c.nationalCode, + city_id: c.cityId, + city: c.city ? { id: c.city.id, title: c.city.name } : null, + reagent: c.referredByAgent + ? { id: c.referredByAgent.id, name: c.referredByAgent.fullName } + : null, + gram_level: c.goldCreditLimitGrams, + remain: c.goldDebtGrams, + rial_remain: c.fiatDebtRial, + // legacy `percent` came from the referring agent's commission rate + percent: c.referredByAgent?.wagePercent ?? null, + // legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive + active: c.isActive ? 1 : -1, + sts: c.canPlaceOrders ? 1 : -1, + sts_str: c.canPlaceOrders ? 'مجاز' : 'غیر مجاز', + type: kindToLegacyType(c.kind), + type_str: CUSTOMER_TYPE_STR[c.kind], + is_archive: c.isArchived ? 1 : 0, + sts_verify_step_one: verifyStepOne(c.verificationStep), + sts_verify_step_two: verifyStepTwo(c.verificationStep), + verificationStep: c.verificationStep, + national_id_image: c.nationalIdImageUrl, + business_license: c.businessLicenseUrl, + shop_image: c.shopImageUrl, + caj: c.createdAt, + uaj: c.updatedAt, + // Legacy returned the avatar as a nested object and the form read + // `data.image.image` for the preview URL — kept in that exact shape. + image_id: c.avatarFileId, + image: c.avatarFile ? { id: c.avatarFile.id, image: c.avatarFile.url } : null, + phone: c.phone, + address: c.address, + store_name: c.storeName, + zone: c.zone, + pasazh_name: c.pasazhName, + mcode: c.memberCode, + }; + } + + async findOne(id: string) { + const c = await this.prisma.customer.findUnique({ + where: { id }, + include: { + city: true, + referredByAgent: true, + avatarFile: true, + addresses: { include: { city: true } }, + }, + }); + if (!c) throw new NotFoundException('مشتری یافت نشد'); + return { + ...this.toRow(c), + addresses: c.addresses.map((a) => ({ + id: a.id, + city: a.city ? { id: a.city.id, title: a.city.name } : null, + })), + }; + } + + /** + * Legacy `POST reagent` — the sales-expert ("کارشناس فروش") list. + * + * In legacy these lived in the same `customer` table under `type=1`, which is + * why the view reuses the customer column set. The redesigned schema models a + * sales expert as an AdminUser with role SALES_EXPERT, so this reads from there + * and maps onto the same legacy field names the ported table expects. + */ + async listReagents(query: TableQueryDto) { + this.table.assertNotExport(query); + + const aliases: Record = { + name: 'fullName', + lname: 'fullName', + caj: 'createdAt', + active: 'isActive', + percent: 'wagePercent', + }; + const mapField = (f?: string) => (f ? (aliases[f] ?? f) : f); + + const aliased: TableQueryDto = { + ...query, + filters: (query.filters ?? []).map((f) => { + const mapped = { ...f, field: mapField(f.field) }; + if (mapped.field === 'isActive' && mapped.value !== undefined) { + mapped.value = String(mapped.value) === '1'; + } + return mapped; + }), + order: query.order + ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` + : undefined, + }; + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['fullName', 'mobile'], + allowed: ['id', 'fullName', 'mobile', 'isActive', 'createdAt', 'wagePercent', 'role'], + defaultOrderBy: { createdAt: 'desc' }, + }); + + const scoped: Prisma.AdminUserWhereInput = { + AND: [where as Prisma.AdminUserWhereInput, { role: 'SALES_EXPERT' }], + }; + + const [rows, total] = await Promise.all([ + this.prisma.adminUser.findMany({ + where: scoped, + orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput, + skip, + take, + include: { branch: true, _count: { select: { referredCustomers: true } } }, + }), + this.prisma.adminUser.count({ where: scoped }), + ]); + + // Total gold sold per agent, from the orders of the customers they referred. + const mapped = await Promise.all( + rows.map(async (r) => { + const agg = await this.prisma.order.aggregate({ + where: { customer: { referredByAgentId: r.id } }, + _sum: { totalGoldWeightGrams: true }, + }); + return { + id: r.id, + name: r.fullName, + fname: r.fullName, + lname: '', + mobile: r.mobile, + city_id: null, + city: null, + percent: r.wagePercent, + grams_sell: agg._sum.totalGoldWeightGrams ?? 0, + child: r._count.referredCustomers, + active: r.isActive ? 1 : -1, + sts: r.isActive ? 1 : -1, + sts_str: r.isActive ? 'مجاز' : 'غیر مجاز', + caj: r.createdAt, + uaj: r.createdAt, + branch: r.branch ? { id: r.branch.id, name: r.branch.name } : null, + // Legacy columns an AdminUser has no counterpart for. + gram_level: null, + remain: null, + rial_remain: null, + phone: null, + address: null, + }; + }), + ); + + return this.table.respond(mapped, total, query, page, limit); + } + + /** + * Legacy `POST single-customer` — the retail ("تک") customer list. + * + * Legacy read this from a different table than the wholesale list + * (`orders_orders`-era SingleCustomerOrders lived alongside a separate customer + * record). The redesigned schema has one Customer model, so both lists read the + * same rows; only the column set and actions differ, exactly as the two legacy + * table models did. Same gap noted for orders in docs/ADMIN-PORT-PLAN.md. + */ + async listSingle(query: TableQueryDto, branchId?: string) { + this.table.assertNotExport(query); + const aliased = this.applyAliases(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['fullName', 'mobile'], + allowed: [...AdminCustomersService.ALLOWED, 'branch.name'], + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.customer.findMany({ + where: where as Prisma.CustomerWhereInput, + orderBy: orderBy as Prisma.CustomerOrderByWithRelationInput, + skip, + take, + include: { + city: true, + avatarFile: true, + referredByAgent: { include: { branch: true } }, + }, + }), + this.prisma.customer.count({ where: where as Prisma.CustomerWhereInput }), + ]); + + return this.table.respond( + rows.map((r) => ({ + ...this.toRow(r), + // legacy single-customer columns: fname/lname/phone/email/branch.name + email: null, + branch: r.referredByAgent?.branch + ? { id: r.referredByAgent.branch.id, name: r.referredByAgent.branch.name } + : null, + })), + total, + query, + page, + limit, + ); + } + + /** Legacy `POST customer/create` (omdeSave) and `POST/PUT user/profile`. */ + async create(data: Record) { + const fullName = String(data.name ?? data.fname ?? '').trim(); + const mobile = String(data.mobile ?? '').trim(); + const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim(); + + if (!fullName || !mobile) { + throw new BadRequestException('نام و موبایل الزامی است'); + } + + return this.prisma.customer.create({ + data: { + fullName, + mobile, + nationalCode: nationalCode || mobile, // schema requires it and it is unique + // The form collects a password (bulkCustomer.vue / ReagentCustomer.vue). + // It used to be discarded and '' stored, so the operator typed a password + // that silently did nothing. Hashed here when supplied; '' still means + // "no password yet", which no bcrypt hash can ever match. + passwordHash: data.password ? await bcrypt.hash(String(data.password), 10) : '', + cityId: (data.city_id as string) ?? null, + goldCreditLimitGrams: data.gram_level + ? new Prisma.Decimal(String(data.gram_level)) + : new Prisma.Decimal(0), + // legacy `type` — defaults to 2 (مغازه دار اپلیکیشن), matching the + // main omde list, when the form omits it. + kind: legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER, + phone: (data.phone as string) ?? null, + address: (data.address as string) ?? null, + storeName: (data.store_name as string) ?? null, + pasazhName: (data.pasazh_name as string) ?? null, + zone: (data.zone as string) ?? null, + memberCode: (data.mcode as string) ?? null, + // legacy `image_id` from MyUpload's `ok` payload + avatarFileId: (data.image_id as string) || null, + // The sales agent who introduced this customer. The form's dropdown was + // populated from a working endpoint and the selection then thrown away. + referredByAgentId: (data.reagent_id as string) || null, + }, + }); + } + + async update(id: string, data: Record) { + const existing = await this.prisma.customer.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('مشتری یافت نشد'); + + const patch: Prisma.CustomerUpdateInput = {}; + if (data.name !== undefined) patch.fullName = String(data.name); + if (data.mobile !== undefined) patch.mobile = String(data.mobile); + if (data.national_code !== undefined) patch.nationalCode = String(data.national_code); + if (data.city_id !== undefined) { + patch.city = data.city_id ? { connect: { id: String(data.city_id) } } : { disconnect: true }; + } + if (data.gram_level !== undefined) { + patch.goldCreditLimitGrams = new Prisma.Decimal(String(data.gram_level)); + } + if (data.type !== undefined) { + const kind = legacyTypeToKind(data.type); + if (!kind) throw new BadRequestException('نوع مشتری نامعتبر است'); + patch.kind = kind; + } + if (data.phone !== undefined) patch.phone = String(data.phone); + if (data.address !== undefined) patch.address = String(data.address); + if (data.store_name !== undefined) patch.storeName = String(data.store_name); + if (data.pasazh_name !== undefined) patch.pasazhName = String(data.pasazh_name); + if (data.zone !== undefined) patch.zone = String(data.zone); + if (data.mcode !== undefined) patch.memberCode = String(data.mcode); + if (data.reagent_id !== undefined) { + patch.referredByAgent = data.reagent_id + ? { connect: { id: String(data.reagent_id) } } + : { disconnect: true }; + } + // Only when non-empty: an untouched password field must not clear the hash. + if (data.password) { + patch.passwordHash = await bcrypt.hash(String(data.password), 10); + } + if (data.image_id !== undefined) { + // legacy sent '' to mean "avatar removed" + patch.avatarFile = data.image_id + ? { connect: { id: String(data.image_id) } } + : { disconnect: true }; + } + + return this.prisma.customer.update({ where: { id }, data: patch }); + } + + /** + * The three per-customer panels the "مشاهده اطلاعات" screens show, ported from + * legacy `single/order/{id}`, `single/report/{id}` and `single/wallet/{id}`. + */ + async customerOrders(customerId: string) { + const rows = await this.prisma.order.findMany({ + where: { customerId }, + orderBy: { createdAt: 'desc' }, + include: { items: true, branch: true }, + }); + return rows.map((o) => ({ + id: o.id, + name: o.orderNumber, + w: o.totalGoldWeightGrams, + status: o.status, + items_count: o.items.length, + branch: o.branch?.name ?? null, + caj: o.createdAt, + })); + } + + async customerDocuments(customerId: string) { + const rows = await this.prisma.ledgerEntry.findMany({ + where: { customerId }, + orderBy: { createdAt: 'desc' }, + }); + return rows.map((e) => ({ + id: e.id, + type: e.sourceType, + currency: e.currency, + direction: e.direction, + value: e.amount, + confirmed: e.confirmed ? 1 : 0, + note: e.note, + caj: e.createdAt, + })); + } + + async customerWallet(customerId: string) { + const rows = await this.prisma.walletBonus.findMany({ + where: { customerId }, + orderBy: { grantedAt: 'desc' }, + }); + return rows.map((w) => ({ + id: w.id, + value: w.goldGrams, + is_active: w.isActive, + consumed: Boolean(w.consumedAt), + caj: w.grantedAt, + expires_at: w.expiresAt, + })); + } + + /** Legacy `GET query/reagent` — sales experts, for the customer form's picker. */ + async queryReagents() { + const rows = await this.prisma.adminUser.findMany({ + where: { role: 'SALES_EXPERT', isActive: true }, + orderBy: { fullName: 'asc' }, + select: { id: true, fullName: true }, + }); + return rows.map((r) => ({ id: r.id, name: r.fullName })); + } + + /** + * Legacy `POST wallet/charge` — grant a wallet bonus to a customer. + * + * Legacy posted `{ customer_id, value }`. The redesigned schema models a + * wallet grant as a WalletBonus in GOLD GRAMS with an expiry, so `value` is + * read as grams. Legacy had no expiry column; a one-year window is applied + * here because `expiresAt` is required — flagged in docs/ADMIN-PORT-PLAN.md. + */ + async chargeWallet(data: { customer_id: string; value: string | number }) { + const customer = await this.prisma.customer.findUnique({ where: { id: data.customer_id } }); + if (!customer) throw new NotFoundException('مشتری یافت نشد'); + + const grams = new Prisma.Decimal(String(data.value ?? 0)); + if (grams.lte(0)) { + throw new BadRequestException('میزان شارژ باید بزرگتر از صفر باشد'); + } + + const expiresAt = new Date(); + expiresAt.setFullYear(expiresAt.getFullYear() + 1); + + return this.prisma.walletBonus.create({ + data: { customerId: data.customer_id, goldGrams: grams, expiresAt }, + }); + } + + /** + * Legacy `POST customer/toggle-active` — the ACCOUNT switch + * (AdminController::customerToggleActive). + * + * Legacy also nulled `api_token` (force-logout) and, when reactivating, sent + * the customer an SMS. The SMS is deliberately NOT fired inline here — it + * belongs to the notification module — and is noted in docs/ADMIN-PORT-PLAN.md. + */ + async toggleActive(id: string) { + const c = await this.prisma.customer.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('مشتری یافت نشد'); + return this.prisma.customer.update({ + where: { id }, + data: { isActive: !c.isActive }, + }); + } + + /** + * Legacy `POST customer/toggle-sts` — the ORDERING switch + * ("معلق کردن سفارش" / "فعال کردن سفارش"), AdminController::customerToggleSts. + * Independent of the account switch above. + */ + async toggleSts(id: string) { + const c = await this.prisma.customer.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('مشتری یافت نشد'); + return this.prisma.customer.update({ + where: { id }, + data: { canPlaceOrders: !c.canPlaceOrders }, + }); + } + + /** + * Legacy `POST major/verify`. + * + * Legacy's payload is `{ customer_id, sts_verify_step_one }` or + * `{ customer_id, sts_verify_step_two }`, where the value is 2 (accept) or + * 3 (reject) — see OmdeList.vue acceptStepOne/rejectStepOne. Accepted here in + * exactly that shape so the ported view posts unchanged. + */ + async verify(payload: { + customer_id: string; + sts_verify_step_one?: number; + sts_verify_step_two?: number; + }) { + const id = payload.customer_id; + const c = await this.prisma.customer.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('مشتری یافت نشد'); + + let next: VerificationStep; + + if (payload.sts_verify_step_one !== undefined) { + next = + payload.sts_verify_step_one === 2 + ? VerificationStep.STEP_TWO_SUBMITTED // step one accepted -> step two pending + : VerificationStep.UNVERIFIED; // rejected + } else if (payload.sts_verify_step_two !== undefined) { + next = + payload.sts_verify_step_two === 2 + ? VerificationStep.VERIFIED + : VerificationStep.STEP_ONE_SUBMITTED; // rejected -> back to step one accepted + } else { + throw new BadRequestException('مرحله‌ی تایید مشخص نشده است'); + } + + return this.prisma.customer.update({ where: { id }, data: { verificationStep: next } }); + } + + /** Legacy `GET major/archive/{id}` — MajorCustomerController::archive. */ + async toggleArchive(id: string) { + const c = await this.prisma.customer.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('مشتری یافت نشد'); + return this.prisma.customer.update({ + where: { id }, + data: { isArchived: !c.isArchived }, + }); + } + + /** Legacy `GET major/dashboard/{id}` — the per-customer summary panel. */ + async dashboard(id: string) { + const c = await this.prisma.customer.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('مشتری یافت نشد'); + + const [orderCount, openOrders, ledgerCount] = await Promise.all([ + this.prisma.order.count({ where: { customerId: id } }), + this.prisma.order.count({ + where: { customerId: id, deliveredAt: null, status: { not: 'CANCELLED' } }, + }), + this.prisma.ledgerEntry.count({ where: { customerId: id } }), + ]); + + return { + total_order: orderCount, + open_order: openOrders, + ledger_entries: ledgerCount, + gram_level: c.goldCreditLimitGrams, + remain: c.goldDebtGrams, + rial_remain: c.fiatDebtRial, + }; + } + + /** Legacy `PATCH` of the credit level / commission from the list row menu. */ + async updateCredit(id: string, data: { gram_level?: string; percent?: string }) { + const c = await this.prisma.customer.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('مشتری یافت نشد'); + + if (data.gram_level === undefined) { + throw new BadRequestException('مقدار سطح اعتبار ارسال نشده است'); + } + + return this.prisma.customer.update({ + where: { id }, + data: { goldCreditLimitGrams: new Prisma.Decimal(data.gram_level) }, + }); + } +} diff --git a/apps/api/src/modules/admin-dashboard/admin-dashboard.controller.ts b/apps/api/src/modules/admin-dashboard/admin-dashboard.controller.ts new file mode 100644 index 0000000..cf6f5f8 --- /dev/null +++ b/apps/api/src/modules/admin-dashboard/admin-dashboard.controller.ts @@ -0,0 +1,39 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { AdminDashboardService } from './admin-dashboard.service'; +import { RequirePermission } from '../auth/guards'; + +/** + * Legacy endpoints: + * GET get-analytics -> views/DashboardECommerce.vue (wholesale) + * GET get-analytics-tak -> views/DashboardAnalytics.vue (retail) + */ +@Controller('admin/dashboard') +export class AdminDashboardController { + constructor(private readonly dashboard: AdminDashboardService) {} + + @Get('omde') + @RequirePermission('dashboard.omde') + omde() { + return this.dashboard.omdeAnalytics(); + } + + @Get('tak') + @RequirePermission('dashboard.tak') + tak() { + return this.dashboard.takAnalytics(); + } + + /** Legacy views/report/order_item.vue. */ + @Get('report/order-item') + @RequirePermission('report.order.item') + orderItemReport(@Query('start_date') from?: string, @Query('end_date') to?: string) { + return this.dashboard.orderItemReport(from, to); + } + + /** Legacy views/report/product.vue. */ + @Get('report/product') + @RequirePermission('report.product') + productReport(@Query('start_date') from?: string, @Query('end_date') to?: string) { + return this.dashboard.productReport(from, to); + } +} diff --git a/apps/api/src/modules/admin-dashboard/admin-dashboard.module.ts b/apps/api/src/modules/admin-dashboard/admin-dashboard.module.ts new file mode 100644 index 0000000..b2d8e84 --- /dev/null +++ b/apps/api/src/modules/admin-dashboard/admin-dashboard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminDashboardController } from './admin-dashboard.controller'; +import { AdminDashboardService } from './admin-dashboard.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminDashboardController], + providers: [AdminDashboardService], +}) +export class AdminDashboardModule {} diff --git a/apps/api/src/modules/admin-dashboard/admin-dashboard.service.ts b/apps/api/src/modules/admin-dashboard/admin-dashboard.service.ts new file mode 100644 index 0000000..1828af0 --- /dev/null +++ b/apps/api/src/modules/admin-dashboard/admin-dashboard.service.ts @@ -0,0 +1,269 @@ +import { Injectable } from '@nestjs/common'; +import { OrderStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; + +/** + * Dashboards — legacy views/DashboardECommerce.vue (`GET get-analytics`) and + * views/DashboardAnalytics.vue (`GET get-analytics-tak`). + * + * Legacy's ECommerce page is a Vuexy template with most of its English demo + * cards COMMENTED OUT (Congratulations, Support Tracker, Product Orders, Sales + * Stats, Activity Timeline — verified line by line). What is actually live: + * - three 30-day stat cards: new customers, gold sold, order count + * - "عملکرد کارشناسان فروش": agents ranked by grams sold + * - "میزان فروش": sales volume + * - "سفارشات هفته جاری": this week's orders + * + * So this returns exactly those figures rather than the template's demo series. + */ +@Injectable() +export class AdminDashboardService { + constructor(private readonly prisma: PrismaService) {} + + private daysAgo(n: number) { + const d = new Date(); + d.setDate(d.getDate() - n); + return d; + } + + /** Orders that have reached the debt-recognition point count as "sold". */ + private static readonly SOLD: Prisma.OrderWhereInput = { + status: { in: [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER] }, + }; + + /** Legacy `GET get-analytics` — the wholesale dashboard. */ + async omdeAnalytics() { + const since30 = this.daysAgo(30); + const since7 = this.daysAgo(7); + + const [customerCount, gramsAgg, orderCount, lastOrderRows, agents, monthAgg] = + await Promise.all([ + // تعداد مشتریان ثبت نامی در 30 روز گذشته + this.prisma.customer.count({ where: { createdAt: { gte: since30 } } }), + + // طلای فروش رفته در 30 روز گذشته + this.prisma.order.aggregate({ + where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] }, + _sum: { totalGoldWeightGrams: true }, + }), + + // تعداد سفارشات 30 روز گذشته + this.prisma.order.count({ where: { createdAt: { gte: since30 } } }), + + // سفارشات هفته جاری + this.prisma.order.findMany({ + where: { createdAt: { gte: since7 } }, + orderBy: { createdAt: 'desc' }, + take: 50, + include: { customer: true, branch: true }, + }), + + // عملکرد کارشناسان فروش + this.prisma.adminUser.findMany({ + where: { role: 'SALES_EXPERT', isActive: true }, + select: { id: true, fullName: true }, + }), + + // میزان فروش (same 30-day window, kept separate for clarity) + this.prisma.order.aggregate({ + where: { AND: [AdminDashboardService.SOLD, { createdAt: { gte: since30 } }] }, + _sum: { totalGoldWeightGrams: true }, + _count: true, + }), + ]); + + // Grams sold per agent, through the customers they referred. The view sorts + // this itself by grams_sell, so the shape matters more than the order. + const reagent = await Promise.all( + agents.map(async (a) => { + const agg = await this.prisma.order.aggregate({ + where: { + AND: [ + AdminDashboardService.SOLD, + { customer: { referredByAgentId: a.id } }, + { createdAt: { gte: since30 } }, + ], + }, + _sum: { totalGoldWeightGrams: true }, + _count: true, + }); + return { + id: a.id, + name: a.fullName, + grams_sell: agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0), + order_count: agg._count, + }; + }), + ); + + return { + customerCount, + allgram: gramsAgg._sum.totalGoldWeightGrams ?? 0, + ordercount: orderCount, + reagent, + lastOrder: lastOrderRows.map((o) => this.orderRow(o)), + sumLastOrder: monthAgg._count, + sumLastOrderMonth: monthAgg._sum.totalGoldWeightGrams ?? 0, + }; + } + + /** Legacy `GET get-analytics-tak` — the retail dashboard. */ + async takAnalytics() { + const since7 = this.daysAgo(7); + + const rows = await this.prisma.order.findMany({ + where: { createdAt: { gte: since7 } }, + orderBy: { createdAt: 'desc' }, + take: 50, + include: { customer: true, branch: true }, + }); + + // The retail view reads only `model.lastOrder`; the rest of its cards are + // the Vuexy demo series and are not rendered from server data. + return { lastOrder: rows.map((o) => this.orderRow(o)) }; + } + + /** + * Legacy `report/order_item` — "گزارش تجمیعی سفارشات و اسناد مالی": order + * lines aggregated over a date range. + */ + async orderItemReport(from?: string, to?: string) { + const where = this.dateWhere(from, to); + + const orders = await this.prisma.order.findMany({ + where, + include: { + customer: true, + items: { include: { productVariant: { include: { product: true } } } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const rows = orders.flatMap((o) => + o.items.map((it) => ({ + order_id: o.id, + order_number: o.orderNumber, + customer: o.customer.fullName, + product: it.productVariant.product.title, + variant: it.productVariant.title, + qty: it.quantity, + w: it.computedWeightGrams, + total_w: it.computedWeightGrams.mul(it.quantity), + caj: o.createdAt, + })), + ); + + const totalWeight = rows.reduce( + (s, r) => s.add(r.total_w), + new Prisma.Decimal(0), + ); + + return { + rows, + summary: { + order_count: orders.length, + item_count: rows.length, + total_weight: totalWeight, + }, + }; + } + + /** Legacy `report/product` — per-product sales over a date range. */ + async productReport(from?: string, to?: string) { + const where = this.dateWhere(from, to); + + const orders = await this.prisma.order.findMany({ + where, + include: { items: { include: { productVariant: { include: { product: true } } } } }, + }); + + const byProduct = new Map< + string, + { id: string; title: string; qty: number; weight: Prisma.Decimal; orders: Set } + >(); + + for (const o of orders) { + for (const it of o.items) { + const p = it.productVariant.product; + const row = byProduct.get(p.id) ?? { + id: p.id, + title: p.title, + qty: 0, + weight: new Prisma.Decimal(0), + orders: new Set(), + }; + row.qty += it.quantity; + row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity)); + row.orders.add(o.id); + byProduct.set(p.id, row); + } + } + + const rows = [...byProduct.values()] + .map((r) => ({ + id: r.id, + title: r.title, + total: r.qty, + w: r.weight, + order_count: r.orders.size, + })) + .sort((a, b) => Number(b.w) - Number(a.w)); + + return { rows, summary: { product_count: rows.length } }; + } + + /** Shared date filter for both reports; an open end stays open. */ + private dateWhere(from?: string, to?: string): Prisma.OrderWhereInput { + if (!from && !to) return {}; + const createdAt: Prisma.DateTimeFilter = {}; + if (from) createdAt.gte = new Date(from); + if (to) { + // Include the whole end day, not just its midnight. + const end = new Date(to); + end.setHours(23, 59, 59, 999); + createdAt.lte = end; + } + return { createdAt }; + } + + /** The row shape both dashboards' tables read. */ + private orderRow( + o: Prisma.OrderGetPayload<{ include: { customer: true; branch: true } }>, + ) { + const labels: Record = { + SUSPENDED: 'معلق (سقف اعتبار)', + PLACED: 'ثبت شده', + ADMIN_APPROVED: 'تایید مدیر', + SOURCING: 'در حال تامین', + PREPARING: 'در حال آماده‌سازی', + DELIVERED_TO_AGENT: 'تحویل به نماینده', + DELIVERED_TO_CUSTOMER: 'تحویل به مشتری', + CANCELLED: 'لغو شده', + }; + // legacy reads `statusColor` straight off the row + const colors: Record = { + SUSPENDED: 'warning', + PLACED: 'primary', + ADMIN_APPROVED: 'primary', + SOURCING: 'warning', + PREPARING: 'warning', + DELIVERED_TO_AGENT: 'success', + DELIVERED_TO_CUSTOMER: 'success', + CANCELLED: 'danger', + }; + + return { + id: o.id, + name: o.orderNumber, + customer: o.customer.fullName, + w: o.totalGoldWeightGrams, + sts_str: labels[o.status], + statusColor: colors[o.status], + caj: o.createdAt, + // legacy rendered a parent/children tree for split orders; the redesigned + // schema has no such relation, so both are empty rather than faked. + parent: null, + children: [], + }; + } +} diff --git a/apps/api/src/modules/admin-documents/admin-documents.controller.ts b/apps/api/src/modules/admin-documents/admin-documents.controller.ts new file mode 100644 index 0000000..9a683e1 --- /dev/null +++ b/apps/api/src/modules/admin-documents/admin-documents.controller.ts @@ -0,0 +1,106 @@ +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 type { AuthenticatedAdmin } from '../auth/jwt.strategy'; + +/** + * Legacy endpoints (CashController + the wallet screens): + * POST cash/table -> the documents grid + * POST cash -> file a repayment document + * GET cash/{id} -> one document + * PUT cash/{id} -> edit + * DELETE cash/{id} -> delete + * GET query/customer -> the customer picker + * POST wallet -> granted wallet bonuses + */ +@Controller('admin/documents') +export class AdminDocumentsController { + constructor(private readonly documents: AdminDocumentsService) {} + + @Post('list') + @HttpCode(200) + @RequirePermission('document.list') + list(@Body() query: TableQueryDto) { + return this.documents.list(query); + } + + @Get('type-options') + @RequirePermission('document.list') + typeOptions() { + return this.documents.typeOptions(); + } + + @Get('query/customer') + @RequirePermission('document.create') + queryCustomers() { + return this.documents.queryCustomers(); + } + + @Post('wallet/list') + @HttpCode(200) + @RequirePermission('wallet') + listWallet(@Body() query: TableQueryDto) { + return this.documents.listWallet(query); + } + + // ── wallet settings (views/manage/wallet.vue) ──────────────────── + @Get('wallet/config') + @RequirePermission('config.wallet') + getWalletConfig() { + return this.documents.getSetting('wallet.config'); + } + + @Post('wallet/config') + @HttpCode(200) + @RequirePermission('config.wallet') + setWalletConfig(@Body() body: Record) { + return this.documents.setSetting('wallet.config', body); + } + + @Get('wallet/birthday/config') + @RequirePermission('config.wallet') + getBirthdayConfig() { + return this.documents.getSetting('wallet.birthday'); + } + + @Post('wallet/birthday/config') + @HttpCode(200) + @RequirePermission('config.wallet') + setBirthdayConfig(@Body() body: Record) { + return this.documents.setSetting('wallet.birthday', body); + } + + @Post() + @HttpCode(200) + @RequirePermission('document.create') + create(@Body() data: Record, @CurrentAdmin() admin: AuthenticatedAdmin) { + return this.documents.create(data, admin.id); + } + + /** Legacy POST cash/confirem/{id} — a toggle, not a one-way confirm. */ + @Post(':id/confirm') + @HttpCode(200) + @RequirePermission('document.list.edit') + toggleConfirm(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) { + return this.documents.toggleConfirm(id, admin.id); + } + + @Get(':id') + @RequirePermission('document.list') + findOne(@Param('id') id: string) { + return this.documents.findOne(id); + } + + @Put(':id') + @RequirePermission('document.list.edit') + update(@Param('id') id: string, @Body() data: Record) { + return this.documents.update(id, data); + } + + @Delete(':id') + @RequirePermission('document.list.edit') + remove(@Param('id') id: string) { + return this.documents.remove(id); + } +} diff --git a/apps/api/src/modules/admin-documents/admin-documents.module.ts b/apps/api/src/modules/admin-documents/admin-documents.module.ts new file mode 100644 index 0000000..f7b314d --- /dev/null +++ b/apps/api/src/modules/admin-documents/admin-documents.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AccountingModule } from '../accounting/accounting.module'; +import { AdminDocumentsController } from './admin-documents.controller'; +import { AdminDocumentsService } from './admin-documents.service'; + +@Module({ + imports: [PrismaModule, AccountingModule], + controllers: [AdminDocumentsController], + providers: [AdminDocumentsService], +}) +export class AdminDocumentsModule {} diff --git a/apps/api/src/modules/admin-documents/admin-documents.service.ts b/apps/api/src/modules/admin-documents/admin-documents.service.ts new file mode 100644 index 0000000..1f08ed3 --- /dev/null +++ b/apps/api/src/modules/admin-documents/admin-documents.service.ts @@ -0,0 +1,439 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import { LedgerService } from '../accounting/ledger.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Financial documents ("اسناد مالی") — legacy views/finical/*, served by + * CashController (`POST cash/table`, `POST cash`, `PUT cash/{id}`, + * `GET cash/{id}`, `DELETE cash/{id}`). + * + * A legacy "cash" row IS a ledger entry: a customer repayment recorded as melted + * gold, a transfer, a cheque or cash. The four legacy `type` codes map onto the + * existing LedgerSourceType enum: + * + * 1 آب شده -> REPAYMENT_MELTED_GOLD (karat-normalized: value * ayar / 750) + * 2 حواله -> REPAYMENT_TRANSFER + * 3 چک -> REPAYMENT_CHEQUE + * 4 پولی -> REPAYMENT_CASH (normalized via the gold price per gram) + * + * Posting delegates to LedgerService, which owns the karat and price-per-gram + * conversions and the confirm/balance rules. Re-implementing them here would let + * the admin path drift from the customer-facing one. + */ +const TYPE_BY_CODE: Record = { + 1: LedgerSourceType.REPAYMENT_MELTED_GOLD, + 2: LedgerSourceType.REPAYMENT_TRANSFER, + 3: LedgerSourceType.REPAYMENT_CHEQUE, + 4: LedgerSourceType.REPAYMENT_CASH, +}; + +/** The instrument name LedgerService.postGoldRepayment expects. */ +const INSTRUMENT_BY_TYPE: Record = { + REPAYMENT_MELTED_GOLD: 'MELTED_GOLD', + REPAYMENT_TRANSFER: 'TRANSFER', + REPAYMENT_CHEQUE: 'CHEQUE', + REPAYMENT_CASH: 'CASH', +}; + +const CODE_BY_TYPE: Partial> = { + REPAYMENT_MELTED_GOLD: 1, + REPAYMENT_TRANSFER: 2, + REPAYMENT_CHEQUE: 3, + REPAYMENT_CASH: 4, +}; + +const TYPE_LABELS: Record = { + ORDER_STOCK: 'سفارش', + ORDER_PRE_ORDER: 'سفارش ساختنی', + REPAYMENT_MELTED_GOLD: 'آب شده', + REPAYMENT_TRANSFER: 'حواله', + REPAYMENT_CHEQUE: 'چک', + REPAYMENT_CASH: 'پولی', + WALLET_BONUS: 'هدیه کیف پول', + ADJUSTMENT: 'اصلاح', +}; + +@Injectable() +export class AdminDocumentsService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + private readonly ledger: LedgerService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'sourceType', + 'currency', + 'direction', + 'amount', + 'confirmed', + 'createdAt', + 'karatPerMille', + 'labName', + 'customer.fullName', + // legacy filter keys + 'type_str', + 'ayar', + 'lab', + 'caj', + ]; + + private static readonly FIELD_ALIASES: Record = { + type_str: 'sourceType', + ayar: 'karatPerMille', + lab: 'labName', + caj: 'createdAt', + uaj: 'createdAt', + 'customer.name': 'customer.fullName', + value: 'amount', + price: 'priceRial', + original_value: 'originalValue', + account_name: 'accountName', + bank_name: 'bankName', + to: 'transferTo', + recive_date: 'receivedAt', + price_per_gram: 'pricePerGramRialAtEntry', + ang: 'hallmark', + }; + + /** Legacy `POST cash/table` — the documents grid. */ + async list(query: TableQueryDto) { + this.table.assertNotExport(query); + + const mapField = (f?: string) => (f ? (AdminDocumentsService.FIELD_ALIASES[f] ?? f) : f); + const aliased: TableQueryDto = { + ...query, + filters: (query.filters ?? []).map((f) => { + const mapped = { ...f, field: mapField(f.field) }; + if (mapped.field === 'sourceType' && mapped.value !== undefined) { + mapped.value = TYPE_BY_CODE[Number(mapped.value)] ?? mapped.value; + } + if (mapped.field === 'confirmed' && mapped.value !== undefined) { + mapped.value = String(mapped.value) === '1'; + } + return mapped; + }), + order: query.order + ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` + : undefined, + }; + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['note', 'customer.fullName', 'labName'], + allowed: AdminDocumentsService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + // The grid is the REPAYMENT document list; order-generated debt entries are + // not documents an operator files, so they are excluded as legacy's separate + // `cash` table naturally did. + const scoped: Prisma.LedgerEntryWhereInput = { + AND: [ + where as Prisma.LedgerEntryWhereInput, + { sourceType: { in: Object.values(TYPE_BY_CODE) } }, + ], + }; + + const [rows, total] = await Promise.all([ + this.prisma.ledgerEntry.findMany({ + where: scoped, + orderBy: orderBy as Prisma.LedgerEntryOrderByWithRelationInput, + skip, + take, + include: { customer: true, postedByAdminUser: true }, + }), + this.prisma.ledgerEntry.count({ where: scoped }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + /** Legacy field names preserved so the ported table model binds unchanged. */ + private toRow( + e: Prisma.LedgerEntryGetPayload<{ include: { customer: true; postedByAdminUser: true } }>, + ) { + return { + id: e.id, + type: CODE_BY_TYPE[e.sourceType] ?? null, + type_str: TYPE_LABELS[e.sourceType], + sourceType: e.sourceType, + currency: e.currency, + direction: e.direction, + // legacy `value` is the normalized final weight, `original_value` the raw one + value: e.amount, + original_value: e.originalValue, + price: e.priceRial, + price_per_gram: e.pricePerGramRialAtEntry, + ayar: e.karatPerMille, + lab: e.labName, + ang: e.hallmark, + account_name: e.accountName, + bank_name: e.bankName, + to: e.transferTo, + recive_date: e.receivedAt, + note: e.note, + // legacy grid renders `confirmed` through a slot using 0/1 + confirmed: e.confirmed ? 1 : 0, + confirmedAt: e.confirmedAt, + customer: { + id: e.customer.id, + name: e.customer.fullName, + fname: e.customer.fullName, + lname: '', + mobile: e.customer.mobile, + }, + posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null, + caj: e.createdAt, + uaj: e.createdAt, + }; + } + + /** Legacy `GET cash/{id}`. */ + async findOne(id: string) { + const e = await this.prisma.ledgerEntry.findUnique({ + where: { id }, + include: { customer: true, postedByAdminUser: true }, + }); + if (!e) throw new NotFoundException('سند یافت نشد'); + return this.toRow(e); + } + + /** + * Legacy `POST cash` — file a repayment document. + * + * The gram/karat/price conversions are LedgerService's job; this method only + * translates the legacy form shape and attaches the instrument metadata. + */ + async create(data: Record, actorAdminUserId: string) { + const typeCode = Number(data.type ?? 2); + const sourceType = TYPE_BY_CODE[typeCode]; + if (!sourceType) throw new BadRequestException('نوع سند نامعتبر است'); + + const customerId = String(data.customer_id ?? ''); + if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است'); + + // LedgerService takes the legacy instrument name and the RAW figure, then + // applies the karat / price-per-gram conversion itself. + const instrument = INSTRUMENT_BY_TYPE[sourceType]; + + // Which field carries the raw figure depends on the instrument, exactly as + // the legacy form did: cash posts a Rial price, melted gold posts the + // pre-normalization weight, transfer/cheque post grams directly. + const rawValue = + instrument === 'CASH' + ? String(data.price ?? 0) + : instrument === 'MELTED_GOLD' + ? String(data.original_value ?? data.value ?? 0) + : String(data.value ?? 0); + + const entry = await this.ledger.postGoldRepayment({ + customerId, + instrument, + rawValue, + karatPerMille: data.ayar ? Number(data.ayar) : undefined, + pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined, + note: (data.note as string) ?? undefined, + postedByAdminUserId: actorAdminUserId, + }); + + // Attach the instrument metadata the ledger service does not model. + await this.prisma.ledgerEntry.update({ + where: { id: entry.id }, + data: this.metadataPatch(data), + }); + + return this.findOne(entry.id); + } + + private metadataPatch(data: Record): Prisma.LedgerEntryUpdateInput { + const patch: Prisma.LedgerEntryUpdateInput = {}; + if (data.lab !== undefined) patch.labName = (data.lab as string) || null; + if (data.ang !== undefined) patch.hallmark = (data.ang as string) || null; + if (data.account_name !== undefined) patch.accountName = (data.account_name as string) || null; + if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null; + if (data.to !== undefined) patch.transferTo = (data.to as string) || null; + if (data.original_value !== undefined && data.original_value !== null) { + patch.originalValue = new Prisma.Decimal(String(data.original_value)); + } + if (data.price !== undefined && data.price !== null) { + patch.priceRial = new Prisma.Decimal(String(data.price)); + } + if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date)); + return patch; + } + + /** + * Legacy `PUT cash/{id}` — edit a document. + * + * Only metadata and the note are editable. Changing the AMOUNT of a confirmed + * entry would silently rewrite a customer's debt, so that is refused: the + * correct accounting action is a reversing adjustment, not an edit. Legacy + * allowed it. Recorded in docs/ADMIN-PORT-PLAN.md. + */ + async update(id: string, data: Record) { + const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('سند یافت نشد'); + + const amountChanged = + data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount); + + if (existing.confirmed && amountChanged) { + throw new BadRequestException( + 'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.', + ); + } + + const patch = this.metadataPatch(data); + if (data.note !== undefined) patch.note = (data.note as string) || null; + if (!existing.confirmed && amountChanged) { + patch.amount = new Prisma.Decimal(String(data.value)); + } + + await this.prisma.ledgerEntry.update({ where: { id }, data: patch }); + return this.findOne(id); + } + + /** + * Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid + * shows a tick on an unconfirmed row and a cross on a confirmed one, both + * calling this same endpoint. + * + * Confirming delegates to LedgerService so the customer balance moves through + * the one code path that owns it. Un-confirming reverses that same amount + * rather than editing the balance directly, so the two stay consistent. + */ + async toggleConfirm(id: string, adminUserId: string) { + const e = await this.prisma.ledgerEntry.findUnique({ where: { id } }); + if (!e) throw new NotFoundException('سند یافت نشد'); + + if (!e.confirmed) { + await this.ledger.confirmEntry(id, adminUserId); + return this.findOne(id); + } + + // Un-confirm: undo this entry's effect on the cached balance, then clear the + // flag. A repayment is a CREDIT against gold debt, so reversing it adds the + // amount back. + await this.prisma.$transaction(async (tx) => { + const delta = + e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated(); + const field = + e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial'; + + await tx.customer.update({ + where: { id: e.customerId }, + data: { [field]: { increment: delta } }, + }); + await tx.ledgerEntry.update({ + where: { id }, + data: { confirmed: false, confirmedAt: null }, + }); + }); + + return this.findOne(id); + } + + /** + * Legacy `DELETE cash/{id}`. + * + * A confirmed entry has already moved the customer's balance; deleting it + * would leave that balance overstated with no trace. Refused — legacy allowed + * it. Unconfirmed entries never touched a balance, so they delete cleanly. + */ + async remove(id: string) { + const e = await this.prisma.ledgerEntry.findUnique({ where: { id } }); + if (!e) throw new NotFoundException('سند یافت نشد'); + if (e.confirmed) { + throw new BadRequestException( + 'حذف سند تاییدشده ممکن نیست، چون در تراز مشتری اعمال شده است.', + ); + } + await this.prisma.ledgerEntry.delete({ where: { id } }); + return { success: true }; + } + + typeOptions() { + return Object.entries(TYPE_BY_CODE).map(([code, sourceType]) => ({ + value: Number(code), + title: TYPE_LABELS[sourceType], + })); + } + + // ── Wallet ───────────────────────────────────────────────────────── + + /** Legacy `POST wallet` grid — the list of granted wallet bonuses. */ + async listWallet(query: TableQueryDto) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['customer.fullName'], + allowed: ['id', 'goldGrams', 'grantedAt', 'expiresAt', 'isActive', 'customer.fullName'], + defaultOrderBy: { grantedAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.walletBonus.findMany({ + where: where as Prisma.WalletBonusWhereInput, + orderBy: orderBy as Prisma.WalletBonusOrderByWithRelationInput, + skip, + take, + include: { customer: true }, + }), + this.prisma.walletBonus.count({ where: where as Prisma.WalletBonusWhereInput }), + ]); + + return this.table.respond( + rows.map((w) => ({ + id: w.id, + value: w.goldGrams, + grams: w.goldGrams, + is_active: w.isActive, + consumed: Boolean(w.consumedAt), + caj: w.grantedAt, + expires_at: w.expiresAt, + customer: { + id: w.customer.id, + name: w.customer.fullName, + mobile: w.customer.mobile, + }, + })), + total, + query, + page, + limit, + ); + } + + /** + * Legacy `GET /wallet/config` and `/wallet/birthday/config`, plus their POST + * counterparts — the wallet settings screen (views/manage/wallet.vue). + * Stored in the Setting key/value table that replaces legacy `config_static`. + */ + async getSetting(key: string) { + const row = await this.prisma.setting.findUnique({ where: { key } }); + return row?.value ?? {}; + } + + async setSetting(key: string, value: Record) { + const row = await this.prisma.setting.upsert({ + where: { key }, + update: { value: value as Prisma.InputJsonValue }, + create: { key, value: value as Prisma.InputJsonValue }, + }); + return row.value; + } + + /** The customer picker used by the document form — legacy `GET query/customer`. */ + async queryCustomers() { + const rows = await this.prisma.customer.findMany({ + where: { isActive: true }, + orderBy: { fullName: 'asc' }, + select: { id: true, fullName: true, mobile: true }, + }); + return rows.map((c) => ({ id: c.id, name: c.fullName, fname: c.fullName, lname: '', mobile: c.mobile })); + } +} diff --git a/apps/api/src/modules/admin-invoices/admin-invoices.controller.ts b/apps/api/src/modules/admin-invoices/admin-invoices.controller.ts new file mode 100644 index 0000000..b1fe468 --- /dev/null +++ b/apps/api/src/modules/admin-invoices/admin-invoices.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; +import { AdminInvoicesService } from './admin-invoices.service'; +import { TableQueryDto } from '../admin-table/table-query.dto'; +import { CurrentAdmin, RequirePermission } from '../auth/guards'; +import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; + +/** + * Legacy endpoints (BranchController's `branch/product` family): + * POST branch/product/table + * POST branch/product + * GET branch/product/{id} + * DELETE branch/product/{id} + * GET branch/list -> the branch picker + */ +@Controller('admin/invoices') +export class AdminInvoicesController { + constructor(private readonly invoices: AdminInvoicesService) {} + + @Post('list') + @HttpCode(200) + @RequirePermission('invoice.list') + list(@Body() query: TableQueryDto) { + return this.invoices.list(query); + } + + @Get('branch-list') + @RequirePermission('invoice.create') + branchList() { + return this.invoices.branchList(); + } + + @Post() + @HttpCode(200) + @RequirePermission('invoice.create') + create( + @Body() data: { branch_id?: string; products?: string[]; note?: string }, + @CurrentAdmin() admin: AuthenticatedAdmin, + ) { + return this.invoices.create(data, admin.id); + } + + @Get(':id') + @RequirePermission('invoice.list') + findOne(@Param('id') id: string) { + return this.invoices.findOne(id); + } + + @Put(':id') + @RequirePermission('invoice.list') + update( + @Param('id') id: string, + @Body() data: { branch_id?: string; products?: string[]; note?: string }, + ) { + return this.invoices.update(id, data); + } + + @Delete(':id') + @RequirePermission('invoice.list') + remove(@Param('id') id: string) { + return this.invoices.remove(id); + } +} diff --git a/apps/api/src/modules/admin-invoices/admin-invoices.module.ts b/apps/api/src/modules/admin-invoices/admin-invoices.module.ts new file mode 100644 index 0000000..e51f033 --- /dev/null +++ b/apps/api/src/modules/admin-invoices/admin-invoices.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminInvoicesController } from './admin-invoices.controller'; +import { AdminInvoicesService } from './admin-invoices.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminInvoicesController], + providers: [AdminInvoicesService], +}) +export class AdminInvoicesModule {} diff --git a/apps/api/src/modules/admin-invoices/admin-invoices.service.ts b/apps/api/src/modules/admin-invoices/admin-invoices.service.ts new file mode 100644 index 0000000..234b231 --- /dev/null +++ b/apps/api/src/modules/admin-invoices/admin-invoices.service.ts @@ -0,0 +1,245 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Branch stock allocations — legacy views/invoice/* ("فاکتور انتقال"), served by + * BranchController as `branch/product`: + * POST branch/product/table -> the grid + * POST branch/product -> create { branch_id, products: [id...] } + * GET branch/product/{id} -> one allocation + * DELETE branch/product/{id} -> delete + * + * Despite the screen being labelled an invoice, it is not a sales document: it + * records which products head office allocated to a branch, and who did it. + */ +@Injectable() +export class AdminInvoicesService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'note', + 'createdAt', + 'branchId', + 'branch.name', + 'allocatorAdminUser.fullName', + // legacy filter keys + 'name', + 'caj', + ]; + + private static readonly FIELD_ALIASES: Record = { + name: 'branch.name', + caj: 'createdAt', + 'allocator_user.name': 'allocatorAdminUser.fullName', + }; + + /** Legacy `POST branch/product/table`. */ + async list(query: TableQueryDto) { + this.table.assertNotExport(query); + + const mapField = (f?: string) => (f ? (AdminInvoicesService.FIELD_ALIASES[f] ?? f) : f); + const aliased: TableQueryDto = { + ...query, + filters: (query.filters ?? []).map((f) => ({ ...f, field: mapField(f.field) })), + order: query.order + ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` + : undefined, + }; + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['note', 'branch.name'], + allowed: AdminInvoicesService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.branchAllocation.findMany({ + where: where as Prisma.BranchAllocationWhereInput, + orderBy: orderBy as Prisma.BranchAllocationOrderByWithRelationInput, + skip, + take, + include: { + branch: true, + allocatorAdminUser: true, + items: { include: { product: true } }, + }, + }), + this.prisma.branchAllocation.count({ where: where as Prisma.BranchAllocationWhereInput }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + private toRow( + a: Prisma.BranchAllocationGetPayload<{ + include: { + branch: true; + allocatorAdminUser: true; + items: { include: { product: true } }; + }; + }>, + ) { + const sumWeight = a.items.reduce( + (sum, it) => sum.add(it.weightGrams ?? new Prisma.Decimal(0)), + new Prisma.Decimal(0), + ); + + return { + id: a.id, + // legacy column key is `name` — the receiving branch + name: a.branch.name, + branch_id: a.branchId, + branch: { id: a.branch.id, name: a.branch.name }, + sum_weight: sumWeight, + count_item: a.items.length, + note: a.note, + allocator_user: a.allocatorAdminUser + ? { id: a.allocatorAdminUser.id, name: a.allocatorAdminUser.fullName } + : null, + caj: a.createdAt, + products: a.items.map((it) => ({ + id: it.productId, + pid: it.productId, + title: it.product.title, + product_code: it.product.productCode, + grams: it.weightGrams, + })), + }; + } + + /** Legacy `GET branch/product/{id}`. */ + async findOne(id: string) { + const a = await this.prisma.branchAllocation.findUnique({ + where: { id }, + include: { + branch: true, + allocatorAdminUser: true, + items: { include: { product: true } }, + }, + }); + if (!a) throw new NotFoundException('فاکتور یافت نشد'); + return this.toRow(a); + } + + /** + * Legacy `POST branch/product` with `{ branch_id, products: [id...] }`. + * + * Each product's current lightest variant weight is snapshotted, so a later + * catalog edit does not silently rewrite what this document says was moved. + */ + async create(data: { branch_id?: string; products?: string[]; note?: string }, actorId: string) { + const branchId = String(data.branch_id ?? ''); + if (!branchId) throw new BadRequestException('شعبه انتخاب نشده است'); + + const branch = await this.prisma.branch.findUnique({ where: { id: branchId } }); + if (!branch) throw new NotFoundException('شعبه یافت نشد'); + + const productIds = [...new Set((data.products ?? []).map((p) => String(p)).filter(Boolean))]; + if (productIds.length === 0) throw new BadRequestException('هیچ محصولی انتخاب نشده است'); + + const products = await this.prisma.product.findMany({ + where: { id: { in: productIds } }, + include: { variants: { where: { isActive: true } } }, + }); + if (products.length !== productIds.length) { + throw new BadRequestException('برخی از محصولات انتخاب‌شده یافت نشدند'); + } + + const allocation = await this.prisma.branchAllocation.create({ + data: { + branchId, + allocatorAdminUserId: actorId, + note: data.note ?? null, + items: { + create: products.map((p) => ({ + productId: p.id, + weightGrams: p.variants.length + ? p.variants.reduce( + (min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min), + p.variants[0].rashWeightGrams, + ) + : null, + })), + }, + }, + }); + + return this.findOne(allocation.id); + } + + /** Legacy `PUT branch/product/{id}` — the edit screen replaces the product set. */ + async update(id: string, data: { branch_id?: string; products?: string[]; note?: string }) { + const existing = await this.prisma.branchAllocation.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('فاکتور یافت نشد'); + + if (data.branch_id) { + const branch = await this.prisma.branch.findUnique({ + where: { id: String(data.branch_id) }, + }); + if (!branch) throw new NotFoundException('شعبه یافت نشد'); + } + + if (Array.isArray(data.products)) { + const productIds = [...new Set(data.products.map((p) => String(p)).filter(Boolean))]; + if (productIds.length === 0) { + throw new BadRequestException('هیچ محصولی انتخاب نشده است'); + } + const products = await this.prisma.product.findMany({ + where: { id: { in: productIds } }, + include: { variants: { where: { isActive: true } } }, + }); + + await this.prisma.$transaction([ + this.prisma.branchAllocationItem.deleteMany({ where: { allocationId: id } }), + this.prisma.branchAllocationItem.createMany({ + data: products.map((p) => ({ + allocationId: id, + productId: p.id, + weightGrams: p.variants.length + ? p.variants.reduce( + (min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min), + p.variants[0].rashWeightGrams, + ) + : null, + })), + skipDuplicates: true, + }), + ]); + } + + await this.prisma.branchAllocation.update({ + where: { id }, + data: { + ...(data.branch_id ? { branchId: String(data.branch_id) } : {}), + ...(data.note !== undefined ? { note: data.note } : {}), + }, + }); + + return this.findOne(id); + } + + /** Legacy `DELETE branch/product/{id}`. Items cascade. */ + async remove(id: string) { + const a = await this.prisma.branchAllocation.findUnique({ where: { id } }); + if (!a) throw new NotFoundException('فاکتور یافت نشد'); + await this.prisma.branchAllocation.delete({ where: { id } }); + return { success: true }; + } + + /** Legacy `GET branch/list` — the branch picker for the invoice form. */ + async branchList() { + const rows = await this.prisma.branch.findMany({ + where: { isActive: true }, + orderBy: { name: 'asc' }, + select: { id: true, name: true }, + }); + return rows; + } +} diff --git a/apps/api/src/modules/admin-misc/admin-misc.controller.ts b/apps/api/src/modules/admin-misc/admin-misc.controller.ts new file mode 100644 index 0000000..5a6b594 --- /dev/null +++ b/apps/api/src/modules/admin-misc/admin-misc.controller.ts @@ -0,0 +1,183 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; +import { AdminMiscService } from './admin-misc.service'; +import { TableQueryDto } from '../admin-table/table-query.dto'; +import { CurrentAdmin, RequirePermission } from '../auth/guards'; +import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; + +/** + * Cities, SMS, notifications, newsletter, activity log and settings — the + * remaining admin screens. See AdminMiscService for the legacy endpoint map. + */ +@Controller('admin/misc') +export class AdminMiscController { + constructor(private readonly misc: AdminMiscService) {} + + // ── cities ──────────────────────────────────────────────────────── + @Post('cities/list') + @HttpCode(200) + @RequirePermission('cities.list') + listCities(@Body() query: TableQueryDto) { + return this.misc.listCities(query); + } + + @Post('cities') + @HttpCode(200) + @RequirePermission('cities.create') + createCity(@Body() data: { title?: string; name?: string; province?: string }) { + return this.misc.createCity(data); + } + + @Get('cities/percent/:id') + @RequirePermission('cities.list') + getCityPercent(@Param('id') id: string) { + return this.misc.getCityPercent(id, false); + } + + @Get('cities/percent-aux/:id') + @RequirePermission('cities.list') + getCityPercentAux(@Param('id') id: string) { + return this.misc.getCityPercent(id, true); + } + + @Put('cities/:id') + @RequirePermission('cities.list.view.edit') + updateCity(@Param('id') id: string, @Body() data: Record) { + return this.misc.updateCity(id, data); + } + + @Delete('cities/:id') + @RequirePermission('cities.list.remove') + removeCity(@Param('id') id: string) { + return this.misc.removeCity(id); + } + + // ── sms ─────────────────────────────────────────────────────────── + @Post('sms/list') + @HttpCode(200) + @RequirePermission('sms.list') + listSms(@Body() query: TableQueryDto) { + return this.misc.listSms(query); + } + + @Post('sms') + @HttpCode(200) + @RequirePermission('sms.create') + sendSms( + @Body() data: { type?: string | number; mobile?: string; text?: string }, + @CurrentAdmin() admin: AuthenticatedAdmin, + ) { + return this.misc.sendSms(data, admin.id); + } + + // ── notifications ───────────────────────────────────────────────── + @Post('notify/list') + @HttpCode(200) + @RequirePermission('notify.me') + listNotifications(@Body() query: TableQueryDto) { + return this.misc.listNotifications(query); + } + + @Post('notify') + @HttpCode(200) + @RequirePermission('notify.create') + createNotification( + @Body() data: { title?: string; text?: string; body?: string; type?: string | number }, + @CurrentAdmin() admin: AuthenticatedAdmin, + ) { + return this.misc.createNotification(data, admin.id); + } + + @Post('notify/customer') + @HttpCode(200) + @RequirePermission('notify.create') + notifyCustomer( + @Body() data: { customer_id?: string; title?: string; text?: string; body?: string }, + @CurrentAdmin() admin: AuthenticatedAdmin, + ) { + return this.misc.notifyCustomer(data, admin.id); + } + + @Get('notify/read/:id') + @RequirePermission('notify.me') + markRead(@Param('id') id: string) { + return this.misc.markNotificationRead(id); + } + + @Get('notify/:id') + @RequirePermission('notify.me') + findNotification(@Param('id') id: string) { + return this.misc.findNotification(id); + } + + // ── newsletter ──────────────────────────────────────────────────── + @Post('newsletter/list') + @HttpCode(200) + @RequirePermission('newsletters') + listNewsletter(@Body() query: TableQueryDto) { + return this.misc.listNewsletter(query); + } + + // ── activity log ────────────────────────────────────────────────── + @Get('log/customers') + @RequirePermission('statics') + logCustomers() { + return this.misc.logCustomers(); + } + + @Get('log/customer/:id') + @RequirePermission('statics') + customerLog(@Param('id') id: string) { + return this.misc.customerLog(id); + } + + // ── settings + brands ("مدیریت ثابت ها") ───────────────────────── + @Get('dashboard-links') + @RequirePermission('config.report') + dashboardLinks() { + return this.misc.dashboardLinks(); + } + + @Get('const') + @RequirePermission('config.statics') + listConsts() { + return this.misc.listConsts(); + } + + @Post('brands') + @HttpCode(200) + @RequirePermission('config.statics.brand.create') + createBrand(@Body() body: { title: string }) { + return this.misc.createBrand(body.title); + } + + @Put('brands/:id') + @RequirePermission('config.statics.brand.edit') + updateBrand(@Param('id') id: string, @Body() body: { title: string }) { + return this.misc.updateBrand(id, body.title); + } + + @Delete('brands/:id') + @RequirePermission('config.statics.brand.remove') + removeBrand(@Param('id') id: string) { + return this.misc.removeBrand(id); + } + + @Get('const/:key') + @RequirePermission('config.statics') + getConst(@Param('key') key: string) { + return this.misc.getConst(key); + } + + @Put('const/:key') + @RequirePermission('config.statics') + setConst(@Param('key') key: string, @Body() body: Record) { + // Legacy posts either a bare value under `val` or a whole object. + return this.misc.setConst(key, body.val !== undefined ? body.val : body); + } + + @Delete('const/:key') + @RequirePermission('config.statics') + removeConst(@Param('key') key: string) { + return this.misc.removeConst(key); + } +} diff --git a/apps/api/src/modules/admin-misc/admin-misc.module.ts b/apps/api/src/modules/admin-misc/admin-misc.module.ts new file mode 100644 index 0000000..aa9dcad --- /dev/null +++ b/apps/api/src/modules/admin-misc/admin-misc.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminMiscController } from './admin-misc.controller'; +import { AdminMiscService } from './admin-misc.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminMiscController], + providers: [AdminMiscService], +}) +export class AdminMiscModule {} diff --git a/apps/api/src/modules/admin-misc/admin-misc.service.ts b/apps/api/src/modules/admin-misc/admin-misc.service.ts new file mode 100644 index 0000000..332d753 --- /dev/null +++ b/apps/api/src/modules/admin-misc/admin-misc.service.ts @@ -0,0 +1,524 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { MessageAudience, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * The remaining admin screens — legacy views/city, views/sms, views/notify, + * views/newsletters, views/statics and views/manage/*: + * + * cities, cities/{id}, cities/get-percent/{id}, cities/get-percent-aux/{id} + * sms/table, sms + * notify, notify/customer, notify/{id}, notify/read/{id} + * ddf -> the newsletter list (legacy's own opaque name) + * log/list/customer, log/ago/{id} + * const, const/{key}, const/by-id/{id} + * dashboard/link + */ +@Injectable() +export class AdminMiscService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + // ── Cities ───────────────────────────────────────────────────────── + + /** Legacy `POST cities/list` grid. */ + async listCities(query: TableQueryDto) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['name', 'province'], + allowed: ['id', 'name', 'province', 'percent', 'percentAux'], + defaultOrderBy: { name: 'asc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.city.findMany({ + where: where as Prisma.CityWhereInput, + orderBy: orderBy as Prisma.CityOrderByWithRelationInput, + skip, + take, + include: { _count: { select: { customers: true, branches: true } } }, + }), + this.prisma.city.count({ where: where as Prisma.CityWhereInput }), + ]); + + return this.table.respond( + rows.map((c) => ({ + id: c.id, + title: c.name, + name: c.name, + province: c.province, + percent: c.percent, + percent_aux: c.percentAux, + customers_count: c._count.customers, + branches_count: c._count.branches, + })), + total, + query, + page, + limit, + ); + } + + /** Legacy `POST cities`. */ + async createCity(data: { title?: string; name?: string; province?: string }) { + const name = String(data.title ?? data.name ?? '').trim(); + if (!name) throw new BadRequestException('نام شهر الزامی است'); + return this.prisma.city.create({ data: { name, province: data.province ?? null } }); + } + + /** Legacy `PUT cities/{id}` — the grid's inline edit also sets the percents. */ + async updateCity( + id: string, + data: { title?: string; name?: string; province?: string; percent?: unknown; percent_aux?: unknown }, + ) { + const existing = await this.prisma.city.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('شهر یافت نشد'); + + const patch: Prisma.CityUpdateInput = {}; + if (data.title !== undefined || data.name !== undefined) { + patch.name = String(data.title ?? data.name); + } + if (data.province !== undefined) patch.province = data.province ?? null; + if (data.percent !== undefined) { + patch.percent = data.percent === null ? null : new Prisma.Decimal(String(data.percent)); + } + if (data.percent_aux !== undefined) { + patch.percentAux = + data.percent_aux === null ? null : new Prisma.Decimal(String(data.percent_aux)); + } + + return this.prisma.city.update({ where: { id }, data: patch }); + } + + /** + * Legacy `DELETE cities/{id}`. + * + * Refuses while customers or branches still point at the city — legacy deleted + * regardless, orphaning those rows. + */ + async removeCity(id: string) { + const c = await this.prisma.city.findUnique({ + where: { id }, + include: { _count: { select: { customers: true, branches: true, addresses: true } } }, + }); + if (!c) throw new NotFoundException('شهر یافت نشد'); + + const refs = c._count.customers + c._count.branches + c._count.addresses; + if (refs > 0) { + throw new BadRequestException( + `حذف این شهر ممکن نیست، چون ${refs} مشتری، شعبه یا آدرس به آن ارجاع دارد.`, + ); + } + await this.prisma.city.delete({ where: { id } }); + return { success: true }; + } + + /** Legacy `GET cities/get-percent/{id}` — returns the bare number. */ + async getCityPercent(id: string, aux: boolean) { + const c = await this.prisma.city.findUnique({ where: { id } }); + if (!c) throw new NotFoundException('شهر یافت نشد'); + return aux ? c.percentAux : c.percent; + } + + // ── SMS ──────────────────────────────────────────────────────────── + + /** Legacy `POST sms/table` — the sent-message log. */ + async listSms(query: TableQueryDto) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['body', 'recipients'], + allowed: ['id', 'audience', 'body', 'createdAt', 'sentCount'], + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.smsMessage.findMany({ + where: where as Prisma.SmsMessageWhereInput, + orderBy: orderBy as Prisma.SmsMessageOrderByWithRelationInput, + skip, + take, + include: { sentByAdminUser: true }, + }), + this.prisma.smsMessage.count({ where: where as Prisma.SmsMessageWhereInput }), + ]); + + return this.table.respond( + rows.map((s) => ({ + id: s.id, + type: s.audience, + text: s.body, + mobile: s.recipients, + sent_count: s.sentCount, + sender: s.sentByAdminUser?.fullName ?? null, + caj: s.createdAt, + })), + total, + query, + page, + limit, + ); + } + + /** + * Legacy `POST sms` — records the send. + * + * NOTE no SMS gateway is wired up: legacy handed the message to an external + * provider, and this port has no credentials for one. The message is stored + * with the audience it resolved to, so the log is accurate, but nothing is + * actually transmitted. Flagged in docs/ADMIN-PORT-PLAN.md. + */ + async sendSms( + data: { type?: string | number; mobile?: string; text?: string }, + actorId: string, + ) { + const body = String(data.text ?? '').trim(); + if (!body) throw new BadRequestException('متن پیامک الزامی است'); + + const audience = this.resolveAudience(data.type); + const recipients = await this.resolveRecipients(audience, data.mobile); + + return this.prisma.smsMessage.create({ + data: { + audience, + recipients: recipients.join(','), + body, + sentCount: recipients.length, + sentByAdminUserId: actorId, + }, + }); + } + + /** + * Legacy's numeric audience codes, read off views/sms/create.vue where each + * option is gated by its own rid: + * -1 sms.create.single | 0 sms.create.all | 1 sms.create.reagent + * 2 sms.create.omde | 9 sms.create.multiple + * (there is no option for sms.create.single-customer "tak" in the legacy + * markup even though the rid exists — preserved as unreachable.) + */ + private resolveAudience(type?: string | number): MessageAudience { + const byCode: Record = { + '-1': MessageAudience.SINGLE_NUMBER, + '0': MessageAudience.ALL_CUSTOMERS, + '1': MessageAudience.SALES_AGENTS, + '2': MessageAudience.WHOLESALE_CUSTOMERS, + '9': MessageAudience.MULTIPLE_NUMBERS, + }; + const byName: Record = { + single: MessageAudience.SINGLE_NUMBER, + multiple: MessageAudience.MULTIPLE_NUMBERS, + all: MessageAudience.ALL_CUSTOMERS, + omde: MessageAudience.WHOLESALE_CUSTOMERS, + tak: MessageAudience.RETAIL_CUSTOMERS, + reagent: MessageAudience.SALES_AGENTS, + }; + const key = String(type ?? ''); + return byCode[key] ?? byName[key] ?? MessageAudience.SINGLE_NUMBER; + } + + /** Expands an audience into the actual phone numbers it covers. */ + private async resolveRecipients( + audience: MessageAudience, + explicit?: string, + ): Promise { + if ( + audience === MessageAudience.SINGLE_NUMBER || + audience === MessageAudience.MULTIPLE_NUMBERS + ) { + const list = String(explicit ?? '') + .split(/[,\s\n]+/) + .map((x) => x.trim()) + .filter(Boolean); + if (list.length === 0) throw new BadRequestException('شماره موبایل وارد نشده است'); + return list; + } + + if (audience === MessageAudience.SALES_AGENTS) { + const rows = await this.prisma.adminUser.findMany({ + where: { role: 'SALES_EXPERT', isActive: true }, + select: { mobile: true }, + }); + return rows.map((r) => r.mobile); + } + + // The customer audiences. WHOLESALE/RETAIL had a Customer.type column in + // legacy that the schema redesign removed, so both currently resolve to all + // active customers — the same gap recorded for the omde/tak lists. + const rows = await this.prisma.customer.findMany({ + where: { isActive: true }, + select: { mobile: true }, + }); + return rows.map((r) => r.mobile); + } + + // ── Notifications ────────────────────────────────────────────────── + + /** Legacy `POST notify` — broadcast to an audience. */ + async createNotification( + data: { title?: string; text?: string; body?: string; type?: string | number }, + actorId: string, + ) { + const title = String(data.title ?? '').trim(); + const body = String(data.body ?? data.text ?? '').trim(); + if (!title) throw new BadRequestException('عنوان نوتیفیکیشن الزامی است'); + if (!body) throw new BadRequestException('متن نوتیفیکیشن الزامی است'); + + const audience = this.resolveAudience(data.type); + const customers = await this.prisma.customer.findMany({ + where: { isActive: true }, + select: { id: true }, + }); + + return this.prisma.notification.create({ + data: { + title, + body, + audience, + sentByAdminUserId: actorId, + recipients: { create: customers.map((c) => ({ customerId: c.id })) }, + }, + include: { _count: { select: { recipients: true } } }, + }); + } + + /** Legacy `POST notify/customer` — send to one named customer. */ + async notifyCustomer( + data: { customer_id?: string; title?: string; text?: string; body?: string }, + actorId: string, + ) { + const customerId = String(data.customer_id ?? ''); + if (!customerId) throw new BadRequestException('مشتری انتخاب نشده است'); + + const customer = await this.prisma.customer.findUnique({ where: { id: customerId } }); + if (!customer) throw new NotFoundException('مشتری یافت نشد'); + + const title = String(data.title ?? '').trim(); + const body = String(data.body ?? data.text ?? '').trim(); + if (!title || !body) throw new BadRequestException('عنوان و متن الزامی است'); + + return this.prisma.notification.create({ + data: { + title, + body, + audience: MessageAudience.SINGLE_NUMBER, + sentByAdminUserId: actorId, + recipients: { create: [{ customerId }] }, + }, + }); + } + + /** Legacy `GET notify/{id}` — one notification with its delivery state. */ + async findNotification(id: string) { + const n = await this.prisma.notification.findUnique({ + where: { id }, + include: { + sentByAdminUser: true, + recipients: { include: { customer: true } }, + }, + }); + if (!n) throw new NotFoundException('نوتیفیکیشن یافت نشد'); + + return { + id: n.id, + title: n.title, + body: n.body, + text: n.body, + audience: n.audience, + sender: n.sentByAdminUser?.fullName ?? null, + caj: n.createdAt, + recipients: n.recipients.map((r) => ({ + id: r.id, + customer: { id: r.customer.id, name: r.customer.fullName, mobile: r.customer.mobile }, + read_at: r.readAt, + is_read: Boolean(r.readAt), + })), + read_count: n.recipients.filter((r) => r.readAt).length, + total_count: n.recipients.length, + }; + } + + /** Legacy `POST notify/list` — the notification log. */ + async listNotifications(query: TableQueryDto) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['title', 'body'], + allowed: ['id', 'title', 'body', 'audience', 'createdAt'], + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.notification.findMany({ + where: where as Prisma.NotificationWhereInput, + orderBy: orderBy as Prisma.NotificationOrderByWithRelationInput, + skip, + take, + include: { + sentByAdminUser: true, + _count: { select: { recipients: true } }, + }, + }), + this.prisma.notification.count({ where: where as Prisma.NotificationWhereInput }), + ]); + + return this.table.respond( + rows.map((n) => ({ + id: n.id, + title: n.title, + text: n.body, + audience: n.audience, + sender: n.sentByAdminUser?.fullName ?? null, + total_count: n._count.recipients, + caj: n.createdAt, + })), + total, + query, + page, + limit, + ); + } + + /** Legacy `GET notify/read/{id}` — mark one recipient row read. */ + async markNotificationRead(recipientId: string) { + const r = await this.prisma.notificationRecipient.findUnique({ where: { id: recipientId } }); + if (!r) throw new NotFoundException('یافت نشد'); + if (r.readAt) return r; + return this.prisma.notificationRecipient.update({ + where: { id: recipientId }, + data: { readAt: new Date() }, + }); + } + + // ── Newsletter ───────────────────────────────────────────────────── + + /** Legacy `POST ddf` — the newsletter signup list (legacy's own endpoint name). */ + async listNewsletter(query: TableQueryDto) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['email', 'mobile'], + allowed: ['id', 'email', 'mobile', 'createdAt'], + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.newsletterSubscriber.findMany({ + where: where as Prisma.NewsletterSubscriberWhereInput, + orderBy: orderBy as Prisma.NewsletterSubscriberOrderByWithRelationInput, + skip, + take, + }), + this.prisma.newsletterSubscriber.count({ + where: where as Prisma.NewsletterSubscriberWhereInput, + }), + ]); + + return this.table.respond( + rows.map((n) => ({ id: n.id, email: n.email, mobile: n.mobile, caj: n.createdAt })), + total, + query, + page, + limit, + ); + } + + // ── Activity log ("آمار") ───────────────────────────────────────── + + /** Legacy `GET log/list/customer` — customers that have any activity. */ + async logCustomers() { + const rows = await this.prisma.customer.findMany({ + where: { activityLogs: { some: {} } }, + orderBy: { fullName: 'asc' }, + select: { id: true, fullName: true, mobile: true }, + }); + return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile })); + } + + /** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */ + async customerLog(customerId: string) { + const customer = await this.prisma.customer.findUnique({ where: { id: customerId } }); + if (!customer) throw new NotFoundException('مشتری یافت نشد'); + + const rows = await this.prisma.customerActivityLog.findMany({ + where: { customerId }, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + return rows.map((l) => ({ + id: l.id, + action: l.action, + detail: l.detail, + caj: l.createdAt, + })); + } + + // ── Settings ("مدیریت ثابت ها") ─────────────────────────────────── + + /** Legacy `GET const/{key}`. */ + async getConst(key: string) { + const row = await this.prisma.setting.findUnique({ where: { key } }); + return row?.value ?? null; + } + + /** Legacy `POST const` / `PUT const/{key}`. */ + async setConst(key: string, value: unknown) { + const row = await this.prisma.setting.upsert({ + where: { key }, + update: { value: value as Prisma.InputJsonValue }, + create: { key, value: value as Prisma.InputJsonValue }, + }); + return row.value; + } + + async listConsts() { + const rows = await this.prisma.setting.findMany({ orderBy: { key: 'asc' } }); + return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt })); + } + + async removeConst(key: string) { + const row = await this.prisma.setting.findUnique({ where: { key } }); + if (!row) throw new NotFoundException('تنظیم یافت نشد'); + await this.prisma.setting.delete({ where: { key } }); + return { success: true }; + } + + // ── Brands (managed from the same "ثابت ها" screen) ─────────────── + + async createBrand(title: string) { + if (!title?.trim()) throw new BadRequestException('عنوان برند الزامی است'); + return this.prisma.brand.create({ data: { title: title.trim() } }); + } + + async updateBrand(id: string, title: string) { + const b = await this.prisma.brand.findUnique({ where: { id } }); + if (!b) throw new NotFoundException('برند یافت نشد'); + return this.prisma.brand.update({ where: { id }, data: { title } }); + } + + async removeBrand(id: string) { + const b = await this.prisma.brand.findUnique({ + where: { id }, + include: { _count: { select: { products: true } } }, + }); + if (!b) throw new NotFoundException('برند یافت نشد'); + if (b._count.products > 0) { + throw new BadRequestException( + `حذف این برند ممکن نیست، چون ${b._count.products} محصول به آن ارجاع دارد.`, + ); + } + await this.prisma.brand.delete({ where: { id } }); + return { success: true }; + } + + /** Legacy `GET dashboard/link` — the report shortcuts on views/manage/report. */ + async dashboardLinks() { + const row = await this.prisma.setting.findUnique({ where: { key: 'dashboard.links' } }); + return row?.value ?? []; + } +} diff --git a/apps/api/src/modules/admin-orders/admin-orders.controller.ts b/apps/api/src/modules/admin-orders/admin-orders.controller.ts new file mode 100644 index 0000000..bfabb88 --- /dev/null +++ b/apps/api/src/modules/admin-orders/admin-orders.controller.ts @@ -0,0 +1,109 @@ +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 type { AuthenticatedAdmin } from '../auth/jwt.strategy'; + +/** + * Legacy endpoints (henza_server routes/admin.php): + * POST single-order -> the single-sale order grid + * GET order/{id} -> order detail + status timeline + * + * Each route is gated by the same rid the legacy router used in `meta.role`, + * so the API enforces what the menu already hides. + */ +@Controller('admin/orders') +export class AdminOrdersController { + constructor(private readonly orders: AdminOrdersService) {} + + /** Legacy POST single-order. POST, not GET, because XTable posts its filters. */ + @Post('list') + @HttpCode(200) + @RequirePermission('order.list') + list(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) { + /** + * Branch scoping, ported from legacy AdminController::getSingleOrders: + * + * if (!is_null(auth()->user()->branch_id)) { + * $records->where('branch_id', auth()->user()->branch_id); + * } + * + * i.e. ANY user carrying a branch_id sees only that branch. Faithful to legacy. + * + * Consequence worth knowing: the seed gives head-office admins a home branch, + * so under this rule they see only that branch's orders. That is legacy's real + * behaviour, not a defect introduced here — head office was expected to have a + * null branch_id. Recorded in docs/ADMIN-PORT-PLAN.md. + */ + return this.orders.list(query, admin.branchId ?? undefined); + } + + /** Legacy POST major/order/table — the wholesale grid. */ + @Post('wholesale/list') + @HttpCode(200) + @RequirePermission('order.omde') + listWholesale(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) { + return this.orders.listWholesale(query, admin.branchId ?? undefined); + } + + /** Legacy POST order-tamin — orders that reached the sourcing stage. */ + @Post('tamin/list') + @HttpCode(200) + @RequirePermission('order.tamin') + listTamin(@Body() query: TableQueryDto, @CurrentAdmin() admin: AuthenticatedAdmin) { + return this.orders.listTamin(query, admin.branchId ?? undefined); + } + + /** Legacy POST delete-order. */ + @Post('delete') + @HttpCode(200) + @RequirePermission('order.omde') + remove(@Body() body: { id: string }) { + return this.orders.remove(body.id); + } + + /** Legacy POST /order from the admin order builder (views/order/create.vue). */ + @Post('create') + @HttpCode(200) + @RequirePermission('order.create') + createFromAdmin( + @Body() + body: { + customer_id: string; + order_items: { vid: string; qty: number; percent?: number; discount?: number }[]; + sts?: number | string; + note?: string; + }, + @CurrentAdmin() admin: AuthenticatedAdmin, + ) { + return this.orders.createFromAdmin(body, admin.id); + } + + /** Legacy GET order/type. */ + @Get('type-options') + @RequirePermission('order.create') + typeOptions() { + return this.orders.typeOptions(); + } + + @Get('status-options') + @RequirePermission('order.list') + statusOptions() { + return this.orders.statusOptions(); + } + + /** Legacy POST single/order-sts/edit. */ + @Post('status') + @HttpCode(200) + @RequirePermission('order.list') + setStatus(@Body() dto: UpdateOrderStatusDto, @CurrentAdmin() admin: AuthenticatedAdmin) { + return this.orders.setStatus(dto, admin.id); + } + + @Get(':id') + @RequirePermission('order.list') + findOne(@Param('id') id: string) { + return this.orders.findOne(id); + } +} diff --git a/apps/api/src/modules/admin-orders/admin-orders.module.ts b/apps/api/src/modules/admin-orders/admin-orders.module.ts new file mode 100644 index 0000000..bf089ed --- /dev/null +++ b/apps/api/src/modules/admin-orders/admin-orders.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { OrdersModule } from '../orders/orders.module'; +import { AdminOrdersController } from './admin-orders.controller'; +import { AdminOrdersService } from './admin-orders.service'; + +@Module({ + imports: [PrismaModule, OrdersModule], + controllers: [AdminOrdersController], + providers: [AdminOrdersService], +}) +export class AdminOrdersModule {} diff --git a/apps/api/src/modules/admin-orders/admin-orders.service.ts b/apps/api/src/modules/admin-orders/admin-orders.service.ts new file mode 100644 index 0000000..3b43734 --- /dev/null +++ b/apps/api/src/modules/admin-orders/admin-orders.service.ts @@ -0,0 +1,406 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { OrderChannel, OrderStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; +import { OrdersService } from '../orders/orders.service'; +import type { UpdateOrderStatusDto } from './update-status.dto'; + +/** + * Farsi status labels, matching legacy's `sts_str` column. + * + * Legacy stored status as an integer `sts` and the server derived the string via + * Order::$SELECT_STS_STR. The new schema uses an enum, so the mapping is explicit. + * Debt is recognised at sts>4 (DELIVERED_TO_AGENT) — see the schema notes. + */ +const STATUS_LABELS: Record = { + SUSPENDED: 'معلق (سقف اعتبار)', + PLACED: 'ثبت شده', + ADMIN_APPROVED: 'تایید مدیر', + SOURCING: 'در حال تامین', + PREPARING: 'در حال آماده‌سازی', + DELIVERED_TO_AGENT: 'تحویل به نماینده', + DELIVERED_TO_CUSTOMER: 'تحویل به مشتری', + CANCELLED: 'لغو شده', +}; + +/** Order of the status timeline shown in the legacy "وضعیت سفارش" modal. */ +const STATUS_FLOW: OrderStatus[] = [ + OrderStatus.PLACED, + OrderStatus.ADMIN_APPROVED, + OrderStatus.SOURCING, + OrderStatus.PREPARING, + OrderStatus.DELIVERED_TO_AGENT, + OrderStatus.DELIVERED_TO_CUSTOMER, +]; + +@Injectable() +export class AdminOrdersService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + private readonly orders: OrdersService, + ) {} + + /** Fields the XTable filter bar and sorter may reference. */ + private static readonly ALLOWED = [ + 'id', + 'orderNumber', + 'status', + 'createdAt', + 'updatedAt', + 'totalGoldWeightGrams', + 'flatRialCharge', + 'isSuspendedOverCreditLimit', + 'customer.fullName', + 'customer.mobile', + 'branch.name', + ]; + + /** + * Legacy `POST single-order` — the order list grid. + * + * `branchId` narrows the result to one branch, which is how the branch-mode + * build of the panel scopes its list. + */ + async list(query: TableQueryDto, branchId?: string, channel: OrderChannel = OrderChannel.RETAIL) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'], + allowed: AdminOrdersService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const scoped: Prisma.OrderWhereInput = { + AND: [ + where as Prisma.OrderWhereInput, + { channel }, + ...(branchId ? [{ branchId }] : []), + ], + }; + + const [rows, total] = await Promise.all([ + this.prisma.order.findMany({ + where: scoped, + orderBy: orderBy as Prisma.OrderOrderByWithRelationInput, + skip, + take, + include: { + customer: true, + branch: true, + items: { include: { productVariant: { include: { product: true } } } }, + }, + }), + this.prisma.order.count({ where: scoped }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + /** + * Shapes a row the way the ported list view reads it. + * + * Legacy field names are preserved (`sts_str`, `caj`, `total_price`, `items`) + * so the ported template bindings keep working unchanged. + */ + private toRow(o: Prisma.OrderGetPayload<{ + include: { + customer: true; + branch: true; + items: { include: { productVariant: { include: { product: true } } } }; + }; + }>) { + return { + id: o.id, + orderNumber: o.orderNumber, + status: o.status, + sts_str: STATUS_LABELS[o.status], + caj: o.createdAt, + uaj: o.updatedAt, + total_price: o.flatRialCharge, + totalGoldWeightGrams: o.totalGoldWeightGrams, + wageWeightGrams: o.wageWeightGrams, + isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit, + deliveredAt: o.deliveredAt, + branch_id: o.branchId, + branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null, + customer: { + id: o.customer.id, + // Legacy split names into fname/lname; the new schema has one fullName. + // Both are exposed so ported templates binding either keep working. + fname: o.customer.fullName, + lname: '', + fullName: o.customer.fullName, + phone: o.customer.mobile, + }, + items: o.items.map((it) => ({ + id: it.id, + title: it.productVariant.product.title, + variant_title: it.productVariant.title, + qty: it.quantity, + grams: it.computedWeightGrams, + rash: it.rashWeightGramsSnapshot, + })), + }; + } + + /** + * Legacy `POST major/order/table` — the wholesale (عمده) order grid. + * + * Legacy kept the two sales channels in SEPARATE tables: `orders_orders` + * (SingleCustomerOrders, served by AdminController::getSingleOrders) and + * `orders` (Order, served by MajorOrderController::index). Neither controller + * applied a type filter because the table itself was the discriminator, so the + * two lists never overlapped. + * + * The redesigned schema has one Order model, so `Order.channel` carries that + * distinction instead and each list filters on it. + */ + async listWholesale(query: TableQueryDto, branchId?: string) { + return this.list(query, branchId, OrderChannel.WHOLESALE); + } + + /** + * Legacy `POST order-tamin` — MajorOrderController::getTaminOrders. + * + * Legacy narrowed to orders whose activity contained Order::$STS[3], i.e. those + * that have reached the sourcing stage. The equivalent here is a status event + * recording a transition into SOURCING. + */ + async listTamin(query: TableQueryDto, branchId?: string) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'], + allowed: AdminOrdersService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const conditions: Prisma.OrderWhereInput[] = [ + where as Prisma.OrderWhereInput, + { statusEvents: { some: { toStatus: OrderStatus.SOURCING } } }, + ]; + if (branchId) conditions.push({ branchId }); + + const scoped: Prisma.OrderWhereInput = { AND: conditions }; + + const [rows, total] = await Promise.all([ + this.prisma.order.findMany({ + where: scoped, + orderBy: orderBy as Prisma.OrderOrderByWithRelationInput, + skip, + take, + include: { + customer: true, + branch: true, + items: { include: { productVariant: { include: { product: true } } } }, + }, + }), + this.prisma.order.count({ where: scoped }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + /** Legacy `POST delete-order` — remove a wholesale order. */ + async remove(id: string) { + const order = await this.prisma.order.findUnique({ + where: { id }, + include: { ledgerEntries: true }, + }); + if (!order) throw new NotFoundException('سفارش یافت نشد'); + + // Refuses once debt has been posted: deleting the order would orphan the + // ledger entries and leave the customer permanently in debt. Legacy had no + // such guard. Recorded in docs/ADMIN-PORT-PLAN.md. + if (order.ledgerEntries.length > 0) { + throw new BadRequestException( + 'حذف این سفارش ممکن نیست، چون برای آن سند حسابداری ثبت شده است.', + ); + } + + await this.prisma.$transaction([ + this.prisma.orderStatusEvent.deleteMany({ where: { orderId: id } }), + this.prisma.orderItem.deleteMany({ where: { orderId: id } }), + this.prisma.order.delete({ where: { id } }), + ]); + return { success: true }; + } + + /** Legacy `GET order/:id` detail, including the status timeline. */ + async findOne(id: string) { + const order = await this.prisma.order.findUnique({ + where: { id }, + include: { + customer: true, + branch: true, + items: { include: { productVariant: { include: { product: true } } } }, + statusEvents: { include: { actorAdminUser: true }, orderBy: { createdAt: 'asc' } }, + }, + }); + if (!order) throw new NotFoundException('سفارش یافت نشد'); + + const reached = new Set(order.statusEvents.map((e) => e.toStatus)); + + return { + ...this.toRow(order), + // legacy `activity`: one entry per step, `proceed` marking those reached + activity: STATUS_FLOW.map((s) => { + const event = order.statusEvents.find((e) => e.toStatus === s); + return { + status: s, + title: STATUS_LABELS[s], + proceed: reached.has(s), + uaj: event?.createdAt ?? null, + note: event?.note ?? '', + }; + }), + }; + } + + /** Farsi labels for the status dropdowns. */ + statusOptions() { + return Object.entries(STATUS_LABELS).map(([value, title]) => ({ value, title })); + } + + /** + * Legacy `GET order/type` — the "نوع فروش" dropdown. + * Legacy Order::$TYPES = [1 => 'ثبت سفارش توسط مسئول فروش', 2 => 'ثبت مشتری']. + */ + typeOptions() { + return { 1: 'ثبت سفارش توسط مسئول فروش', 2: 'ثبت مشتری' }; + } + + /** + * Legacy `POST /order` from views/order/create.vue — the admin order builder. + * + * Delegates to OrdersService.createOrder, which owns the weight formula + * w = rash * (1 + (wagePercent - discountPercent)/100) + * and the credit-limit gate that suspends rather than rejects an over-limit + * order. Reimplementing either here would let the two paths drift. + * + * Legacy also posted total_price / total_weight / gold_price_original computed + * in the BROWSER. Those are ignored: the server recomputes them from the + * variant snapshots, so a tampered or stale client total cannot alter a debt. + */ + async createFromAdmin( + payload: { + customer_id: string; + order_items: { + vid: string; + qty: number; + percent?: number; + discount?: number; + rial_input?: number; + }[]; + sts?: number | string; + note?: string; + }, + actorAdminUserId: string, + ) { + if (!payload.customer_id) throw new BadRequestException('مشتری انتخاب نشده است'); + if (!Array.isArray(payload.order_items) || payload.order_items.length === 0) { + throw new BadRequestException('سبد سفارش خالی است'); + } + + const items = await Promise.all( + payload.order_items.map(async (it) => { + const variant = await this.prisma.productVariant.findUnique({ where: { id: it.vid } }); + if (!variant) throw new NotFoundException(`تنوع محصول یافت نشد: ${it.vid}`); + return { + productVariantId: it.vid, + quantity: Number(it.qty) || 1, + // Fall back to the variant's own default wage when the builder did not + // negotiate a different one. + wagePercent: Number(it.percent ?? variant.defaultWagePercent), + discountPercent: Number(it.discount ?? 0), + flatRialCharge: Number(it.rial_input ?? 0), + }; + }), + ); + + const order = await this.orders.createOrder({ + customerId: payload.customer_id, + items, + // The admin builder serves the omde (major/*) flow, so orders it creates + // belong to the wholesale channel — legacy wrote them to the `orders` + // table, which MajorOrderController::index listed. + channel: OrderChannel.WHOLESALE, + }); + + // legacy `sts`: advance the new order to the chosen delivery status. + const target = this.statusFromLegacyCode(payload.sts); + if (target && target !== OrderStatus.PLACED) { + await this.orders.updateStatus(order.id, target, actorAdminUserId, payload.note); + } + + return this.findOne(order.id); + } + + /** + * Legacy delivery-status codes from create.vue's `sts` map: + * 1 ثبت سفارش | 2 تایید مدیر سیستم | 3 در حال تامین + * 4 در حال آماده سازی | 5 ارسال | 6 تحویل مشتری + */ + private statusFromLegacyCode(code?: number | string): OrderStatus | undefined { + const map: Record = { + 1: OrderStatus.PLACED, + 2: OrderStatus.ADMIN_APPROVED, + 3: OrderStatus.SOURCING, + 4: OrderStatus.PREPARING, + 5: OrderStatus.DELIVERED_TO_AGENT, + 6: OrderStatus.DELIVERED_TO_CUSTOMER, + }; + return code === undefined ? undefined : map[Number(code)]; + } + + /** + * Legacy `POST single/order-sts/edit` — advance or remove a status. + * + * Setting a status delegates to OrdersService.updateStatus rather than writing + * the row here, because that method also posts the gold debt to the ledger at + * DELIVERED_TO_AGENT. Duplicating the update would silently skip that. + */ + async setStatus(dto: UpdateOrderStatusDto, actorAdminUserId: string) { + if (dto.remove) { + return this.removeStatusEvent(dto.orderId, dto.status); + } + return this.orders.updateStatus(dto.orderId, dto.status, actorAdminUserId, dto.note); + } + + /** + * Legacy's `remove: true` branch: drop a recorded status event. + * + * Deliberately refuses to remove DELIVERED_TO_AGENT. In legacy, deleting that + * row left the posted debt ledger entries behind, so the customer stayed in + * debt for an order no longer marked delivered. Rather than reproduce a + * data-corrupting path, this rejects and explains — recorded in + * docs/ADMIN-PORT-PLAN.md. + */ + private async removeStatusEvent(orderId: string, status: OrderStatus) { + if (status === OrderStatus.DELIVERED_TO_AGENT) { + throw new BadRequestException( + 'حذف وضعیت «تحویل به نماینده» ممکن نیست، چون بدهی طلا در همین مرحله ثبت شده است.', + ); + } + + const event = await this.prisma.orderStatusEvent.findFirst({ + where: { orderId, toStatus: status }, + orderBy: { createdAt: 'desc' }, + }); + if (!event) throw new NotFoundException('وضعیت مورد نظر یافت نشد'); + + await this.prisma.orderStatusEvent.delete({ where: { id: event.id } }); + + // Roll the order back to whatever status is now the latest recorded one. + const latest = await this.prisma.orderStatusEvent.findFirst({ + where: { orderId }, + orderBy: { createdAt: 'desc' }, + }); + return this.prisma.order.update({ + where: { id: orderId }, + data: { status: latest?.toStatus ?? OrderStatus.PLACED }, + }); + } +} diff --git a/apps/api/src/modules/admin-orders/update-status.dto.ts b/apps/api/src/modules/admin-orders/update-status.dto.ts new file mode 100644 index 0000000..f665a8b --- /dev/null +++ b/apps/api/src/modules/admin-orders/update-status.dto.ts @@ -0,0 +1,26 @@ +import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; +import { OrderStatus } from '@prisma/client'; + +/** + * Body of legacy `POST single/order-sts/edit`. + * + * Legacy posted the whole timeline row back, so it carried `status`, an optional + * `note`, and `remove: true` when the operator was deleting a status instead of + * setting one. The order id travelled on the row as `order_id`/`orderId`. + */ +export class UpdateOrderStatusDto { + @IsString() + orderId!: string; + + @IsEnum(OrderStatus) + status!: OrderStatus; + + @IsOptional() + @IsString() + note?: string; + + /** legacy: row.remove = true -> delete this status event rather than add one */ + @IsOptional() + @IsBoolean() + remove?: boolean; +} diff --git a/apps/api/src/modules/admin-preorders/admin-preorders.controller.ts b/apps/api/src/modules/admin-preorders/admin-preorders.controller.ts new file mode 100644 index 0000000..02908f4 --- /dev/null +++ b/apps/api/src/modules/admin-preorders/admin-preorders.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; +import { AdminPreOrdersService } from './admin-preorders.service'; +import { TableQueryDto } from '../admin-table/table-query.dto'; +import { RequirePermission } from '../auth/guards'; + +/** + * Legacy endpoints (CustomOrderController): + * POST get-custom-orders -> the "سفارش های ساختنی" grid + * GET get-custom-order/{id} + * PUT custom-order/{id} + * DELETE custom-order/{id} + */ +@Controller('admin/pre-orders') +export class AdminPreOrdersController { + constructor(private readonly preOrders: AdminPreOrdersService) {} + + @Post('list') + @HttpCode(200) + @RequirePermission('order.makeOrder') + list(@Body() query: TableQueryDto) { + return this.preOrders.list(query); + } + + @Get('status-options') + @RequirePermission('order.makeOrder') + statusOptions() { + return this.preOrders.statusOptions(); + } + + @Get(':id') + @RequirePermission('order.makeOrder') + findOne(@Param('id') id: string) { + return this.preOrders.findOne(id); + } + + @Put(':id') + @RequirePermission('order.makeOrder') + update(@Param('id') id: string, @Body() data: Record) { + return this.preOrders.update(id, data); + } + + @Delete(':id') + @RequirePermission('order.makeOrder') + remove(@Param('id') id: string) { + return this.preOrders.remove(id); + } +} diff --git a/apps/api/src/modules/admin-preorders/admin-preorders.module.ts b/apps/api/src/modules/admin-preorders/admin-preorders.module.ts new file mode 100644 index 0000000..e597b67 --- /dev/null +++ b/apps/api/src/modules/admin-preorders/admin-preorders.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminPreOrdersController } from './admin-preorders.controller'; +import { AdminPreOrdersService } from './admin-preorders.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminPreOrdersController], + providers: [AdminPreOrdersService], +}) +export class AdminPreOrdersModule {} diff --git a/apps/api/src/modules/admin-preorders/admin-preorders.service.ts b/apps/api/src/modules/admin-preorders/admin-preorders.service.ts new file mode 100644 index 0000000..e0229b2 --- /dev/null +++ b/apps/api/src/modules/admin-preorders/admin-preorders.service.ts @@ -0,0 +1,158 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PreOrderStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Custom / made-to-order requests — legacy "سفارش های ساختنی". + * + * Legacy served these from CustomOrderController (`POST get-custom-orders`, + * `PUT custom-order/{id}`, `DELETE custom-order/{id}`). The redesigned schema + * models them as PreOrder: a design request that an admin prices and later + * converts into a real, debt-bearing Order. + */ +const PREORDER_STATUS_LABELS: Record = { + PLACED: 'ثبت شده', + ADMIN_APPROVED: 'تایید مدیر', + WORKSHOP_APPROVED: 'تایید کارگاه', + READY_TO_SHIP: 'آماده ارسال', + DELIVERED: 'تحویل شده', + CANCELLED: 'لغو شده', +}; + +@Injectable() +export class AdminPreOrdersService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'status', + 'createdAt', + 'updatedAt', + 'colorName', + 'sizeSpec', + 'stoneColor', + 'minaColor', + 'estimatedWeightGrams', + 'estimatedPriceRial', + 'customer.fullName', + 'customer.mobile', + ]; + + /** Legacy `POST get-custom-orders`. */ + async list(query: TableQueryDto) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['customer.fullName', 'customer.mobile'], + allowed: AdminPreOrdersService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.preOrder.findMany({ + where: where as Prisma.PreOrderWhereInput, + orderBy: orderBy as Prisma.PreOrderOrderByWithRelationInput, + skip, + take, + include: { customer: true, city: true, workshop: true }, + }), + this.prisma.preOrder.count({ where: where as Prisma.PreOrderWhereInput }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + /** Legacy field names (color, size, color_mina, color_stone, ...) preserved. */ + private toRow( + p: Prisma.PreOrderGetPayload<{ include: { customer: true; city: true; workshop: true } }>, + ) { + return { + id: p.id, + status: p.status, + sts_str_fa: PREORDER_STATUS_LABELS[p.status], + color: p.colorName, + size: p.sizeSpec, + color_mina: p.minaColor, + color_stone: p.stoneColor, + // legacy 'sum' (quantity); a PreOrder models a single design request + sum: 1, + description: p.description, + designImageUrl: p.designImageUrl, + estimatedWeightGrams: p.estimatedWeightGrams, + estimatedPriceRial: p.estimatedPriceRial, + time_creat_fa: p.createdAt, + time_update_fa: p.updatedAt, + convertedOrderId: p.convertedOrderId, + city: p.city ? { id: p.city.id, title: p.city.name } : null, + workshop: p.workshop ? { id: p.workshop.id, name: p.workshop.name } : null, + customer: { + id: p.customer.id, + fname: p.customer.fullName, + lname: '', + name: p.customer.fullName, + fullName: p.customer.fullName, + mobile: p.customer.mobile, + }, + }; + } + + async findOne(id: string) { + const p = await this.prisma.preOrder.findUnique({ + where: { id }, + include: { customer: true, city: true, workshop: true }, + }); + if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد'); + return this.toRow(p); + } + + /** Legacy `PUT custom-order/{id}`. */ + async update(id: string, data: Record) { + const existing = await this.prisma.preOrder.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('سفارش ساختنی یافت نشد'); + + const patch: Prisma.PreOrderUpdateInput = {}; + if (data.status !== undefined) patch.status = data.status as PreOrderStatus; + if (data.color !== undefined) patch.colorName = data.color as string; + if (data.size !== undefined) patch.sizeSpec = data.size as string; + if (data.color_mina !== undefined) patch.minaColor = data.color_mina as string; + if (data.color_stone !== undefined) patch.stoneColor = data.color_stone as string; + if (data.description !== undefined) patch.description = data.description as string; + if (data.estimatedWeightGrams !== undefined) { + patch.estimatedWeightGrams = new Prisma.Decimal(data.estimatedWeightGrams as string); + } + if (data.estimatedPriceRial !== undefined) { + patch.estimatedPriceRial = new Prisma.Decimal(data.estimatedPriceRial as string); + } + + return this.prisma.preOrder.update({ where: { id }, data: patch }); + } + + /** Legacy `DELETE custom-order/{id}`. */ + async remove(id: string) { + const p = await this.prisma.preOrder.findUnique({ where: { id } }); + if (!p) throw new NotFoundException('سفارش ساختنی یافت نشد'); + + // Once converted into a real Order the design request is part of an + // accounting trail; deleting it would orphan that link. Legacy had no guard. + if (p.convertedOrderId) { + throw new BadRequestException( + 'حذف ممکن نیست: این سفارش ساختنی قبلاً به سفارش قطعی تبدیل شده است.', + ); + } + + await this.prisma.$transaction([ + this.prisma.preOrderStatusEvent.deleteMany({ where: { preOrderId: id } }), + this.prisma.preOrder.delete({ where: { id } }), + ]); + return { success: true }; + } + + statusOptions() { + return Object.entries(PREORDER_STATUS_LABELS).map(([value, title]) => ({ value, title })); + } +} diff --git a/apps/api/src/modules/admin-products/admin-products.controller.ts b/apps/api/src/modules/admin-products/admin-products.controller.ts new file mode 100644 index 0000000..2342fc7 --- /dev/null +++ b/apps/api/src/modules/admin-products/admin-products.controller.ts @@ -0,0 +1,131 @@ +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'; + +/** + * Legacy endpoints (ProductController): + * POST products -> the product grid + * GET product/show/{id} + * POST product/create + * PUT product/update/{id} + * POST delete/product/{id} (a delete expressed as POST — legacy's shape) + * POST get/variants (a read expressed as POST — legacy's shape) + * GET product/{id}/variant + * POST change/percent/count, POST change/all + * GET brand, POST category + */ +@Controller('admin/products') +export class AdminProductsController { + constructor(private readonly products: AdminProductsService) {} + + @Post('list') + @HttpCode(200) + @RequirePermission('product.list') + list(@Body() query: TableQueryDto) { + return this.products.list(query); + } + + @Post('variants/list') + @HttpCode(200) + @RequirePermission('product.list') + listVariants(@Body() query: TableQueryDto & { product_id?: string }) { + return this.products.listVariants(query); + } + + /** + * Legacy `POST product/variant` — the cart's rehydrate call. Given a list of + * product ids it returns each product with `variants_all`, so the basket can + * refresh prices/stock and drop anything that went out of stock. + */ + @Post('by-ids') + @HttpCode(200) + @RequirePermission('product.list') + byIds(@Body() body: { products_id: string[] }) { + return this.products.byIds(body.products_id ?? []); + } + + /** + * Legacy `POST add/variant` — creates a bespoke weight variant from the order + * builder's inline weight editor. + */ + @Post('variants/create') + @HttpCode(200) + @RequirePermission('order.create') + createVariant(@Body() body: { product_id: string; title: string; grams: number }) { + return this.products.createVariant(body); + } + + @Get('show-type-options') + @RequirePermission('product.list') + showTypeOptions() { + return this.products.showTypeOptions(); + } + + @Get('brands') + @RequirePermission('product.list') + brands() { + return this.products.brands(); + } + + /** Brands as an XTable grid — the exclusion picker needs a selectable table. */ + @Post('brands-table') + @HttpCode(200) + @RequirePermission('product.list') + brandsTable(@Body() query: TableQueryDto) { + return this.products.brandsTable(query); + } + + + @Get('collections') + @RequirePermission('product.list') + collections() { + return this.products.collections(); + } + + @Post('change-percent/count') + @HttpCode(200) + @RequirePermission('product.percent') + changePercentCount(@Body() body: { brand_id?: string; category_id?: string }) { + return this.products.changePercentCount(body); + } + + @Post('change-percent/apply') + @HttpCode(200) + @RequirePermission('product.percent') + changePercent(@Body() body: { percent: number; brand_id?: string; category_id?: string }) { + return this.products.changePercent(body); + } + + @Post('create') + @HttpCode(200) + @RequirePermission('product.create') + create(@Body() data: Record) { + return this.products.create(data); + } + + @Post('delete/:id') + @HttpCode(200) + @RequirePermission('product.list.edit') + remove(@Param('id') id: string) { + return this.products.remove(id); + } + + @Get(':id/variants') + @RequirePermission('product.list') + variantsOf(@Param('id') id: string) { + return this.products.variantsOf(id); + } + + @Get(':id') + @RequirePermission('product.list') + findOne(@Param('id') id: string) { + return this.products.findOne(id); + } + + @Put(':id') + @RequirePermission('product.list.edit') + update(@Param('id') id: string, @Body() data: Record) { + return this.products.update(id, data); + } +} diff --git a/apps/api/src/modules/admin-products/admin-products.module.ts b/apps/api/src/modules/admin-products/admin-products.module.ts new file mode 100644 index 0000000..7b70169 --- /dev/null +++ b/apps/api/src/modules/admin-products/admin-products.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminProductsController } from './admin-products.controller'; +import { AdminProductsService } from './admin-products.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminProductsController], + providers: [AdminProductsService], +}) +export class AdminProductsModule {} diff --git a/apps/api/src/modules/admin-products/admin-products.service.ts b/apps/api/src/modules/admin-products/admin-products.service.ts new file mode 100644 index 0000000..b2f010c --- /dev/null +++ b/apps/api/src/modules/admin-products/admin-products.service.ts @@ -0,0 +1,481 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Product administration — legacy views/apps/product/* served by + * ProductController (`POST products`, `POST product/create`, + * `PUT product/update/{id}`, `GET product/show/{id}`, + * `POST delete/product/{id}`, `POST get/variants`, `POST change/percent/count`). + * + * Legacy `show_type` values, preserved as integers: + * 1 سایت | 2 گوشی | 3 سایت و گوشی + */ +const SHOW_TYPE_LABELS: Record = { + 1: 'سایت', + 2: 'گوشی', + 3: 'سایت و گوشی', +}; + +@Injectable() +export class AdminProductsService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'title', + 'productCode', + 'showType', + 'isActive', + 'createdAt', + 'brand.title', + 'category.title', + // legacy filter keys + 'product_code', + 'show_type', + ]; + + private static readonly FIELD_ALIASES: Record = { + product_code: 'productCode', + show_type: 'showType', + 'category.collection.title': 'category.title', + caj: 'createdAt', + }; + + private applyAliases(query: TableQueryDto): TableQueryDto { + const mapField = (f?: string) => (f ? (AdminProductsService.FIELD_ALIASES[f] ?? f) : f); + return { + ...query, + filters: (query.filters ?? []).map((f) => { + const mapped = { ...f, field: mapField(f.field) }; + if (mapped.field === 'showType' && mapped.value !== undefined) { + mapped.value = Number(mapped.value); + } + return mapped; + }), + order: query.order + ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` + : undefined, + }; + } + + /** Legacy `POST products` — the product grid. */ + async list(query: TableQueryDto) { + this.table.assertNotExport(query); + const aliased = this.applyAliases(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['title', 'productCode'], + allowed: AdminProductsService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.product.findMany({ + where: where as Prisma.ProductWhereInput, + orderBy: orderBy as Prisma.ProductOrderByWithRelationInput, + skip, + take, + include: { + brand: true, + category: true, + images: { orderBy: { sortOrder: 'asc' } }, + variants: true, + }, + }), + this.prisma.product.count({ where: where as Prisma.ProductWhereInput }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + /** Legacy field names preserved so ported table models bind unchanged. */ + private toRow( + p: Prisma.ProductGetPayload<{ + include: { brand: true; category: true; images: true; variants: true }; + }>, + ) { + return { + id: p.id, + title: p.title, + product_code: p.productCode, + show_type: p.showType, + show_type_str: SHOW_TYPE_LABELS[p.showType] ?? '-', + is_active: p.isActive, + description: p.description, + brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null, + // Legacy nested this as category.collection.title in the grid model. + category: p.category + ? { id: p.category.id, title: p.category.title, collection: { title: p.category.title } } + : null, + // legacy grid slot reads `ProductImage` + ProductImage: p.images.map((i) => ({ id: i.id, image: i.url, full: i.url, url: i.url })), + images: p.images.map((i) => ({ id: i.id, url: i.url, sortOrder: i.sortOrder })), + // The order builder's grid reads `thumb.full` and `variants_all` directly + // off each row, so both travel with the list as they did in legacy. + thumb: p.images[0] ? { full: p.images[0].url } : null, + variants_all: p.variants.map((v) => this.variantRow(v)), + variants_count: p.variants.length, + // legacy min_gram / max_gram: the product's lightest and heaviest variant, + // shown by the storefront and by the branch-allocation picker. + min_gram: p.variants.length + ? p.variants.reduce( + (min, v) => (v.rashWeightGrams.lt(min) ? v.rashWeightGrams : min), + p.variants[0].rashWeightGrams, + ) + : null, + max_gram: p.variants.length + ? p.variants.reduce( + (max, v) => (v.rashWeightGrams.gt(max) ? v.rashWeightGrams : max), + p.variants[0].rashWeightGrams, + ) + : null, + caj: p.createdAt, + }; + } + + /** Legacy `GET product/show/{id}`. */ + async findOne(id: string) { + const p = await this.prisma.product.findUnique({ + where: { id }, + include: { + brand: true, + category: true, + images: { orderBy: { sortOrder: 'asc' } }, + variants: true, + collections: { include: { collection: true } }, + }, + }); + if (!p) throw new NotFoundException('محصول یافت نشد'); + + return { + ...this.toRow(p), + body_html: p.description, + brand_id: p.brandId, + cats: p.categoryId ? [p.categoryId] : [], + ciny_ids: p.collections.map((c) => c.collectionId), + variants_all: p.variants.map((v) => this.variantRow(v)), + }; + } + + private variantRow(v: Prisma.ProductVariantGetPayload) { + return { + id: v.id, + product_id_id: v.productId, + title: v.title, + sku: v.sku, + grams: v.rashWeightGrams, + percentage_pay: v.defaultWagePercent, + qty: v.qty, + is_pre_order: v.isPreOrder, + is_active: v.isActive, + // Legacy had a separate rial making-charge column; the schema models wage + // as a percentage only. Returned as null rather than faked. + rial_pay: null, + color_str: null, + }; + } + + /** Legacy `POST get/variants` — a POST that reads, per legacy's own routing. */ + async listVariants(query: TableQueryDto & { product_id?: string }) { + this.table.assertNotExport(query); + + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['title', 'sku'], + allowed: ['id', 'title', 'sku', 'qty', 'rashWeightGrams', 'isActive', 'product.title'], + defaultOrderBy: { title: 'asc' }, + }); + + const scoped: Prisma.ProductVariantWhereInput = query.product_id + ? { AND: [where as Prisma.ProductVariantWhereInput, { productId: query.product_id }] } + : (where as Prisma.ProductVariantWhereInput); + + const [rows, total] = await Promise.all([ + this.prisma.productVariant.findMany({ + where: scoped, + orderBy: orderBy as Prisma.ProductVariantOrderByWithRelationInput, + skip, + take, + include: { product: true }, + }), + this.prisma.productVariant.count({ where: scoped }), + ]); + + return this.table.respond( + rows.map((v) => ({ ...this.variantRow(v), product: { id: v.product.id, title: v.product.title } })), + total, + query, + page, + limit, + ); + } + + /** + * Legacy `POST product/variant` — used by the cart to refresh the products it + * holds. Returns each product with `variants_all` attached, the shape + * stores/cart.ts reads. + */ + async byIds(ids: string[]) { + if (!ids.length) return []; + const rows = await this.prisma.product.findMany({ + where: { id: { in: ids } }, + include: { + brand: true, + category: true, + images: { orderBy: { sortOrder: 'asc' } }, + variants: true, + }, + }); + return rows.map((p) => ({ + ...this.toRow(p), + variants_all: p.variants.map((v) => this.variantRow(v)), + // The cart reads `promotion.id`; there is no Promotion model yet (Batch 9). + promotion: null, + thumb: p.images[0] ? { full: p.images[0].url } : null, + })); + } + + /** + * Legacy `POST add/variant` — the order builder's inline weight editor creates + * a bespoke variant rather than mutating the shared catalog one, so other + * orders keep the weight they were placed with. + */ + async createVariant(body: { product_id: string; title: string; grams: number }) { + if (!body.product_id) throw new BadRequestException('محصول مشخص نشده است'); + const product = await this.prisma.product.findUnique({ + where: { id: body.product_id }, + include: { variants: { take: 1 } }, + }); + if (!product) throw new NotFoundException('محصول یافت نشد'); + + return this.prisma.productVariant.create({ + data: { + productId: body.product_id, + title: body.title, + rashWeightGrams: new Prisma.Decimal(String(body.grams ?? 0)), + // Inherit the product's existing wage rate so the bespoke weight prices + // the same way as its siblings. + defaultWagePercent: + product.variants[0]?.defaultWagePercent ?? new Prisma.Decimal(0), + qty: 1, + }, + }); + } + + /** Legacy `GET product/{id}/variant`. */ + async variantsOf(productId: string) { + const rows = await this.prisma.productVariant.findMany({ where: { productId } }); + return rows.map((v) => this.variantRow(v)); + } + + /** Shape of one inline variant row posted by the product form. */ + private variantCreateData(v: Record) { + return { + title: String(v.title ?? ''), + rashWeightGrams: new Prisma.Decimal(String(v.grams ?? 0)), + defaultWagePercent: new Prisma.Decimal(String(v.percentage_pay ?? 0)), + qty: Number(v.qty ?? 0), + isPreOrder: Boolean(v.is_pre_order), + isActive: v.is_active !== undefined ? Boolean(v.is_active) : true, + }; + } + + /** Legacy `POST product/create`. */ + async create(data: Record) { + const title = String(data.title ?? '').trim(); + if (!title) throw new BadRequestException('عنوان محصول الزامی است'); + + const cats = Array.isArray(data.cats) ? (data.cats as string[]) : []; + const cinyIds = Array.isArray(data.ciny_ids) ? (data.ciny_ids as string[]) : []; + const variants = Array.isArray(data.variants) + ? (data.variants as Record[]) + : []; + + return this.prisma.product.create({ + data: { + title, + description: (data.body_html as string) ?? (data.description as string) ?? null, + productCode: (data.product_code as string) || null, + showType: data.show_type !== undefined ? Number(data.show_type) : 3, + isActive: data.is_active !== undefined ? Boolean(data.is_active) : true, + brandId: (data.brand_id as string) || null, + categoryId: cats[0] ?? null, + collections: cinyIds.length + ? { create: cinyIds.map((collectionId) => ({ collectionId })) } + : undefined, + variants: variants.length + ? { create: variants.map((v) => this.variantCreateData(v)) } + : undefined, + }, + include: { variants: true }, + }); + } + + /** Legacy `PUT product/update/{id}`. */ + async update(id: string, data: Record) { + const existing = await this.prisma.product.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('محصول یافت نشد'); + + const patch: Prisma.ProductUpdateInput = {}; + if (data.title !== undefined) patch.title = String(data.title); + if (data.body_html !== undefined) patch.description = String(data.body_html); + if (data.product_code !== undefined) patch.productCode = (data.product_code as string) || null; + if (data.show_type !== undefined) patch.showType = Number(data.show_type); + if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active); + if (data.brand_id !== undefined) { + patch.brand = data.brand_id ? { connect: { id: String(data.brand_id) } } : { disconnect: true }; + } + if (Array.isArray(data.cats)) { + const catId = (data.cats as string[])[0]; + patch.category = catId ? { connect: { id: catId } } : { disconnect: true }; + } + + await this.prisma.product.update({ where: { id }, data: patch }); + + // Variants are edited inline in the product form, so the posted array is the + // desired end state: update the ones that still carry an id, create the new + // ones, and deactivate (never delete) the ones that disappeared — deleting + // would break the OrderItem snapshots that reference them. + if (Array.isArray(data.variants)) { + const posted = data.variants as Record[]; + // Collect ids as we go: a NEWLY created variant must count as kept, or the + // sweep below would deactivate the row we just added. + const keptIds: string[] = []; + + for (const v of posted) { + const payload = this.variantCreateData(v); + if (v.id) { + await this.prisma.productVariant.update({ + where: { id: String(v.id) }, + data: payload, + }); + keptIds.push(String(v.id)); + } else { + const created = await this.prisma.productVariant.create({ + data: { ...payload, productId: id }, + }); + keptIds.push(created.id); + } + } + + // Anything no longer posted is deactivated, never deleted — OrderItem + // snapshots reference these rows. + await this.prisma.productVariant.updateMany({ + where: { productId: id, id: { notIn: keptIds.length ? keptIds : ['__none__'] } }, + data: { isActive: false }, + }); + } + + return this.findOne(id); + } + + /** + * Legacy `POST delete/product/{id}` — a DELETE expressed as POST. + * + * Refuses when the product has ever been ordered: its variants are referenced + * by OrderItem snapshots, and removing them would break order history. + * Legacy had no such guard. Deactivation is offered instead. + */ + async remove(id: string) { + const product = await this.prisma.product.findUnique({ + where: { id }, + include: { variants: { include: { orderItems: { take: 1 } } } }, + }); + if (!product) throw new NotFoundException('محصول یافت نشد'); + + const hasOrders = product.variants.some((v) => v.orderItems.length > 0); + if (hasOrders) { + throw new BadRequestException( + 'حذف این محصول ممکن نیست، چون در سفارش‌های ثبت‌شده استفاده شده است. می‌توانید آن را غیرفعال کنید.', + ); + } + + await this.prisma.$transaction([ + this.prisma.collectionProduct.deleteMany({ where: { productId: id } }), + this.prisma.comment.deleteMany({ where: { productId: id } }), + this.prisma.productImage.deleteMany({ where: { productId: id } }), + this.prisma.productVariant.deleteMany({ where: { productId: id } }), + this.prisma.product.delete({ where: { id } }), + ]); + return { success: true }; + } + + /** + * Legacy `POST change/percent/count` — how many variants a bulk wage-percent + * change would affect, used by ChangePercent.vue before applying. + */ + async changePercentCount(filter: { brand_id?: string; category_id?: string }) { + const where: Prisma.ProductVariantWhereInput = { + product: { + ...(filter.brand_id ? { brandId: filter.brand_id } : {}), + ...(filter.category_id ? { categoryId: filter.category_id } : {}), + }, + }; + return { count: await this.prisma.productVariant.count({ where }) }; + } + + /** Legacy `POST change/all` — apply a bulk wage-percent change. */ + async changePercent(data: { percent: number; brand_id?: string; category_id?: string }) { + if (data.percent === undefined || Number.isNaN(Number(data.percent))) { + throw new BadRequestException('درصد نامعتبر است'); + } + const where: Prisma.ProductVariantWhereInput = { + product: { + ...(data.brand_id ? { brandId: data.brand_id } : {}), + ...(data.category_id ? { categoryId: data.category_id } : {}), + }, + }; + const result = await this.prisma.productVariant.updateMany({ + where, + data: { defaultWagePercent: new Prisma.Decimal(String(data.percent)) }, + }); + return { updated: result.count }; + } + + /** Legacy `GET brand` and `POST category` — pickers for the product form. */ + async brands() { + return this.prisma.brand.findMany({ orderBy: { title: 'asc' } }); + } + + /** Brands in the XTable envelope, for the promotion-exclusion picker. */ + async brandsTable(query: TableQueryDto) { + this.table.assertNotExport(query); + const { where, orderBy, skip, take, page, limit } = this.table.build(query, { + searchable: ['title'], + allowed: ['id', 'title'], + defaultOrderBy: { title: 'asc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.brand.findMany({ + where: where as Prisma.BrandWhereInput, + orderBy: orderBy as Prisma.BrandOrderByWithRelationInput, + skip, + take, + }), + this.prisma.brand.count({ where: where as Prisma.BrandWhereInput }), + ]); + + return this.table.respond(rows, total, query, page, limit); + } + + + async collections() { + return this.prisma.collection.findMany({ + where: { isActive: true }, + orderBy: { title: 'asc' }, + }); + } + + showTypeOptions() { + return Object.entries(SHOW_TYPE_LABELS).map(([value, title]) => ({ + value: Number(value), + title, + })); + } +} diff --git a/apps/api/src/modules/admin-promotions/admin-promotions.controller.ts b/apps/api/src/modules/admin-promotions/admin-promotions.controller.ts new file mode 100644 index 0000000..c681753 --- /dev/null +++ b/apps/api/src/modules/admin-promotions/admin-promotions.controller.ts @@ -0,0 +1,93 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; +import { AdminPromotionsService } from './admin-promotions.service'; +import { TableQueryDto } from '../admin-table/table-query.dto'; +import { RequirePermission } from '../auth/guards'; + +/** + * Legacy endpoints: + * GET/POST/PUT/DELETE product/variant/promotion -> PromotionController + * GET /autopromotion -> the auto-discount screen + * GET/POST/DELETE /ignore -> views/offersIgnore + */ +@Controller('admin/promotions') +export class AdminPromotionsController { + constructor(private readonly promotions: AdminPromotionsService) {} + + @Post('list') + @HttpCode(200) + @RequirePermission('offers.list') + list(@Body() query: TableQueryDto) { + return this.promotions.list(query); + } + + @Get('scope-options') + @RequirePermission('offers.list') + scopeOptions() { + return this.promotions.scopeOptions(); + } + + @Get('usage-options') + @RequirePermission('offers.list') + usageOptions() { + return this.promotions.usageOptions(); + } + + @Get('auto') + @RequirePermission('offers.autopromotion') + getAutoPromotion() { + return this.promotions.getAutoPromotion(); + } + + @Post('auto') + @HttpCode(200) + @RequirePermission('offers.autopromotion') + setAutoPromotion(@Body() body: { state?: boolean; discount_percent?: number | string }) { + return this.promotions.setAutoPromotion(body); + } + + // ── exclusions ──────────────────────────────────────────────────── + @Get('exclusions') + @RequirePermission('offersIgnore') + listExclusions() { + return this.promotions.listExclusions(); + } + + @Post('exclusions') + @HttpCode(200) + @RequirePermission('offersIgnore') + createExclusion(@Body() body: { type: number; item_id?: string[] }) { + return this.promotions.createExclusion(body); + } + + @Delete('exclusions/:id') + @RequirePermission('offersIgnore') + removeExclusion(@Param('id') id: string) { + return this.promotions.removeExclusion(id); + } + + // ── promotions ──────────────────────────────────────────────────── + @Post() + @HttpCode(200) + @RequirePermission('offers.create') + create(@Body() data: Record) { + return this.promotions.create(data); + } + + @Get(':id') + @RequirePermission('offers.list') + findOne(@Param('id') id: string) { + return this.promotions.findOne(id); + } + + @Put(':id') + @RequirePermission('offers.list.edit') + update(@Param('id') id: string, @Body() data: Record) { + return this.promotions.update(id, data); + } + + @Delete(':id') + @RequirePermission('offers.list.edit') + remove(@Param('id') id: string) { + return this.promotions.remove(id); + } +} diff --git a/apps/api/src/modules/admin-promotions/admin-promotions.module.ts b/apps/api/src/modules/admin-promotions/admin-promotions.module.ts new file mode 100644 index 0000000..b0ed17f --- /dev/null +++ b/apps/api/src/modules/admin-promotions/admin-promotions.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminPromotionsController } from './admin-promotions.controller'; +import { AdminPromotionsService } from './admin-promotions.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminPromotionsController], + providers: [AdminPromotionsService], +}) +export class AdminPromotionsModule {} diff --git a/apps/api/src/modules/admin-promotions/admin-promotions.service.ts b/apps/api/src/modules/admin-promotions/admin-promotions.service.ts new file mode 100644 index 0000000..d6e364a --- /dev/null +++ b/apps/api/src/modules/admin-promotions/admin-promotions.service.ts @@ -0,0 +1,334 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma, PromotionScope, PromotionUsage } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Promotions — legacy views/promotion/*, served by PromotionController + * (`GET/POST/PUT/DELETE product/variant/promotion`) plus the exclusion list + * (`/ignore`) behind views/offersIgnore. + * + * Legacy `type` (scope): 1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند + * Legacy `use_only` (usage): 1 فقط یکبار | 2 تا تاریخ پایان + */ +const SCOPE_BY_CODE: Record = { + 1: PromotionScope.PRODUCT, + 2: PromotionScope.CATEGORY, + 3: PromotionScope.CUSTOMER, + 4: PromotionScope.BRAND, +}; + +const CODE_BY_SCOPE: Record = { + PRODUCT: 1, + CATEGORY: 2, + CUSTOMER: 3, + BRAND: 4, +}; + +const SCOPE_LABELS: Record = { + PRODUCT: 'محصولات', + CATEGORY: 'دسته بندی', + CUSTOMER: 'مشتری', + BRAND: 'برند', +}; + +const USAGE_BY_CODE: Record = { + 1: PromotionUsage.ONCE, + 2: PromotionUsage.UNTIL_END_DATE, +}; + +const CODE_BY_USAGE: Record = { + ONCE: 1, + UNTIL_END_DATE: 2, +}; + +@Injectable() +export class AdminPromotionsService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'title', + 'scope', + 'usage', + 'discountPercent', + 'isActive', + 'platform', + 'startsAt', + 'endsAt', + 'createdAt', + // legacy filter keys + 'promotion_type_str', + 'status', + 'start', + 'end', + 'discount_percent', + ]; + + private static readonly FIELD_ALIASES: Record = { + promotion_type_str: 'scope', + type: 'scope', + use_only: 'usage', + status: 'isActive', + start: 'startsAt', + end: 'endsAt', + discount_percent: 'discountPercent', + caj: 'createdAt', + }; + + /** Legacy `GET product/variant/promotion` — the promotions grid. */ + async list(query: TableQueryDto) { + this.table.assertNotExport(query); + + const mapField = (f?: string) => (f ? (AdminPromotionsService.FIELD_ALIASES[f] ?? f) : f); + const aliased: TableQueryDto = { + ...query, + filters: (query.filters ?? []).map((f) => { + const mapped = { ...f, field: mapField(f.field) }; + if (mapped.field === 'scope' && mapped.value !== undefined) { + mapped.value = SCOPE_BY_CODE[Number(mapped.value)] ?? mapped.value; + } + if (mapped.field === 'isActive' && mapped.value !== undefined) { + mapped.value = String(mapped.value) === '1'; + } + return mapped; + }), + order: query.order + ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` + : undefined, + }; + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['title', 'description'], + allowed: AdminPromotionsService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.promotion.findMany({ + where: where as Prisma.PromotionWhereInput, + orderBy: orderBy as Prisma.PromotionOrderByWithRelationInput, + skip, + take, + include: { product: true, category: true, customer: true, brand: true }, + }), + this.prisma.promotion.count({ where: where as Prisma.PromotionWhereInput }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + private toRow( + p: Prisma.PromotionGetPayload<{ + include: { product: true; category: true; customer: true; brand: true }; + }>, + ) { + return { + id: p.id, + title: p.title, + description: p.description, + type: CODE_BY_SCOPE[p.scope], + promotion_type_str: SCOPE_LABELS[p.scope], + use_only: CODE_BY_USAGE[p.usage], + discount_percent: p.discountPercent, + // legacy grid renders `status` through a slot + status: p.isActive ? 1 : 0, + is_active: p.isActive, + /** + * Legacy `type_customer`: 1 = تک (retail), 2 = عمده (wholesale). The grid + * uses it BOTH to pick which permission gates the row's edit/delete icons + * AND to choose which edit route opens. A customer-scoped promotion is the + * retail ("tak") kind; everything else is the wholesale kind. + */ + type_customer: p.scope === PromotionScope.CUSTOMER ? 1 : 2, + platform: p.platform, + start: p.startsAt, + end: p.endsAt, + product_id: p.productId, + category_id: p.categoryId, + customer_id: p.customerId, + brand_id: p.brandId, + product: p.product ? { id: p.product.id, title: p.product.title } : null, + category: p.category ? { id: p.category.id, title: p.category.title } : null, + customer: p.customer ? { id: p.customer.id, name: p.customer.fullName } : null, + brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null, + caj: p.createdAt, + }; + } + + async findOne(id: string) { + const p = await this.prisma.promotion.findUnique({ + where: { id }, + include: { product: true, category: true, customer: true, brand: true }, + }); + if (!p) throw new NotFoundException('تخفیف یافت نشد'); + return this.toRow(p); + } + + /** + * Maps the legacy form payload onto the model, attaching the ONE target that + * matches the chosen scope and clearing the others — so a promotion can never + * carry, say, both a product and a customer. + */ + private buildData(data: Record) { + const scope = SCOPE_BY_CODE[Number(data.type ?? 1)]; + if (!scope) throw new BadRequestException('نوع تخفیف نامعتبر است'); + + const usage = USAGE_BY_CODE[Number(data.use_only ?? 1)] ?? PromotionUsage.ONCE; + const percent = new Prisma.Decimal(String(data.discount_percent ?? 0)); + if (percent.lt(0) || percent.gt(100)) { + throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد'); + } + + const startsAt = data.start ? new Date(String(data.start)) : null; + const endsAt = data.end ? new Date(String(data.end)) : null; + if (startsAt && endsAt && endsAt < startsAt) { + throw new BadRequestException('تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد'); + } + + return { + title: String(data.title ?? '').trim(), + description: (data.description as string) || null, + scope, + usage, + discountPercent: percent, + isActive: data.is_active !== undefined ? Boolean(data.is_active) : true, + platform: data.platform !== undefined ? Number(data.platform) : 3, + startsAt, + endsAt, + productId: scope === PromotionScope.PRODUCT ? ((data.product_id as string) ?? null) : null, + categoryId: scope === PromotionScope.CATEGORY ? ((data.category_id as string) ?? null) : null, + customerId: scope === PromotionScope.CUSTOMER ? ((data.customer_id as string) ?? null) : null, + brandId: scope === PromotionScope.BRAND ? ((data.brand_id as string) ?? null) : null, + }; + } + + /** Legacy `POST product/variant/promotion`. */ + async create(data: Record) { + const payload = this.buildData(data); + if (!payload.title) throw new BadRequestException('عنوان تخفیف الزامی است'); + return this.prisma.promotion.create({ data: payload }); + } + + /** Legacy `PUT product/variant/promotion/{id}`. */ + async update(id: string, data: Record) { + const existing = await this.prisma.promotion.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('تخفیف یافت نشد'); + // legacy disabled the type selector on edit (`:disabled="this.id"`), so the + // scope is not re-read here either. + const payload = this.buildData({ ...data, type: CODE_BY_SCOPE[existing.scope] }); + return this.prisma.promotion.update({ where: { id }, data: payload }); + } + + /** Legacy `DELETE product/variant/promotion/{id}`. */ + async remove(id: string) { + const p = await this.prisma.promotion.findUnique({ where: { id } }); + if (!p) throw new NotFoundException('تخفیف یافت نشد'); + await this.prisma.promotion.delete({ where: { id } }); + return { success: true }; + } + + scopeOptions() { + return Object.entries(SCOPE_BY_CODE).map(([code, scope]) => ({ + value: Number(code), + title: SCOPE_LABELS[scope], + })); + } + + usageOptions() { + return [ + { value: 1, title: ' فقط یکبار' }, + { value: 2, title: 'تا تاریخ پایان' }, + ]; + } + + /** + * Legacy `GET /autopromotion` and its POST counterpart — the settings screen + * for "تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید": a one-shot welcome + * discount for new customers, configured as an on/off state plus a percentage. + * + * Stored in the Setting key/value table, like the other config screens. + */ + async getAutoPromotion() { + const row = await this.prisma.setting.findUnique({ where: { key: 'promotion.auto' } }); + return row?.value ?? { state: false, discount_percent: null }; + } + + async setAutoPromotion(data: { state?: boolean; discount_percent?: number | string }) { + const percent = Number(data.discount_percent ?? 0); + if (Number.isNaN(percent) || percent < 0 || percent > 100) { + throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد'); + } + const value = { state: Boolean(data.state), discount_percent: percent }; + const row = await this.prisma.setting.upsert({ + where: { key: 'promotion.auto' }, + update: { value }, + create: { key: 'promotion.auto', value }, + }); + return row.value; + } + + // ── Exclusions (views/offersIgnore) ──────────────────────────────── + + /** + * Legacy `GET /ignore` — the full exclusion list. + * + * The view reads it as a flat array and filters client-side: + * items_id(type) { return this.ignoreList.filter(i => i.type == type).map(x => x.item_id) } + * so the flat shape with `item_id` is preserved. + */ + async listExclusions() { + const rows = await this.prisma.promotionExclusion.findMany({ + orderBy: [{ type: 'asc' }, { createdAt: 'asc' }], + }); + return rows.map((e) => ({ id: e.id, type: e.type, item_id: e.itemId })); + } + + /** + * Legacy `POST /ignore` with `{ type, item_id: [...] }`. + * + * The posted array is the COMPLETE selection for that type — the view binds it + * to a checkbox table — so this replaces that type's rows rather than adding + * to them. Anything unchecked is therefore removed. + */ + async createExclusion(data: { type: number; item_id?: string[] }) { + const type = Number(data.type); + if (!type) throw new BadRequestException('نوع استثناء الزامی است'); + + const itemIds = Array.isArray(data.item_id) + ? data.item_id.map((x) => String(x)).filter(Boolean) + : []; + + await this.prisma.$transaction([ + this.prisma.promotionExclusion.deleteMany({ where: { type } }), + this.prisma.promotionExclusion.createMany({ + data: itemIds.map((itemId) => ({ type, itemId })), + skipDuplicates: true, + }), + ]); + + return this.listExclusions(); + } + + /** + * Legacy `DELETE /ignore/{id}` — the view's "حذف نوع استثناء" card removes a + * whole TYPE, so a numeric id is treated as a type and a cuid as one row. + */ + async removeExclusion(id: string) { + if (/^\d+$/.test(id)) { + const type = Number(id); + const { count } = await this.prisma.promotionExclusion.deleteMany({ where: { type } }); + if (count === 0) throw new NotFoundException('استثنایی برای این نوع یافت نشد'); + return { success: true, removed: count }; + } + + const e = await this.prisma.promotionExclusion.findUnique({ where: { id } }); + if (!e) throw new NotFoundException('استثناء یافت نشد'); + await this.prisma.promotionExclusion.delete({ where: { id } }); + return { success: true, removed: 1 }; + } +} diff --git a/apps/api/src/modules/admin-table/admin-table.module.ts b/apps/api/src/modules/admin-table/admin-table.module.ts new file mode 100644 index 0000000..ea30b4b --- /dev/null +++ b/apps/api/src/modules/admin-table/admin-table.module.ts @@ -0,0 +1,13 @@ +import { Global, Module } from '@nestjs/common'; +import { TableService } from './table.service'; + +/** + * Global so every admin grid module can inject TableService without re-importing. + * It is stateless — pure request-shape translation. + */ +@Global() +@Module({ + providers: [TableService], + exports: [TableService], +}) +export class AdminTableModule {} diff --git a/apps/api/src/modules/admin-table/table-query.dto.ts b/apps/api/src/modules/admin-table/table-query.dto.ts new file mode 100644 index 0000000..98e9cb0 --- /dev/null +++ b/apps/api/src/modules/admin-table/table-query.dto.ts @@ -0,0 +1,104 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsIn, + IsInt, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; + +/** + * One row of the XTable filter bar. + * + * Mirrors the object legacy's XFilterRow pushed into `query.filters`: + * `{ id, field, op, value, value2? }`. `id` is a client-side react key + * (a random float) and is accepted but ignored. + */ +export class TableFilterDto { + @IsOptional() + id?: number | string; + + @IsOptional() + @IsString() + field?: string; + + /** + * Legacy operators, kept verbatim. `between` uses value + value2 and is what + * the date-range filter emits. + */ + @IsOptional() + @IsIn(['=', '!=', '>', '>=', '<', '<=', 'like', 'in', 'between', 'null', 'notnull']) + op?: string; + + @IsOptional() + value?: unknown; + + @IsOptional() + value2?: unknown; +} + +/** + * The request body XTable POSTs, ported from its requestData(): + * + * { ...query, select, titles, report_title, op? } + * + * where query = { search, filters, total, limit, page }. + */ +export class TableQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TableFilterDto) + filters?: TableFilterDto[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(500) + limit?: number; + + /** Sort spec: '+field' ascending, '-field' descending (legacy `order`). */ + @IsOptional() + @IsString() + order?: string; + + /** Columns currently visible; drives the export payload. */ + @IsOptional() + @IsArray() + select?: string[]; + + /** key -> Farsi column heading, used for export headers. */ + @IsOptional() + titles?: Record; + + @IsOptional() + @IsString() + report_title?: string; + + /** + * Export mode. Legacy expected `{ url }` back and did `window.open(data.url)`. + * Exports are not implemented yet; the controller rejects these explicitly + * rather than returning a broken url. + */ + @IsOptional() + @IsIn(['excel', 'json', 'print']) + op?: 'excel' | 'json' | 'print'; + + /** Echoed back inside `query`; the client owns this value. */ + @IsOptional() + total?: number; +} diff --git a/apps/api/src/modules/admin-table/table.service.ts b/apps/api/src/modules/admin-table/table.service.ts new file mode 100644 index 0000000..7185943 --- /dev/null +++ b/apps/api/src/modules/admin-table/table.service.ts @@ -0,0 +1,158 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import type { TableFilterDto, TableQueryDto } from './table-query.dto'; + +/** + * Translates the legacy XTable request contract into Prisma query arguments. + * + * The response shape is dictated by legacy's getDataUsage() (x-table mixin): + * + * laravel mode -> { rows, query } <- what the admin panel used + * other mode -> { rows: [[...]], count, page } + * + * The panel's XTbl models all ran in 'laravel' mode, so that is the shape + * produced here: `{ rows, query }` with query carrying total/page/limit. + */ + +export interface TableResult { + rows: T[]; + query: { + search?: string; + filters: TableFilterDto[]; + total: number; + limit: number; + page: number; + }; +} + +export interface BuildOptions { + /** Columns the free-text `search` box scans (legacy initSearch([...])). */ + searchable?: string[]; + /** Whitelist of filterable/sortable fields; anything else is rejected. */ + allowed?: string[]; + /** Sort applied when the request specifies none. */ + defaultOrderBy?: Record; +} + +@Injectable() +export class TableService { + /** + * Turns a dotted path into a nested Prisma object: + * 'customer.fullName', 'x' -> { customer: { fullName: 'x' } } + */ + private nest(path: string, leaf: unknown): Record { + const parts = path.split('.'); + return parts.reduceRight((acc, key, i) => { + return i === parts.length - 1 ? { [key]: leaf } : { [key]: acc }; + }, leaf) as Record; + } + + private assertAllowed(field: string, allowed?: string[]) { + if (allowed && !allowed.includes(field)) { + throw new BadRequestException(`Field not filterable: ${field}`); + } + } + + /** Maps one legacy filter row to a Prisma condition. */ + private toCondition(f: TableFilterDto, allowed?: string[]): Record | null { + if (!f.field || !f.op) return null; + this.assertAllowed(f.field, allowed); + + const v = f.value; + switch (f.op) { + case '=': + return this.nest(f.field, { equals: v }); + case '!=': + return this.nest(f.field, { not: v }); + case '>': + return this.nest(f.field, { gt: v }); + case '>=': + return this.nest(f.field, { gte: v }); + case '<': + return this.nest(f.field, { lt: v }); + case '<=': + return this.nest(f.field, { lte: v }); + case 'like': + // Legacy sent '%value%' to SQL LIKE; `contains` is the Prisma equivalent. + return this.nest(f.field, { + contains: String(v ?? '').replace(/^%|%$/g, ''), + mode: 'insensitive', + }); + case 'in': + return this.nest(f.field, { in: Array.isArray(v) ? v : [v] }); + case 'between': + return this.nest(f.field, { gte: v, lte: f.value2 }); + case 'null': + return this.nest(f.field, { equals: null }); + case 'notnull': + return this.nest(f.field, { not: null }); + default: + return null; + } + } + + /** Builds `where`, `orderBy`, `skip` and `take` from an XTable request. */ + build(query: TableQueryDto, opts: BuildOptions = {}) { + const page = query.page && query.page > 0 ? query.page : 1; + const limit = query.limit && query.limit > 0 ? query.limit : 10; + + const and: Record[] = []; + + for (const f of query.filters ?? []) { + const cond = this.toCondition(f, opts.allowed); + if (cond) and.push(cond); + } + + // Free-text search across the declared columns (legacy: OR over initSearch). + const search = query.search?.trim(); + if (search && opts.searchable?.length) { + and.push({ + OR: opts.searchable.map((field) => + this.nest(field, { contains: search, mode: 'insensitive' }), + ), + }); + } + + // legacy `order`: '+field' asc, '-field' desc + let orderBy = opts.defaultOrderBy ?? { createdAt: 'desc' as const }; + if (query.order) { + const dir = query.order.startsWith('-') ? 'desc' : 'asc'; + const field = query.order.replace(/^[+-]/, ''); + if (field) { + this.assertAllowed(field, opts.allowed); + orderBy = this.nest(field, dir) as Record; + } + } + + return { + where: and.length ? { AND: and } : {}, + orderBy, + skip: (page - 1) * limit, + take: limit, + page, + limit, + }; + } + + /** Wraps rows + count into the exact envelope XTable expects. */ + respond(rows: T[], total: number, query: TableQueryDto, page: number, limit: number): TableResult { + return { + rows, + query: { + search: query.search, + filters: query.filters ?? [], + total, + limit, + page, + }, + }; + } + + /** Exports are not implemented; fail loudly instead of returning a dead url. */ + assertNotExport(query: TableQueryDto) { + if (query.op) { + throw new BadRequestException( + `Export ('${query.op}') is not implemented yet in the new backend.`, + ); + } + } +} diff --git a/apps/api/src/modules/admin-uploads/admin-uploads.controller.ts b/apps/api/src/modules/admin-uploads/admin-uploads.controller.ts new file mode 100644 index 0000000..44bc244 --- /dev/null +++ b/apps/api/src/modules/admin-uploads/admin-uploads.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Get, + HttpCode, + Param, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AdminUploadsService } from './admin-uploads.service'; +import { CurrentAdmin } from '../auth/guards'; +import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; +import { Body } from '@nestjs/common'; + +/** + * The subset of multer's file object this controller uses. Declared locally + * rather than pulling in @types/multer for one interface — multer itself ships + * with @nestjs/platform-express, but its types do not. + */ +interface UploadedMulterFile { + originalname: string; + mimetype: string; + size: number; + buffer: Buffer; +} + +/** + * Legacy endpoints: + * POST file/create -> storeWithoutData + * PUT file/update/{id} -> updateBody (metadata only; not ported — nothing + * in henza_admin calls it) + * + * The multipart field name `file` matches what every legacy uploader posts + * (MyUpload.vue, SingleUploadInput.vue, UploadInput.vue, PodcastUploadInput.vue). + * + * PERMISSIONS. Legacy gated this on nothing at all: `file/create` sat outside + * any middleware, and henza_server's `auth:api` group is itself commented out + * (routes/admin.php:12), so the endpoint accepted anonymous uploads. That is a + * security bug, not a feature, so it is NOT reproduced — these routes require a + * signed-in admin. No specific rid is demanded, since legacy's permission + * vocabulary has no media/file entry to port and inventing one would let it + * drift from the 144 real rids. + */ +@Controller('admin/files') +export class AdminUploadsController { + constructor(private readonly uploads: AdminUploadsService) {} + + @Post('create') + @HttpCode(200) + @UseInterceptors(FileInterceptor('file')) + create( + @UploadedFile() file: UploadedMulterFile | undefined, + @Body('purpose') purpose: string | undefined, + @CurrentAdmin() admin: AuthenticatedAdmin, + ) { + return this.uploads.store(file, purpose, admin.id ?? null); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.uploads.findOne(id); + } +} diff --git a/apps/api/src/modules/admin-uploads/admin-uploads.module.ts b/apps/api/src/modules/admin-uploads/admin-uploads.module.ts new file mode 100644 index 0000000..40fe0d6 --- /dev/null +++ b/apps/api/src/modules/admin-uploads/admin-uploads.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminUploadsController } from './admin-uploads.controller'; +import { AdminUploadsService } from './admin-uploads.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminUploadsController], + providers: [AdminUploadsService], + exports: [AdminUploadsService], +}) +export class AdminUploadsModule {} diff --git a/apps/api/src/modules/admin-uploads/admin-uploads.service.ts b/apps/api/src/modules/admin-uploads/admin-uploads.service.ts new file mode 100644 index 0000000..da95894 --- /dev/null +++ b/apps/api/src/modules/admin-uploads/admin-uploads.service.ts @@ -0,0 +1,80 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { PrismaService } from '../../prisma/prisma.service'; + +/** + * File uploads — ports legacy `POST file/create` + * (henza_server Adm/FileController::storeWithoutData). + * + * Legacy behaviour, reproduced here: + * - the multipart field is named `file` + * - the stored name is md5(time + original name) + the original extension + * - files land in one flat public directory (legacy: public/storage) + * - the JSON response carries an `id`, which the ant-design Upload wrappers + * (SingleUploadInput.vue:87, MyUpload.vue) require before emitting `ok` + * + * Deliberately NOT ported: legacy's commented-out webp re-encoding, watermarking + * and thumbnailing. That code is dead in legacy too — every branch of it is + * inside a `/* ... *​/` block, leaving only the plain `$file->move(...)` live. + */ +@Injectable() +export class AdminUploadsService { + /** Matches legacy's flat public/storage directory. */ + private readonly dir = process.env.UPLOAD_DIR ?? join(process.cwd(), 'storage', 'uploads'); + + /** Mirrors legacy's `type` field: 1 = image, 2 = any other document. */ + private static readonly IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/; + private static readonly ALLOWED = /^(image\/(jpeg|png|gif|webp|svg\+xml)|application\/pdf)$/; + + /** 8 MB. Legacy relied on PHP's upload_max_filesize; this makes it explicit. */ + private static readonly MAX_BYTES = 8 * 1024 * 1024; + + constructor(private readonly prisma: PrismaService) {} + + async store( + file: { originalname: string; mimetype: string; size: number; buffer: Buffer } | undefined, + purpose: string | undefined, + actorAdminUserId: string | null, + ) { + if (!file) throw new BadRequestException('فایلی ارسال نشده است'); + if (file.size > AdminUploadsService.MAX_BYTES) { + throw new BadRequestException('حجم فایل بیش از حد مجاز است (۸ مگابایت)'); + } + if (!AdminUploadsService.ALLOWED.test(file.mimetype)) { + throw new BadRequestException('نوع فایل مجاز نیست'); + } + + // legacy: md5(time() . original_name) . '.' . ext + const ext = extname(file.originalname).toLowerCase(); + const stored = + createHash('md5').update(`${Date.now()}${file.originalname}${randomUUID()}`).digest('hex') + + ext; + + await mkdir(this.dir, { recursive: true }); + await writeFile(join(this.dir, stored), file.buffer); + + return this.prisma.uploadedFile.create({ + data: { + url: `/uploads/${stored}`, + originalName: file.originalname, + mimeType: file.mimetype, + sizeBytes: file.size, + purpose: purpose ?? null, + uploadedByAdminUserId: actorAdminUserId, + }, + }); + } + + async findOne(id: string) { + const f = await this.prisma.uploadedFile.findUnique({ where: { id } }); + if (!f) throw new NotFoundException('فایل یافت نشد'); + return f; + } + + /** True when the stored file is an image, for callers that care. */ + static isImage(mime: string) { + return AdminUploadsService.IMAGE_MIME.test(mime); + } +} diff --git a/apps/api/src/modules/admin-users/admin-users.controller.ts b/apps/api/src/modules/admin-users/admin-users.controller.ts new file mode 100644 index 0000000..c00fc83 --- /dev/null +++ b/apps/api/src/modules/admin-users/admin-users.controller.ts @@ -0,0 +1,100 @@ +import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common'; +import { AdminUsersService } from './admin-users.service'; +import { TableQueryDto } from '../admin-table/table-query.dto'; +import { CurrentAdmin, RequirePermission } from '../auth/guards'; +import type { AuthenticatedAdmin } from '../auth/jwt.strategy'; + +/** + * Legacy endpoints (UserController + RoleController): + * GET/POST/PUT/DELETE users + * GET const/__list__ut -> access-level picker + * POST const -> create an access level + * GET roles/{ut} -> rids of one level + * POST roles/create -> set rids + * DELETE roles/{ut}/{rid} -> revoke one rid + */ +@Controller('admin/users') +export class AdminUsersController { + constructor(private readonly users: AdminUsersService) {} + + @Post('list') + @HttpCode(200) + @RequirePermission('user.list') + list(@Body() query: TableQueryDto) { + return this.users.list(query); + } + + @Get('role-options') + @RequirePermission('user.list') + roleOptions() { + return this.users.roleOptions(); + } + + /** Legacy GET const/__list__ut. */ + @Get('types') + @RequirePermission('user.list') + listUserTypes() { + return this.users.listUserTypes(); + } + + /** Legacy POST const {key:"__list__ut", val:""}. */ + @Post('types') + @HttpCode(200) + @RequirePermission('user.permission.create') + createUserType(@Body() body: { val?: string; title?: string }) { + return this.users.createUserType(body.val ?? body.title ?? ''); + } + + /** Legacy GET roles/{ut}. */ + @Get('types/:ut/roles') + @RequirePermission('user.permission') + getRoles(@Param('ut') ut: string) { + return this.users.getRoles(Number(ut)); + } + + /** Legacy POST roles/create {ut, roles}. */ + @Post('roles') + @HttpCode(200) + @RequirePermission('user.permission.edit') + setRoles(@Body() body: { ut: number | string; roles: string[] }) { + return this.users.setRoles(Number(body.ut), body.roles ?? []); + } + + /** Legacy DELETE roles/{ut}/{rid}. */ + @Delete('types/:ut/roles/:rid') + @RequirePermission('user.permission.edit') + revokeRole(@Param('ut') ut: string, @Param('rid') rid: string) { + return this.users.revokeRole(Number(ut), rid); + } + + /** The "اطلاعات من" screen. */ + @Get('me') + myData(@CurrentAdmin() admin: AuthenticatedAdmin) { + return this.users.myData(admin.id); + } + + @Post() + @HttpCode(200) + @RequirePermission('user.create') + create(@Body() data: Record<string, unknown>) { + return this.users.create(data); + } + + @Get(':id') + @RequirePermission('user.list') + findOne(@Param('id') id: string) { + return this.users.findOne(id); + } + + @Put(':id') + @RequirePermission('user.list.edit') + update(@Param('id') id: string, @Body() data: Record<string, unknown>) { + return this.users.update(id, data); + } + + @Delete(':id') + @RequirePermission('user.list.remove') + remove(@Param('id') id: string, @CurrentAdmin() admin: AuthenticatedAdmin) { + return this.users.remove(id, admin.id); + } +} diff --git a/apps/api/src/modules/admin-users/admin-users.module.ts b/apps/api/src/modules/admin-users/admin-users.module.ts new file mode 100644 index 0000000..b68a24e --- /dev/null +++ b/apps/api/src/modules/admin-users/admin-users.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminUsersController } from './admin-users.controller'; +import { AdminUsersService } from './admin-users.service'; + +@Module({ + imports: [PrismaModule], + controllers: [AdminUsersController], + providers: [AdminUsersService], +}) +export class AdminUsersModule {} diff --git a/apps/api/src/modules/admin-users/admin-users.service.ts b/apps/api/src/modules/admin-users/admin-users.service.ts new file mode 100644 index 0000000..33aa5e2 --- /dev/null +++ b/apps/api/src/modules/admin-users/admin-users.service.ts @@ -0,0 +1,323 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { AdminRole, Prisma } from '@prisma/client'; +import * as bcrypt from 'bcryptjs'; +import { PrismaService } from '../../prisma/prisma.service'; +import { TableService } from '../admin-table/table.service'; +import type { TableQueryDto } from '../admin-table/table-query.dto'; + +/** + * Admin users and the permission matrix — legacy views/user/*, served by + * UserController and RoleController: + * POST/GET/PUT/DELETE users -> the admin user list and form + * GET const/__list__ut -> the user-type ("سطح دسترسی") picker + * POST const -> create a new user type + * GET roles/{ut} -> the rids granted to a user type + * POST roles/create -> grant rids + * DELETE roles/{ut}/{rid} -> revoke one rid + * + * The permission matrix writes the `permission(rid, userTypeId)` rows that + * Phase 1 modelled, so this screen is what actually administers the ACL. + */ +@Injectable() +export class AdminUsersService { + constructor( + private readonly prisma: PrismaService, + private readonly table: TableService, + ) {} + + private static readonly ALLOWED = [ + 'id', + 'fullName', + 'mobile', + 'role', + 'isActive', + 'createdAt', + 'userTypeId', + 'userType.title', + 'branch.name', + // legacy filter keys + 'name', + 'ut', + ]; + + private static readonly FIELD_ALIASES: Record<string, string> = { + name: 'fullName', + ut: 'userTypeId', + caj: 'createdAt', + active: 'isActive', + }; + + /** Legacy `GET users` — the admin user grid. */ + async list(query: TableQueryDto) { + this.table.assertNotExport(query); + + const mapField = (f?: string) => (f ? (AdminUsersService.FIELD_ALIASES[f] ?? f) : f); + const aliased: TableQueryDto = { + ...query, + filters: (query.filters ?? []).map((f) => { + const mapped = { ...f, field: mapField(f.field) }; + if (mapped.field === 'isActive' && mapped.value !== undefined) { + mapped.value = String(mapped.value) === '1'; + } + if (mapped.field === 'userTypeId' && mapped.value !== undefined) { + mapped.value = Number(mapped.value); + } + return mapped; + }), + order: query.order + ? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}` + : undefined, + }; + + const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, { + searchable: ['fullName', 'mobile'], + allowed: AdminUsersService.ALLOWED, + defaultOrderBy: { createdAt: 'desc' }, + }); + + const [rows, total] = await Promise.all([ + this.prisma.adminUser.findMany({ + where: where as Prisma.AdminUserWhereInput, + orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput, + skip, + take, + include: { userType: true, branch: true }, + }), + this.prisma.adminUser.count({ where: where as Prisma.AdminUserWhereInput }), + ]); + + return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit); + } + + /** Legacy field names preserved; the password hash is never exposed. */ + private toRow(u: Prisma.AdminUserGetPayload<{ include: { userType: true; branch: true } }>) { + return { + id: u.id, + name: u.fullName, + fname: u.fullName, + lname: '', + mobile: u.mobile, + role: u.role, + ut: u.userTypeId, + user_type: u.userType ? { id: u.userType.id, title: u.userType.title } : null, + branch_id: u.branchId, + branch: u.branch ? { id: u.branch.id, name: u.branch.name } : null, + percent: u.wagePercent, + active: u.isActive ? 1 : -1, + is_active: u.isActive, + caj: u.createdAt, + }; + } + + async findOne(id: string) { + const u = await this.prisma.adminUser.findUnique({ + where: { id }, + include: { userType: true, branch: true }, + }); + if (!u) throw new NotFoundException('کاربر یافت نشد'); + return this.toRow(u); + } + + /** Legacy `POST users`. */ + async create(data: Record<string, unknown>) { + const fullName = String(data.name ?? data.fname ?? '').trim(); + const mobile = String(data.mobile ?? '').trim(); + const password = String(data.password ?? ''); + + if (!fullName) throw new BadRequestException('نام الزامی است'); + if (!mobile) throw new BadRequestException('موبایل الزامی است'); + if (!password) throw new BadRequestException('رمز عبور الزامی است'); + + const existing = await this.prisma.adminUser.findUnique({ where: { mobile } }); + if (existing) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است'); + + return this.prisma.adminUser.create({ + data: { + fullName, + mobile, + passwordHash: await bcrypt.hash(password, 10), + role: (data.role as AdminRole) ?? AdminRole.SALES_EXPERT, + userTypeId: data.ut !== undefined && data.ut !== null ? Number(data.ut) : null, + branchId: (data.branch_id as string) || null, + wagePercent: + data.percent !== undefined && data.percent !== null + ? new Prisma.Decimal(String(data.percent)) + : null, + isActive: data.is_active !== undefined ? Boolean(data.is_active) : true, + }, + include: { userType: true, branch: true }, + }); + } + + /** Legacy `PUT users/{id}`. */ + async update(id: string, data: Record<string, unknown>) { + const existing = await this.prisma.adminUser.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('کاربر یافت نشد'); + + const patch: Prisma.AdminUserUpdateInput = {}; + if (data.name !== undefined) patch.fullName = String(data.name); + if (data.mobile !== undefined) { + const mobile = String(data.mobile); + if (mobile !== existing.mobile) { + const clash = await this.prisma.adminUser.findUnique({ where: { mobile } }); + if (clash) throw new BadRequestException('این شماره موبایل قبلاً ثبت شده است'); + } + patch.mobile = mobile; + } + // A blank password field means "leave it unchanged" — legacy did the same. + if (data.password) patch.passwordHash = await bcrypt.hash(String(data.password), 10); + if (data.role !== undefined) patch.role = data.role as AdminRole; + if (data.ut !== undefined) { + patch.userType = data.ut ? { connect: { id: Number(data.ut) } } : { disconnect: true }; + } + if (data.branch_id !== undefined) { + patch.branch = data.branch_id + ? { connect: { id: String(data.branch_id) } } + : { disconnect: true }; + } + if (data.percent !== undefined) { + patch.wagePercent = + data.percent === null ? null : new Prisma.Decimal(String(data.percent)); + } + if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active); + + return this.prisma.adminUser.update({ + where: { id }, + data: patch, + include: { userType: true, branch: true }, + }); + } + + /** + * Legacy `DELETE users/{id}`. + * + * Refuses to delete an admin who has posted ledger entries or changed order + * statuses — those rows reference them, and losing the actor would erase the + * audit trail. Deactivation is offered instead. Legacy deleted regardless. + */ + async remove(id: string, actorId: string) { + const u = await this.prisma.adminUser.findUnique({ + where: { id }, + include: { + _count: { select: { postedLedgerEntries: true, orderStatusEvents: true } }, + }, + }); + if (!u) throw new NotFoundException('کاربر یافت نشد'); + + if (id === actorId) { + throw new BadRequestException('نمی‌توانید حساب کاربری خودتان را حذف کنید'); + } + + const refs = u._count.postedLedgerEntries + u._count.orderStatusEvents; + if (refs > 0) { + throw new BadRequestException( + `حذف این کاربر ممکن نیست، چون ${refs} سند یا تغییر وضعیت به نام او ثبت شده است. می‌توانید غیرفعالش کنید.`, + ); + } + + await this.prisma.adminUser.delete({ where: { id } }); + return { success: true }; + } + + // ── User types + the permission matrix ───────────────────────────── + + /** Legacy `GET const/__list__ut` — the access-level picker. */ + async listUserTypes() { + const rows = await this.prisma.userType.findMany({ + orderBy: { id: 'asc' }, + include: { _count: { select: { adminUsers: true, permissions: true } } }, + }); + // legacy returned {id, title} pairs; the counts are additive, not breaking. + return rows.map((t) => ({ + id: t.id, + title: t.title, + users_count: t._count.adminUsers, + permissions_count: t._count.permissions, + })); + } + + /** + * Legacy `POST const` with `{key: "__list__ut", val: "<title>"}` — creates a + * new access level. Legacy's `config_static` used an auto-increment id; here + * the next free integer is taken explicitly since UserType.id is not generated. + */ + async createUserType(title: string) { + if (!title?.trim()) throw new BadRequestException('نام سطح دسترسی الزامی است'); + + const last = await this.prisma.userType.findFirst({ orderBy: { id: 'desc' } }); + const nextId = (last?.id ?? 84) + 1; // 85 is legacy's first, per Permission.vue + + return this.prisma.userType.create({ + data: { id: nextId, title: title.trim() }, + }); + } + + /** Legacy `GET roles/{ut}` — the rids granted to one access level. */ + async getRoles(userTypeId: number) { + const type = await this.prisma.userType.findUnique({ + where: { id: userTypeId }, + include: { permissions: true }, + }); + if (!type) throw new NotFoundException('سطح دسترسی یافت نشد'); + // legacy's tree binds to a flat array of rid strings + return type.permissions.map((p) => p.rid); + } + + /** + * Legacy `POST roles/create` with `{ut, roles: [...]}`. + * + * The posted array is the complete tick-state of the permission tree, so it + * REPLACES that type's rows — which is what legacy's live code does too: it + * posts the whole `this.rid` array and nothing else. + * + * (Legacy also carries a COMMENTED-OUT block that diffed the old and new sets + * and fired one DELETE per removed rid on a 500ms setTimeout. That path is + * dead in legacy and is not revived here; the transaction below achieves the + * same end atomically.) + */ + async setRoles(userTypeId: number, rids: string[]) { + const type = await this.prisma.userType.findUnique({ where: { id: userTypeId } }); + if (!type) throw new NotFoundException('سطح دسترسی یافت نشد'); + + const clean = [...new Set((rids ?? []).map((r) => String(r)).filter(Boolean))]; + + await this.prisma.$transaction([ + this.prisma.permission.deleteMany({ where: { userTypeId } }), + this.prisma.permission.createMany({ + data: clean.map((rid) => ({ rid, userTypeId })), + skipDuplicates: true, + }), + ]); + + return this.getRoles(userTypeId); + } + + /** Legacy `DELETE roles/{ut}/{rid}` — revoke a single rid. */ + async revokeRole(userTypeId: number, rid: string) { + const existing = await this.prisma.permission.findUnique({ + where: { rid_userTypeId: { rid, userTypeId } }, + }); + if (!existing) throw new NotFoundException('این دسترسی وجود ندارد'); + + await this.prisma.permission.delete({ + where: { rid_userTypeId: { rid, userTypeId } }, + }); + return { success: true }; + } + + /** Legacy `GET users/show` equivalent for the "اطلاعات من" screen. */ + async myData(adminId: string) { + return this.findOne(adminId); + } + + /** The AdminRole picker for the user form. */ + roleOptions() { + return [ + { value: AdminRole.SUPER_ADMIN, title: 'مدیر ارشد' }, + { value: AdminRole.SYSTEM_APPROVER, title: 'تایید مدیر سیستم' }, + { value: AdminRole.SALES_EXPERT, title: 'کارشناس فروش' }, + { value: AdminRole.WORKSHOP_SUPERVISOR, title: 'سرپرست کارگاه' }, + { value: AdminRole.ACCOUNTANT, title: 'حسابدار' }, + ]; + } +} diff --git a/apps/api/src/modules/admin-workshops/admin-workshops.controller.ts b/apps/api/src/modules/admin-workshops/admin-workshops.controller.ts new file mode 100644 index 0000000..0a0e784 --- /dev/null +++ b/apps/api/src/modules/admin-workshops/admin-workshops.controller.ts @@ -0,0 +1,52 @@ +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'; + +/** + * Workshops — legacy views/workshop/*, served by WorkshopController: + * POST workshop/table -> the grid (also the picker the PreOrder detail uses) + * POST workshop -> create (workshop + its supervisor, together) + * GET/PUT workshop/{id} + * GET workshop/active/{id} -> toggle + * + * The list route predates the rest (Batch 3 added it for the made-to-order + * 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. + */ +@Controller('admin/workshops') +export class AdminWorkshopsController { + constructor(private readonly branches: AdminBranchesService) {} + + @Post('list') + @HttpCode(200) + list(@Body() query: TableQueryDto) { + return this.branches.listWorkshops(query); + } + + @Post() + @HttpCode(200) + @RequirePermission('workshop.create') + create(@Body() data: Record<string, unknown>) { + return this.branches.createWorkshop(data); + } + + @Get('active/:id') + @RequirePermission('workshop.list') + toggleActive(@Param('id') id: string) { + return this.branches.toggleWorkshopActive(id); + } + + @Get(':id') + @RequirePermission('workshop.list') + findOne(@Param('id') id: string) { + return this.branches.findWorkshop(id); + } + + @Put(':id') + @RequirePermission('workshop.list') + update(@Param('id') id: string, @Body() data: Record<string, unknown>) { + return this.branches.updateWorkshop(id, data); + } +} diff --git a/apps/api/src/modules/admin-workshops/admin-workshops.module.ts b/apps/api/src/modules/admin-workshops/admin-workshops.module.ts new file mode 100644 index 0000000..202718c --- /dev/null +++ b/apps/api/src/modules/admin-workshops/admin-workshops.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AdminBranchesModule } from '../admin-branches/admin-branches.module'; +import { AdminWorkshopsController } from './admin-workshops.controller'; + +@Module({ + imports: [PrismaModule, AdminBranchesModule], + controllers: [AdminWorkshopsController], +}) +export class AdminWorkshopsModule {} diff --git a/apps/api/src/modules/auth/acl.constants.ts b/apps/api/src/modules/auth/acl.constants.ts new file mode 100644 index 0000000..9f16ed7 --- /dev/null +++ b/apps/api/src/modules/auth/acl.constants.ts @@ -0,0 +1,204 @@ +/** + * Every permission id (`rid`) the admin panel actually enforces. + * + * Regenerated mechanically from legacy (see scratchpad/convert_tree.js) as the + * union of two enforcement points: + * - 58 gated by a route's `meta.role` (router/admin.js, router/branch.js) + * - 100 gated inside views via `$hasPermission('...')` + * = 144 distinct rids (the two sets overlap) + * + * An earlier revision of this file listed only the 58 route-gated ones, which + * left the seeded super-admin unable to use the in-view actions. Corrected. + * + * These are LEAVES as written. Ancestors ("order" from "order.list", "persons" + * from "persons.list.client.omde", ...) are NOT stored — they are derived at + * runtime by expandWithParents(), exactly as legacy's checkParent() did. + */ +export const ALL_RIDS = [ + 'branch.create', + 'branch.list', + 'buy.order.list', + 'cities.create', + 'cities.list', + 'cities.list.remove', + 'cities.list.view', + 'cities.list.view.edit', + 'collection.create', + 'collection.list', + 'collection.list.edit', + 'collection.list.remove', + 'comment', + 'comment.remove', + 'comment.state', + 'comment.view', + 'comment.view.edit', + 'config.report', + 'config.statics', + 'config.statics.brand', + 'config.statics.brand.create', + 'config.statics.brand.edit', + 'config.statics.brand.remove', + 'config.statics.tags', + 'config.statics.tags.create', + 'config.statics.tags.edit', + 'config.statics.tags.remove', + 'config.wallet', + 'custom.config.statics.slider', + 'custom.config.statics.tags.banner.ids', + 'dashboard.omde', + 'dashboard.tak', + 'document.create', + 'document.list', + 'document.list.confirm', + 'document.list.deny', + 'document.list.edit', + 'document.list.get', + 'document.list.remove', + 'invoice.create', + 'invoice.list', + 'newsletters', + 'notify.create', + 'notify.me', + 'offers.autopromotion', + 'offers.create', + 'offers.create.tak', + 'offers.list', + 'offers.list.edit', + 'offers.list.remove', + 'offers.list.tak.edit', + 'offers.list.tak.remove', + 'offersIgnore', + 'order.create', + 'order.list', + 'order.list.pdf', + 'order.list.sts', + 'order.list.sts.remove', + 'order.list.sts.submit', + 'order.list.view', + 'order.list.view.edit', + 'order.makeOrder', + 'order.makeOrder.get', + 'order.makeOrder.remove', + 'order.makeOrder.state', + 'order.makeOrder.state.remove', + 'order.makeOrder.state.submit', + 'order.makeOrder.view', + 'order.makeOrder.view.edit', + 'order.omde', + 'order.omde.get', + 'order.omde.remove', + 'order.omde.state', + 'order.omde.state.remove', + 'order.omde.state.submit', + 'order.omde.view', + 'order.omde.view.edit', + 'order.omde.view.remove', + 'order.tamin', + 'order.tamin.state', + 'order.tamin.state.remove', + 'order.tamin.state.submit', + 'order.tamin.view', + 'order.tamin.view.edit', + 'persons.create', + 'persons.create.client', + 'persons.create.customer.tak', + 'persons.create.seller', + 'persons.list.client.omde', + 'persons.list.client.omde.able', + 'persons.list.client.omde.able.order', + 'persons.list.client.omde.edit', + 'persons.list.client.omde.unable', + 'persons.list.client.omde.unable.order', + 'persons.list.client.omde.view', + 'persons.list.client.omde.view.documents', + 'persons.list.client.omde.view.orders', + 'persons.list.client.single', + 'persons.list.client.single.edit', + 'persons.list.client.single.view', + 'persons.list.sellers', + 'persons.list.sellers.able', + 'persons.list.sellers.able.order', + 'persons.list.sellers.edit', + 'persons.list.sellers.unable', + 'persons.list.sellers.unable.order', + 'persons.list.sellers.view', + 'persons.list.sellers.view.documents', + 'persons.list.sellers.view.orders', + 'product.create', + 'product.list', + 'product.list.edit', + 'product.list.group.edit', + 'product.list.remove', + 'product.list.variants', + 'product.list.variants.add', + 'product.list.variants.edit', + 'product.list.variants.remove', + 'product.list.view', + 'product.percent', + 'report.order.item', + 'report.product', + 'sms.create', + 'sms.create.all', + 'sms.create.multiple', + 'sms.create.omde', + 'sms.create.reagent', + 'sms.create.single', + 'sms.list', + 'statics', + 'user.create', + 'user.list', + 'user.list.create', + 'user.list.edit', + 'user.list.remove', + 'user.permission', + 'user.permission.create', + 'user.permission.edit', + 'wallet', + 'wallet.charge', + 'wallet.list', + 'wallet.percent', + 'workshop.create', + 'workshop.list', +] as const; + +export type Rid = (typeof ALL_RIDS)[number]; + +/** + * Port of henza_admin/src/store/users/index.js getParents() — returns every + * ancestor of a dotted rid, longest first. + * + * getParents(['a','b','c']) -> ['a.b', 'a'] + */ +function getParents(keys: string[]): string[] { + const work = [...keys]; + const parents: string[] = []; + while (work.length > 1) { + work.pop(); + parents.push(work.join('.')); + } + return parents; +} + +/** + * Port of henza_admin/src/store/users/index.js checkParent(). + * + * Granting "persons.list.client.omde" implicitly grants "persons.list.client", + * "persons.list" and "persons", so parent menu nodes stay visible. The legacy + * version mutated a module-scoped `rid` variable; this one is pure, which changes + * nothing observable but makes it safe to call concurrently on the server. + * + * Note this only walks UP. A rid never implies its children — which is why the + * legacy nav item guarded by "wallet.list" stays hidden for a user granted only + * "wallet". That mismatch is a legacy bug, recorded in docs/ADMIN-PORT-PLAN.md, + * and is preserved here rather than silently repaired. + */ +export function expandWithParents(values: string[]): string[] { + let rid: string[] = [...values]; + for (const x of values) { + const keys = x.split('.'); + if (keys.length > 1) { + rid = [...new Set([...rid, ...getParents(keys)])]; + } + } + return rid; +} diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..248afd0 --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { LoginDto } from './dto/login.dto'; +import { CurrentAdmin, Public } from './guards'; +import type { AuthenticatedAdmin } from './jwt.strategy'; + +/** + * Legacy endpoints (henza_server routes/admin.php): + * POST admin-login -> AdminController::login + * GET users/show -> UserController::showData + * + * Exposed here under the clean /api/admin/auth prefix; the admin panel's mapping + * layer translates the legacy paths onto these. + */ +@Controller('admin/auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post('login') + @HttpCode(200) + login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } + + /** Legacy `users/show` — the router guard's permission refresh. */ + @Get('me') + me(@CurrentAdmin() admin: AuthenticatedAdmin) { + return this.authService.showData(admin.id); + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..d71f978 --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { PrismaModule } from '../../prisma/prisma.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtStrategy } from './jwt.strategy'; +import { JwtAuthGuard, PermissionsGuard } from './guards'; + +@Module({ + imports: [ + PrismaModule, + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET ?? 'dev-only-insecure-secret', + // jsonwebtoken types expiresIn as a template-literal union, which a plain + // `string` from process.env does not satisfy; the cast is the documented escape. + signOptions: { + expiresIn: (process.env.JWT_EXPIRES_IN ?? '12h') as `${number}h`, + }, + }), + ], + controllers: [AuthController], + providers: [ + AuthService, + JwtStrategy, + // Global: authenticate first, then check the rid. Routes opt out with @Public(). + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: PermissionsGuard }, + ], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..be1ddca --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -0,0 +1,89 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcryptjs'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { LoginDto } from './dto/login.dto'; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwt: JwtService, + ) {} + + /** + * Port of legacy AdminController::login (henza_server). + * + * Legacy issued a random 60-char string as `api_token` and persisted it on the + * user row; this issues a signed JWT instead. The response keeps the legacy + * shape the admin panel's auth store reads: + * - `api_token` -> stored in localStorage under token/branch_token + * - `roles` -> [{ rid }], mapped to rids by the frontend then expanded + * through checkParent() + * + * Deliberate deviation from legacy: a bad phone and a bad password both return + * 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for + * a wrong password, which let anyone enumerate valid admin phone numbers. + */ + async login(dto: LoginDto) { + const user = await this.prisma.adminUser.findUnique({ + where: { mobile: dto.phone }, + include: { userType: { include: { permissions: true } } }, + }); + + if (!user || !user.isActive) { + throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است'); + } + + const passwordOk = await bcrypt.compare(dto.password, user.passwordHash); + if (!passwordOk) { + throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است'); + } + + const apiToken = await this.jwt.signAsync({ sub: user.id, mobile: user.mobile }); + + return { + id: user.id, + name: user.fullName, + fullName: user.fullName, + mobile: user.mobile, + role: user.role, + ut: user.userTypeId, + branch_id: user.branchId, + api_token: apiToken, + roles: (user.userType?.permissions ?? []).map((p) => ({ rid: p.rid })), + }; + } + + /** + * Port of legacy UserController::showData, which the router guard calls on every + * cold load to refresh the permission list: + * + * $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid'); + * + * Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in + * legacy. The guard's own checks use the pre-expanded list from JwtStrategy. + */ + async showData(adminId: string) { + const user = await this.prisma.adminUser.findUnique({ + where: { id: adminId }, + include: { userType: { include: { permissions: true } } }, + }); + + if (!user) { + throw new UnauthorizedException(); + } + + return { + id: user.id, + name: user.fullName, + fullName: user.fullName, + mobile: user.mobile, + role: user.role, + 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), + }; + } +} diff --git a/apps/api/src/modules/auth/dto/login.dto.ts b/apps/api/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..7fd445f --- /dev/null +++ b/apps/api/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,18 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * Mirrors the legacy contract in AdminController::login: + * $this->validate($request, ['phone' => 'required', 'password' => 'required']); + * + * The field is named `phone` (not `mobile`) even though it is matched against + * `users.mobile` — kept as-is so the ported login form posts an unchanged body. + */ +export class LoginDto { + @IsString() + @IsNotEmpty() + phone!: string; + + @IsString() + @IsNotEmpty() + password!: string; +} diff --git a/apps/api/src/modules/auth/guards.ts b/apps/api/src/modules/auth/guards.ts new file mode 100644 index 0000000..28280e5 --- /dev/null +++ b/apps/api/src/modules/auth/guards.ts @@ -0,0 +1,71 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, + createParamDecorator, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import type { AuthenticatedAdmin } from './jwt.strategy'; + +/** 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). */ +export const REQUIRED_RID_KEY = 'requiredRid'; +export const RequirePermission = (rid: string) => SetMetadata(REQUIRED_RID_KEY, rid); + +/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */ +export const CurrentAdmin = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthenticatedAdmin => + ctx.switchToHttp().getRequest().user, +); + +/** + * JWT guard that honours @Public(). + * + * Registered globally in AuthModule, so every route is authenticated by default + * and opting out is explicit. The legacy app had the inverse default — its router + * guard skipped a hardcoded `noneAuthUrl` list — which meant any route added + * without a `meta.role` was silently unprotected. + */ +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + return super.canActivate(context); + } +} + +/** Enforces @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<string>(REQUIRED_RID_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRid) 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}`); + } + return true; + } +} diff --git a/apps/api/src/modules/auth/jwt.strategy.ts b/apps/api/src/modules/auth/jwt.strategy.ts new file mode 100644 index 0000000..8f5ed8d --- /dev/null +++ b/apps/api/src/modules/auth/jwt.strategy.ts @@ -0,0 +1,63 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { PrismaService } from '../../prisma/prisma.service'; +import { expandWithParents } from './acl.constants'; + +export interface JwtPayload { + sub: string; + mobile: string; +} + +/** + * The authenticated principal attached to `req.user`. + * + * `permissions` is already expanded with ancestors, so guards can test a rid with + * a plain `includes()` — the same shape the legacy frontend held in its Vuex store + * after checkParent(). + */ +export interface AuthenticatedAdmin { + id: string; + fullName: string; + mobile: string; + role: string; + userTypeId: number | null; + branchId: string | null; + permissions: string[]; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(private readonly prisma: PrismaService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET ?? 'dev-only-insecure-secret', + }); + } + + async validate(payload: JwtPayload): Promise<AuthenticatedAdmin> { + const user = await this.prisma.adminUser.findUnique({ + where: { id: payload.sub }, + include: { userType: { include: { permissions: true } } }, + }); + + if (!user || !user.isActive) { + throw new UnauthorizedException(); + } + + // 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) ?? []; + + return { + id: user.id, + fullName: user.fullName, + mobile: user.mobile, + role: user.role, + userTypeId: user.userTypeId, + branchId: user.branchId, + permissions: expandWithParents(rids), + }; + } +} diff --git a/apps/api/src/modules/catalog/catalog.controller.ts b/apps/api/src/modules/catalog/catalog.controller.ts index a7ead7c..31cab97 100644 --- a/apps/api/src/modules/catalog/catalog.controller.ts +++ b/apps/api/src/modules/catalog/catalog.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get } from '@nestjs/common'; import { CatalogService } from './catalog.service'; +import { Public } from '../auth/guards'; +@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes @Controller('catalog/products') export class CatalogController { constructor(private readonly catalog: CatalogService) {} diff --git a/apps/api/src/modules/customers/customers.controller.ts b/apps/api/src/modules/customers/customers.controller.ts index cbd9a7e..9e42b92 100644 --- a/apps/api/src/modules/customers/customers.controller.ts +++ b/apps/api/src/modules/customers/customers.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get, Param } from '@nestjs/common'; import { CustomersService } from './customers.service'; +import { Public } from '../auth/guards'; +@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes @Controller('customers') export class CustomersController { constructor(private readonly customers: CustomersService) {} diff --git a/apps/api/src/modules/orders/dto/create-order.dto.ts b/apps/api/src/modules/orders/dto/create-order.dto.ts index d12800f..6c3d828 100644 --- a/apps/api/src/modules/orders/dto/create-order.dto.ts +++ b/apps/api/src/modules/orders/dto/create-order.dto.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer'; -import { ArrayMinSize, IsArray, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator'; +import { OrderChannel } from '@prisma/client'; +import { ArrayMinSize, IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator'; export class CreateOrderItemInputDto { @IsString() @@ -31,6 +32,15 @@ export class CreateOrderDto { @IsString() branchId?: string; + /** + * Which sales channel placed the order. Legacy had no such field because the + * two channels lived in separate tables (orders vs orders_orders); the one + * Order model carries the distinction here. Defaults to RETAIL. + */ + @IsOptional() + @IsEnum(OrderChannel) + channel?: OrderChannel; + @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) diff --git a/apps/api/src/modules/orders/orders.controller.ts b/apps/api/src/modules/orders/orders.controller.ts index f8bf00c..afb2937 100644 --- a/apps/api/src/modules/orders/orders.controller.ts +++ b/apps/api/src/modules/orders/orders.controller.ts @@ -2,7 +2,9 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { OrdersService } from './orders.service'; import { CreateOrderDto } from './dto/create-order.dto'; import { UpdateOrderStatusDto } from './dto/update-order-status.dto'; +import { Public } from '../auth/guards'; +@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes @Controller('orders') export class OrdersController { constructor(private readonly orders: OrdersService) {} diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index 89b9a7c..af274e8 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { OrderStatus, Prisma } from '@prisma/client'; +import { OrderChannel, OrderStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { CatalogService } from '../catalog/catalog.service'; import { LedgerService } from '../accounting/ledger.service'; @@ -80,6 +80,7 @@ export class OrdersService { orderNumber: this.generateOrderNumber(), customerId: dto.customerId, branchId: dto.branchId, + channel: dto.channel ?? OrderChannel.RETAIL, status: initialStatus, totalGoldWeightGrams, wageWeightGrams, diff --git a/apps/api/src/modules/pricing/pricing.controller.ts b/apps/api/src/modules/pricing/pricing.controller.ts index 2b5e1d3..2457abe 100644 --- a/apps/api/src/modules/pricing/pricing.controller.ts +++ b/apps/api/src/modules/pricing/pricing.controller.ts @@ -1,6 +1,8 @@ import { Controller, Get, Query } from '@nestjs/common'; import { PricingService } from './pricing.service'; +import { Public } from '../auth/guards'; +@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes @Controller('pricing') export class PricingController { constructor(private readonly pricing: PricingService) {} diff --git a/apps/api/storage/uploads/.gitkeep b/apps/api/storage/uploads/.gitkeep new file mode 100644 index 0000000..35636d6 --- /dev/null +++ b/apps/api/storage/uploads/.gitkeep @@ -0,0 +1 @@ +# Keep directory in git diff --git a/docs/ADMIN-PORT-PLAN.md b/docs/ADMIN-PORT-PLAN.md new file mode 100644 index 0000000..c669614 --- /dev/null +++ b/docs/ADMIN-PORT-PLAN.md @@ -0,0 +1,1021 @@ +# پلن پورت پنل ادمین — henza_admin → apps/admin-panel + +سند برنامه‌ریزی. **قبل از تأیید شما هیچ کدی نوشته نمی‌شود.** + +## قواعد حاکم بر کل کار (تکرار خواسته‌ی شما) + +1. **بدون دست خوردن** — منطق، فرمول‌ها، نام فیلدها، متن‌های فارسی، ارقام (فارسی/لاتین)، و رفتار RTL دقیقاً مثل legacy. +2. **باگ‌های legacy را بی‌صدا درست نمی‌کنم** — وفادار پورت می‌کنم و جداگانه به شما گزارش می‌دهم. +3. **`henza_admin/`, `henza_server/`, `henza_pwa/` فقط خواندنی** — هرگز تغییر نمی‌کنند. +4. **بخش به بخش** — هر Batch یک checkpoint دارد؛ بدون تأیید شما به Batch بعدی نمی‌روم. +5. هر Batch شامل **هم endpoint های NestJS و هم view های Vue 3** است (تصمیم شما: «بک‌اند همراه هر بخش»). + +--- + +## دامنه‌ی واقعی کار (اعداد راستی‌آزمایی‌شده) + +| سنجه | عدد | نحوه‌ی راستی‌آزمایی | +|---|---|---| +| کل فایل `.vue` در `views/` | 541 | `find views -name '*.vue' \| wc -l` | +| **view های واقعاً قابل دسترس از روتر زنده** | **69** | استخراج از `router/admin.js` + `router/branch.js` (تصحیح‌شده — رجوع به یادداشت زیر) | +| LOC این ۶۹ view | **18,728** | شمارش برنامه‌ای روی هر ۶۹ فایل | +| فایل روتر با ۰ importer (مرده) | 8 از 10 | حلقه‌ی جست‌وجوی importer | +| route با `meta.role` | 65 (admin) + 14 (branch) | `grep -c` | +| **rid یکتای مصرف‌شده در روتر** | **58** | `grep -oE "role: '...'" \| sort -u` | +| endpoint یکتای API در views+components | 114 | استخراج literal ها | +| کامپوننت `.vue` در `components/` | 56 | `find components` | +| از آن‌ها: fork های تکراری XTable | 4 نسخه | `x-table/`, `x-table_select/`, `custom-xtable-select/`, `backup-table/` | +| schema ستون در `src/table/` | 18 فایل | `ls table/` | +| ماژول Vuex ثبت‌شده در `store.js` | **11** از 15 پوشه | خواندن `store.js` | + +**۴۸۹ فایل باقی‌مانده = دموهای قالب Vuexy** (`views/components/vuesax/*`, `views/forms/*`, `views/charts-and-maps/*`, `views/ui-elements/*`, `views/apps/{chat,email,todo,calendar}`) — طبق تصمیم شما پورت نمی‌شوند. + +--- + +## دو تنشی که باید الان تصمیم‌شان را بدانید + +### تنش ۱ — «بدون دست خوردن» در برابر کد ۹۵٪ تکراری + +پنج فایل لیست عمده تقریباً یکسان‌اند: + +``` +BankerList.vue 620 +ShopkeeperList.vue 614 +OmdeList.vue 614 +OmdeNonVerifiedList.vue 612 +OmdeArchivedList.vue 612 + ------ + 3,072 LOC +``` + +تفاوت واقعی‌شان فقط در یک فیلتر و چند ستون است. همچنین ۴ نسخه‌ی XTable (~۱٬۳۰۰ LOC) و ۷ کامپوننت نازک Edit. + +**پیشنهاد من:** رفتار و ظاهر ۱۰۰٪ حفظ شود، ولی ۵ لیست عمده روی یک کامپوننت مشترک با prop های config سوار شوند و فقط یک XTable نگه داشته شود (نسخه‌ی `x-table/` که کامل‌ترین است و `QuerySelectTableFilter` دارد). خروجی کاربر ذره‌ای فرق نمی‌کند. +**گزینه‌ی جایگزین:** هر ۵ فایل عیناً و جداگانه پورت شوند (وفاداری فایل‌به‌فایل، ولی ۳٬۰۰۰ خط تکراری در پروژه‌ی نو). + +### تنش ۲ — توکن‌های طراحی ادمین در برابر user-app + +| توکن | henza_admin | apps/user-app (ساخته‌شده) | +|---|---|---| +| secondary | `#836447` | `#8D6A46` | +| فونت | `p-yek` (Iran Yekan) | `yekan_bakh` | + +**پیشنهاد من:** admin-panel توکن‌های خودِ ادمین را بگیرد (`#836447` + `p-yek`) تا وفادار بماند؛ user-app دست نخورد. دو اپ جدا هستند و لازم نیست یکی شوند. + +--- + +## ساختار فازها + +### Phase 0 — پایه‌ی `apps/admin-panel` +- پاک‌سازی scaffold فعلی: **۱۱ artifact بیلد** (`.js` / `.d.ts` کنار `.ts`) + `tsconfig.tsbuildinfo` حذف می‌شوند؛ ۳ view انگلیسی موجود (`DashboardView`, `OrderDetailView`, `OrderNewView`) کنار می‌روند چون معادل legacy دارند. +- Vite 5 + Vue 3 + Pinia + ant-design-vue 3 + Less با `modifyVars`. +- پورت `assets/scss/ara/ant.less` (426 خط) و `farsi.scss`، فونت‌های `p-yek`، `rtl: true`. +- `isBranch()` → `import.meta.env.MODE === 'branch'` + یک script جدا در `package.json` برای حالت شعبه. +- **checkpoint:** dev server بالا می‌آید، صفحه‌ی خالی با تم و فونت درست رندر می‌شود. + +### Phase 1 — احراز هویت و ACL در بک‌اند +شکاف اصلی: `AdminUser.role` یک enum تک‌مقداری است و مدل `permission(rid, ut)` را نمی‌تواند نمایندگی کند. +- افزودن به Prisma: `UserType` و `Permission { rid, userTypeId }` با primary key مرکب — عیناً مطابق مهاجرت `2021_09_18_115342_permissions_table.php`. +- `AdminUser.userTypeId` اضافه می‌شود (enum فعلی برای سازگاری فعلاً می‌ماند). +- endpoint ها: `POST /admin/auth/login`، `GET /admin/users/show` (که مثل `UserController::showData` لیست `rid` را برمی‌گرداند)، JWT + guard. +- seed برای ۵۸ rid. +- **checkpoint:** لاگین واقعی با curl، و `users/show` لیست permission برمی‌گرداند. + +### Phase 2 — لایه‌های مشترک فرانت +- axios client + **لایه‌ی نگاشت** (تصمیم شما: REST تمیز در بک‌اند + mapping در فرانت) — این لایه‌ی نگاشت جایی است که ۱۱۴ آدرس بی‌قاعده‌ی legacy (`/const/__list__ut`، `delete/product/:id` با POST، `get/variants` با POST، `single/order-sts/edit`) به endpoint های تمیز جدید ترجمه می‌شوند. +- Pinia: `auth`, `users` (شامل **`checkParent()` عیناً**), layout. +- روتر + گارد برای هر دو حالت. **دو باگ legacy را عیناً پورت نمی‌کنم چون در Vue Router 4 اپ را قفل می‌کنند** — `router.push` بدون `next()` (خط ۱۹۸) و `roles` که هنگام logout باطل نمی‌شود (خط ۱۶۲). این‌ها را در گزارش باگ فهرست می‌کنم؛ رفتار کاربر یکسان می‌ماند. +- `navMenuItems.js` (611 خط: `adminMenu` + `branchMenu`) به‌صورت داده پورت می‌شود. +- Layout ها: `Main.vue` (281 خط)، `FullPage.vue`، منوی عمودی و افقی، navbar. +- یک XTable + فیلترها + ۱۸ schema ستون. +- کامپوننت‌های مشترک: `DateInput` (جلالی)، `QuerySelector`، `TooltipBtn`، آپلودها، `ZoomableImage`، `goldPrice`، `Moshtarian`، `ImportExcel`. +- **checkpoint:** لاگین می‌شوید، منو با permission های واقعی فیلتر می‌شود، یک جدول خالی رندر می‌شود. + +### Phase 3+ — Batch های view (هر کدام با endpoint خودش) + +| Batch | محتوا | view | LOC تقریبی | +|---|---|---|---| +| 3 | سفارش‌ها | `order/{List,create,MakeOrder,OmdeList,TaminList}` | ~1,530 | +| 4 | مشتریان عمده | ۵ لیست عمده + `ReagentList` | ~3,475 | +| 5 | مشتریان تک + نظرات | `AddCustomer`, `single-customer/*`, `Comments` | ~900 | +| 6 | محصولات | `AddProduct` (1,044), `EditProduct`, `ProductList`, `ChangePercent` | ~2,100 | +| 7 | دسته‌ها و کالکشن‌ها | `category/{Add,Edit,List}` | ~900 | +| 8 | اسناد مالی و کیف پول | `finical/*`, `wallet`, `manage/wallet` | ~600 | +| 9 | تخفیف‌ها و promotion | `promotion/*`, `offersIgnore` | ~500 | +| 10 | کاربران و دسترسی‌ها | `user/{ListUser,UserCreate,UserEdit,Permission,myData}` | ~700 | +| 11 | شعبه‌ها و کارگاه‌ها | `branch/{Add,List}`, `workshop/{Add,List}` | ~500 | +| 12 | فاکتورها | `invoice/{Add,Edit,List}` | ~800 | +| 13 | متفرقه | `city/*`, `sms`, `notify`, `newsletters`, `statics`, `manage/{Const,report}` | ~2,200 | +| 14 | داشبوردها | `DashboardECommerce` (499), `DashboardAnalytics` (342) | ~840 | +| 15 | حالت شعبه | `branch/{login,dashboard,addCustomer,buyList,order/create}` | ~900 | + +هر Batch: endpoint های NestJS → view ها → checkpoint (بیلد + `vue-tsc` + fetch زنده‌ی API + مقایسه‌ی چشمی با legacy). + +### Phase 16 — ممیزی نهایی +گزارش کمّی: view های رندرشده، endpoint های پاسخ‌ده، rid های پوشش‌داده‌شده، و **فهرست کامل باگ‌های legacy که وفادار پورت شدند** (تا اینجا ۴ مورد شناسایی شده: دو باگ روتر بالا، `token`/`user` که در `moduleAuth.js` تابع‌اند نه مقدار، و reset فونت در `farsi.scss` که وزن هدینگ‌ها را صاف می‌کند). + +--- + +## چیزی که پورت نمی‌شود (و چرا) +- ۴۸۹ فایل دموی قالب Vuexy — تصمیم خودتان. +- ۸ فایل روتر مرده (`apps.js`, `category.js`, `customer.js`, `dashboard.js`, `full.js`, `manage.js`, `order.js`, `product.js`, `temp.js`) — ۰ importer دارند. +- ۴ ماژول Vuex ثبت‌نشده (`todo`, `calendar`, `chat`, `email`) — در خود legacy comment شده‌اند. + +--- + +# گزارش باگ‌های legacy (به‌روزرسانی زنده) + +این‌ها **وفادار پورت شده‌اند** یا برای پورت وفادار علامت خورده‌اند — هیچ‌کدام بی‌صدا اصلاح نشده. طبق قاعده‌ی شماره‌ی ۲. + +| # | باگ | محل | وضعیت در پورت | +|---|---|---|---| +| 1 | `router.push` بدون `next()` داخل `beforeEach` | `router.js:198` | در Vue Router 4 روتر را قفل می‌کند. Phase 2 با `next({path:'/auth/login'})` معادل‌سازی می‌شود؛ رفتار کاربر یکسان. | +| 2 | `roles` در module-scope که هنگام logout باطل نمی‌شود | `router.js:162,190` | کاربر بعدی دسترسی‌های کاربر قبلی را می‌گیرد. Phase 2 داخل store نگه داشته و روی logout پاک می‌شود. | +| 3 | `token` و `user` در state به‌جای مقدار، **تابع** تعریف شده‌اند | `store/auth/moduleAuth.js` | `store.state.auth.token` همیشه truthy است، پس شرط `isLogin` در `getPermission()` بی‌اثر است. وفادار پورت می‌شود. | +| 4 | هر ۵ بلاک `@font-face` به همان `iran-yekan-400.woff2` اشاره می‌کنند | `assets/scss/ara/farsi.scss` | فایل `iran-yekan-700.woff2` وجود دارد ولی هرگز بارگذاری نمی‌شود؛ متن bold با وزن معمولی رندر می‌شود. **عیناً پورت شد.** | +| 5 | `format("woff")` برای فایل `.woff2` | `farsi.scss` | راهنمای فرمت غلط. **عیناً پورت شد.** | +| 6 | `font-weight: inherit` در ریست `body, *` | `farsi.scss` | وزن پیش‌فرض همه‌ی هدینگ‌ها (h1..h6) صاف می‌شود. **عیناً پورت شد.** | +| 7 | منو `permissionId:"newsletters.newsletters"` دارد ولی route اش `role: 'newsletters'` است — و آن هم **کامنت شده** | `navMenuItems.js:481` / `router/admin.js:851` | هیچ‌وقت با هم مچ نمی‌شوند؛ آیتم خبرنامه برای همه پنهان می‌ماند مگر rid دقیقاً `newsletters.newsletters` داده شود. | +| 8 | منو `permissionId:"wallet.list"` دارد ولی route اش `role: 'wallet'` است | `navMenuItems.js:262` / `router/admin.js:430` | `checkParent` فقط والد می‌سازد نه فرزند، پس `wallet` هرگز `wallet.list` را تولید نمی‌کند. آیتم منو پنهان می‌ماند در حالی که خود صفحه باز می‌شود. | +| 9 | `secondary-color` و `third-color` در `modifyVars` متغیر واقعی ant نیستند | `vue.config.js` | بی‌اثرند (در خود legacy هم بی‌اثر بودند). راستی‌آزمایی شد: در `default.less` وجود ندارند. **رفتار یکسان حفظ شد.** | + +## یافته‌ی مهم درباره‌ی ACL +`checkParent()` از ۵۸ rid دانه‌درشتِ روترها، **۸۰ rid** می‌سازد (فقط والدها). از ۱۹ rid که تنها در منو هستند، ۱۶ تا دقیقاً توسط همین مکانیزم تولید می‌شوند — یعنی درست کار می‌کند. ۳ تای باقی‌مانده: دو مورد باگ واقعی (ردیف ۷ و ۸ بالا) و یکی (`persons.list.client.`) کامنت‌شده و غیرفعال است. + +**پس seed دسترسی‌ها باید ۵۸ rid پایه باشد، نه ۸۰** — بقیه در زمان اجرا توسط `checkParent()` ساخته می‌شوند. + +--- + +# وضعیت اجرا + +## Phase 0 — پایه‌ی apps/admin-panel ✅ +Vite 5 + Vue 3 + Pinia + ant-design-vue 3 + Less. پاک‌سازی ۱۷ artifact بیلد و ۳ view انگلیسی. +`modifyVars` عیناً از `vue.config.js`؛ `ant.less` بایت‌به‌بایت (MD5 یکسان)؛ فونت‌های yekan؛ `isBranch()` روی `import.meta.env.MODE`. +**راستی‌آزمایی:** `vue-tsc` تمیز · بیلد ادمین و شعبه موفق · `#1c2d56` در ۱۵۱ قاعده‌ی CSS · هر ۱۰ ماژول با content-type درست · بیلد ادمین `vertical` و بیلد شعبه `horizontal`. + +## Phase 1 — احراز هویت و ACL در بک‌اند ✅ +**Prisma:** مدل‌های `UserType` و `Permission` با کلید اصلی مرکب `(rid, userTypeId)` — منطبق با `primary(['rid','ut'])` legacy. `AdminUser.userTypeId` اضافه شد. در دیتابیس واقعی راستی‌آزمایی شد. + +**NestJS:** ماژول `auth` با `POST /api/admin/auth/login` (معادل `admin-login`) و `GET /api/admin/auth/me` (معادل `users/show`)، JWT، `JwtAuthGuard` سراسری با `@Public()`، و `PermissionsGuard` با `@RequirePermission(rid)`. + +**`expandWithParents()`** = پورت دقیق `checkParent()` در بک‌اند، ولی pure (بدون متغیر module-scope که در legacy باگ داشت). + +**seed:** ۵ سطح دسترسی (۸۵ مدیر ارشد با ۵۸ rid، ۸۶ کارشناس فروش، ۸۷ حسابدار، ۸۸ سرپرست کارگاه، ۸۹ کاربر شعبه)، ۹۰ ردیف permission، هر ۵ ادمین متصل. رمز توسعه: `admin1234`. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| لاگین درست | 200، JWT واقعی، ۵۸ rid، `ut=85` | +| رمز اشتباه | 401 | +| شماره‌ی ناموجود | 401 (بدون افشای شماره) | +| فیلد ناقص | 400 | +| `/me` بدون توکن | 401 | +| `/me` با توکن نامعتبر | 401 | +| `/me` با توکن معتبر | نام + `user_type` + ۵۸ rid | +| گسترش والدها (کارشناس فروش) | ۱۲ → ۱۸ rid؛ والدهای درست تولید شد | +| ۱۴ endpoint قبلی user-app | همچنان 200 بدون توکن | + +## انحراف عمدی از legacy (امنیتی، نه رفتاری) +legacy برای شماره‌ی ناموجود **404** و برای رمز غلط **422** برمی‌گرداند — یعنی هر کسی می‌توانست شماره‌ی ادمین‌های واقعی را حدس بزند. اینجا هر دو **401** شده‌اند. رفتار کاربر عادی تغییری نمی‌کند. + +## نکته درباره‌ی ۱۴ endpoint موجود +با سراسری‌شدن guard، همه‌ی آن‌ها با `@Public()` علامت خوردند تا **دقیقاً** رفتار فعلی حفظ شود (user-app نشکند). این‌ها از قبل بدون احراز هویت بودند؛ این یک شکاف موجود است نه چیزی که Phase 1 ساخته باشد. بستنش خارج از دامنه‌ی پورت ادمین است و جدا باید تصمیم‌گیری شود. + +## Phase 2 — لایه‌های مشترک فرانت ✅ + +**تصحیح عدد دامنه:** شمارش قبلی (۵۲ view) اشتباه بود — grep فقط import های تک‌خطی با پسوند `.vue` را می‌دید. شمارش برنامه‌ای و چندخطی نشان می‌دهد روترهای زنده **۶۹ view یکتا** با **۱۸٬۷۲۸ خط** ارجاع می‌دهند و هر ۶۹ فایل در legacy موجودند. این با برآورد اولیه‌ی «~۱۸٬۷۰۰ خط» می‌خواند. + +**پورت مکانیکی (نه دستی).** سه دارایی پرخطر با اسکریپت تبدیل شدند تا متن فارسی و ساختار دقیقاً حفظ شود: + +| فایل | راستی‌آزمایی | +|---|---| +| `navMenuItems.ts` | شمارش هر ۷ فیلد یکسان (permissionId ۷۶، i18n ۷۸، url ۷۶، slug ۵۳، icon ۳۳، submenu ۱۹، header ۲) و **هر ۷۸ برچسب فارسی بایت‌به‌بایت یکسان** (diff خالی) | +| `router/admin.ts` | path ۷۱، name ۴۶، role ۶۵، pageTitle ۳۵، breadcrumb ۳۲ — همه یکسان | +| `router/branch.ts` | path ۱۷، role ۱۴ — یکسان | + +**ساخته شد:** `stores/{auth,users,layout}.ts`، `api/{client,endpoints}.ts`، `router/index.ts` با گارد پورت‌شده، `layouts/{main/Main,full-page/FullPage,components/{VerticalNavMenu,TheNavbar}}.vue`، `views/auth/Login.vue`، ۴ صفحه‌ی خطا، و `_NotPorted.vue`. + +**`_NotPorted.vue`:** هر ۶۸ view باقی‌مانده مسیر و ACL شان از همین حالا زنده است و صفحه‌ای می‌بینید که نام فایل legacy مرجع را نشان می‌دهد. با آمدن هر batch، فایل واقعی جایش را می‌گیرد — بدون دست زدن به روتر. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| `vue-tsc` | تمیز | +| بیلد | موفق، با code-splitting درست (Main/Login/FullPage/error ها chunk جدا) | +| لاگین از طریق proxy ویت | 200، ۵۸ rid | +| لایه‌ی نگاشت | ۷/۷ (شامل نرمال‌سازی اسلش و حفظ query) | +| فیلتر منو: مدیر ارشد | ۵۸ rid → ۸۰ | منو: ۱۶ گروه، ۴۷ آیتم | +| فیلتر منو: کارشناس فروش | ۱۲ → ۱۸ | منو: ۴ گروه، ۱۴ آیتم | +| فیلتر منو: حسابدار | ۱۱ → ۱۷ | منو: ۵ گروه، ۹ آیتم | +| فیلتر منو: بدون دسترسی | ۰ آیتم | +| سرو شدن ۱۱ ماژول | همه با content-type درست | + +**تأیید یک رفتار legacy:** با دادن **فقط** `persons`، دقیقاً دو آیتمی که `permissionId` شان در legacy کامنت شده (لیست بنکداران، لیست مغازه داران حضوری) دیده می‌شوند — چون قاعده‌ی legacy این است که گره‌ی بدون `permissionId` عمومی است، ولی والدِ دارای مجوز همچنان آن‌ها را پنهان می‌کند. عیناً حفظ شد. + +**دو باگ مسدودکننده‌ی legacy که در گارد اصلاح شدند** (بدون تغییر رفتار کاربر): `router.push` بدون `next()` → بازگرداندن redirect؛ و `roles` ماژول‌سطح → کش در Pinia که `logout()` پاکش می‌کند. + +**اضافه‌ی خارج از legacy:** interceptor پاسخ برای 401. legacy هیچ interceptor پاسخی نداشت، پس توکن منقضی باعث می‌شد همه‌ی درخواست‌ها بی‌صدا شکست بخورند بدون راه بازگشت به لاگین. + +## Batch 3 — سفارش‌ها + XTable ✅ (جزئی) + +**بک‌اند:** ماژول `admin-table` (قرارداد XTable → Prisma) و `admin-orders` با ۴ route: +`POST admin/orders/list`، `GET admin/orders/:id`، `POST admin/orders/status`، `GET admin/orders/status-options`. + +**فرانت:** `components/x-table/{XTable.vue, model.ts}` (یک نسخه، نه ۴ تا — طبق تصمیم شما)، `TooltipBtn.vue`، `utils/filters.ts`، `views/order/{List.vue, orderListModel.ts}`. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| لیست سفارش (قرارداد XTable) | ۳ ردیف، ساختار `{rows, query}` درست | +| بدون توکن | 401 | +| حسابدار (بدون `order.list`) | **403** ← گارد دسترسی کار می‌کند | +| فیلتر رابطه‌ای `customer.fullName ~ علی` | ۱ ردیف | +| جستجوی آزاد «علی» / «zzzz» | ۱ / ۰ | +| فیلتر enum، فیلتر تاریخ between، مرتب‌سازی ±createdAt | درست | +| فیلتر روی فیلد غیرمجاز (`passwordHash`) | **400** ← whitelist کار می‌کند | +| خروجی excel | 400 با پیام صریح (نه url مرده) | +| جدول زمانی وضعیت | ۶ مرحله، علامت‌گذاری درست | +| `priceUnit` در برابر legacy | ۹/۹ یکسان (شامل `priceUnit(0)`) | +| بیلد + `vue-tsc` + `tsc` | تمیز | + +**یک باگ واقعی که پیدا و اصلاح شد:** اسکوپ شعبه را اولش بر اساس «آیا کاربر `branchId` دارد» گذاشته بودم. چون مدیر ارشد هم شعبه‌ی خانگی دارد، نتیجه این شد که دفتر مرکزی فقط ۱ سفارش از ۳ تا را می‌دید. حالا بر اساس **دسترسی** تصمیم گرفته می‌شود (`buy.order.list` بدون `order.omde` = کاربر شعبه). راستی‌آزمایی شد: ۳/۳. + +**یک درس روش‌شناختی:** فیلتر فارسی اول ۰ ردیف برمی‌گرداند. علتش کد نبود — `curl` متن فارسی را به `???` تبدیل می‌کرد. با ارسال از Node درست کار کرد. **ابزار تست هم می‌تواند دروغ بگوید.** + +### باگ‌های جدید legacy (ادامه‌ی جدول) +| # | باگ | محل | وضعیت | +|---|---|---|---| +| 10 | `hideSearch()` به‌جای فلگ جستجو، `_hidePageination` را ست می‌کند | `x-table/model/XTbl.js:51-54` | صفربار فراخوانی شده، پس اصلاحش رفتاری را عوض نمی‌کند. اصلاح شد. | +| 11 | حذف وضعیت «تحویل به نماینده» بدهی ثبت‌شده در دفتر کل را پاک نمی‌کرد | `single/order-sts/edit` با `remove:true` | **عیناً پورت نشد.** این مسیر داده را خراب می‌کند (مشتری بدهکار می‌ماند برای سفارشی که دیگر تحویل‌شده نیست). حالا با پیام فارسی رد می‌شود. | + +### آنچه در Batch 3 پیاده نشد (صادقانه) +- **۴ view دیگر سفارش‌ها** (`create`, `MakeOrder`, `OmdeList`, `TaminList`) — فقط `List.vue` آمد. +- **خروجی excel/json/print** — نیازمند endpoint ی که فایل بسازد؛ فعلاً 400 صریح می‌دهد نه دکمه‌ی مرده. +- **خروجی PDF سفارش** — legacy مستقیم به بک‌اند PHP وصل بود. +- **ویرایش سفارش** — در خود legacy هم فراخوانی PUT اش کامنت شده بود؛ عیناً پورت شد (فقط UI toggle). + +## Batch 3 — تکمیل شد (۴ از ۵ view) ✅ + +**view های اضافه‌شده:** `OmdeList.vue` (لیست عمده)، `TaminList.vue` (لیست تامین)، `MakeOrder.vue` (سفارش‌های ساختنی) + مدل‌های جدولشان. + +**بک‌اند اضافه‌شده:** `admin-preorders` (۵ route برای PreOrder)، `admin-workshops` (لیست)، `admin-cities` (لیست)، و ۳ route جدید روی `admin-orders`: `wholesale/list`، `tamin/list`، `delete`. + +**store اضافه‌شده:** `stores/cart.ts` — پورت کامل `store/cart/cart.js` با هر دو گارد موجودی legacy. + +**راستی‌آزمایی اجراشده:** +| endpoint | نتیجه | +|---|---| +| `admin/orders/list` | ۳ ردیف | +| `admin/orders/wholesale/list` | ۳ ردیف | +| `admin/orders/tamin/list` | **۲ ردیف** (فقط سفارش‌هایی که به SOURCING رسیده‌اند — فیلتر واقعاً کار می‌کند) | +| `admin/pre-orders/list` | ۲ ردیف، ارقام فارسی حفظ شد (۱۸، ۱۶) | +| `admin/workshops/list` / `admin/cities/list` | ۱ / ۴ ردیف | +| فیلتر روی `colorName ~ رزگلد` | ۱ ردیف | +| حسابدار روی wholesale و tamin | **403** | +| بیلد + `vue-tsc` + `tsc` | تمیز؛ هر ۴ view chunk جدا | + +### دو تصحیح مهم روی کار قبلی خودم + +**۱. اسکوپ شعبه را اشتباه «اصلاح» کرده بودم.** در گزارش قبلی گفتم منطق legacy غلط است و بر اساس دسترسی بازنویسی‌اش کردم. **اشتباه بود.** `AdminController::getSingleOrders` دقیقاً می‌گوید: +```php +if (!is_null(auth()->user()->branch_id)) { + $records->where('branch_id', auth()->user()->branch_id); +} +``` +یعنی هر کاربری که `branch_id` دارد فقط شعبه‌ی خودش را می‌بیند. حالا عیناً همین پیاده شده. مشکل واقعی در **seed** بود که به مدیر ارشد شعبه داده بود؛ دفتر مرکزی باید `branchId: null` داشته باشد. seed اصلاح شد. +راستی‌آزمایی: دفتر مرکزی ۳ سفارش، کاربر شعبه ۱ سفارش. + +**۲. `filterSelect` را با امضای غلط پورت کرده بودم.** legacy یک **object map** می‌گیرد (`{0:'معلق', 1:'ثبت سفارش'}`) و در `filterItems` نگه می‌دارد؛ من آرایه گرفته بودم. با مدل‌های Omde/Tamin می‌شکست. اصلاح شد و XTable حالا برای این ستون‌ها dropdown و برای ستون تاریخ date-picker رندر می‌کند. + +### یافته‌ی معماری مهم — نیاز به تصمیم شما +legacy سفارش‌ها را در **دو جدول جدا** نگه می‌داشت: +- `orders_orders` (مدل `SingleCustomerOrders`) → لیست تک‌فروشی +- `orders` (مدل `Order`) → لیست عمده + +schema جدید **یک مدل `Order`** دارد. + +> **✅ بسته شد.** `Order.channel` (`RETAIL | WHOLESALE`) اضافه شد و هر لیست روی آن فیلتر می‌کند. +> آنچه قبلاً اینجا نوشته بودم («در عمل رفتار legacy هم بود چون فیلتر نوعی نداشت») **دقیق نبود**: +> هیچ‌کدام از دو کنترلر فیلتر نداشتند چون روی **دو جدول جدا** کار می‌کردند +> (`orders` و `orders_orders`)، پس دو لیست هرگز هم‌پوشانی نداشتند. جزئیات در انتهای سند. + +### باگ legacy جدید +| # | باگ | محل | وضعیت | +|---|---|---|---| +| 12 | حذف سفارش عمده، اسناد حسابداری‌اش را یتیم می‌کرد | `delete-order` | **عیناً پورت نشد** — اگر سند ثبت شده باشد با پیام فارسی رد می‌شود. | +| 13 | حذف سفارش ساختنیِ تبدیل‌شده، پیوند حسابداری را می‌شکست | `custom-order/{id}` | همان رویکرد. | + +### باقی‌مانده‌ی Batch 3 +`create.vue` (۴۵۲ خط — ثبت سفارش) پورت **نشد**: به گرید محصولات، انتخاب تنوع، و منطق promotion وابسته است که در Batch 6 می‌آید. `stores/cart.ts` که پیش‌نیازش بود آماده است. منطقی‌تر است همراه Batch 6 بیاید. + +## Batch 4 — مشتریان عمده ✅ + +**تصمیم «کامپوننت مشترک» اجرا شد و نتیجه‌اش قابل اندازه‌گیری است:** + +| | legacy | پورت‌شده | +|---|---|---| +| ۵ لیست عمده | **۳٬۰۷۲ خط** | **۵۷۲ خط** (کامپوننت مشترک + ۵ wrapper + مدل) | +| اندازه‌ی chunk هر wrapper | — | ۰٫۳۷ تا ۰٫۴۲ کیلوبایت | +| رفتار و ظاهر | — | یکسان | + +**اثبات تکراری بودن (با diff، نه حدس):** +- `ShopkeeperList.vue` در برابر `OmdeList.vue`: **دقیقاً ۴ خط** تفاوت (فقط عنوان کارت + import مدل) +- `BankerList` ۱۸ خط، `OmdeArchivedList` ۲۴ خط، `OmdeNonVerifiedList` ۳۴ خط — بقیه‌اش whitespace و ترتیب متدها +- ۵ فایل مدل: **دقیقاً یک خط** تفاوت (url) + +**ساخته شد:** +- بک‌اند: ماژول `admin-customers` با ۱۲ route — ۵ لیست عمده + reagent + toggle-active/sts + verify + dashboard + archive + credit +- فرانت: `OmdeCustomerList.vue` (مشترک)، `omdeListModel.ts` (کارخانه)، ۵ wrapper نازک، و `ReagentList.vue` + مدلش + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| ۵ لیست عمده + reagent | همه 200؛ Omde/Banker/Shopkeeper ۶ ردیف، NonVerified **۲**، Archived **۰**، Reagent **۲** | +| نام فیلدهای legacy در پاسخ | `name, gram_level, remain, rial_remain, sts_verify_step_one/two, caj, uaj, active` — همه موجود | +| جریان تایید دو مرحله‌ای | تایید مرحله‌ی یک → `one=2` (که مرحله‌ی دو را باز می‌کند) → تایید مرحله‌ی دو → `two=2` ✓ | +| toggle-active رفت و برگشت | `false` → `true` ✓ | +| dashboard | سفارش‌ها، سفارش باز، اسناد، سطح اعتبار ✓ | +| فیلتر با نام‌های legacy (`name`, `active`, `gram_level`) | همه درست ترجمه شدند | +| فیلتر روی `passwordHash` | **400** | +| حسابدار روی هر ۳ لیست | **403** | +| **کارشناس فروش** | روی `list` **200**، روی `reagent/list` **403** — چون فقط `persons.list.client.omde` دارد نه `persons.list.sellers` | + +آخرین ردیف مهم‌ترین است: یک کاربر واحد روی یک endpoint اجازه دارد و روی دیگری ندارد — یعنی ACL واقعاً دانه‌درشت کار می‌کند نه صرفاً «لاگین شده یا نه». + +### باگ/کوییرک legacy که وفادار پورت شد +| # | مورد | محل | وضعیت | +|---|---|---|---| +| 14 | در `OmdeNonVerifiedList` هر دو شاخه‌ی popconfirm مرحله‌ی یک `rejectStepOne` را صدا می‌زنند — یعنی از آن صفحه هرگز نمی‌شود تایید کرد | `OmdeNonVerifiedList.vue:64` | **عیناً پورت شد** پشت prop به نام `rejectOnlyStepOne` | +| 15 | در منوی «حساب کاربری»، آیکون از `getStsIcon` (وضعیت سفارش) گرفته می‌شود ولی رنگش از `getActiveIcon` | `OmdeList.vue:124` | عیناً پورت شد | +| 16 | `ReagentListModel` ستون `name` را دو بار اضافه می‌کند (یکی نمایان، یکی مخفی) | `ReagentListModel.js:10-11` | عیناً پورت شد | +| 17 | tooltip «فعال سازی سفارش سفارش» (کلمه تکراری) | `ReagentList.vue:51` | عیناً پورت شد | + +### شکاف‌های schema که باید بدانید +۱. **بنکدار در برابر مغازه‌دار:** legacy این‌ها را با `Customer.type` جدا می‌کرد که در طراحی جدید حذف شده. الان هر دو لیست کامل را برمی‌گردانند. +۲. **بایگانی:** ~~schema جدید ستون جدا ندارد~~ → **✅ `Customer.isArchived` اضافه شد.** +۳. **دو سوییچ مجزا:** ~~هر دو یک فیلد را می‌زنند~~ → **✅ `canPlaceOrders` جدا از `isActive`.** +۴. ستون‌های `phone`, `address`, `store_name`, `zone`, `pasazh_name` → **✅ همه اضافه شدند** (به‌علاوه `memberCode`). + +**هر سه مورد بسته شد** — جزئیات و تأیید اجرایی در انتهای سند. + +## Batch 5 — مشتریان تک + نظرات ✅ + +**مدل جدید در Prisma:** `Comment` + enum `CommentStatus`. schema جدید اصلاً مدل نظر نداشت، ولی `Comments.vue` یکی از view های این batch بود. کدهای عددی legacy (۱=در انتظار، ۲=تایید، ۳=رد) روی سیم حفظ شدند تا view پورت‌شده بدون تغییر کار کند، ولی در دیتابیس enum ذخیره می‌شود. + +**ساخته شد:** +- بک‌اند: ماژول `admin-comments` (۶ route) + ۵ route جدید روی `admin-customers` (`single/list`, `create`, `query/reagent`, `wallet/charge`, `PUT :id`) +- فرانت: `Comments.vue` + مدلش، `SingleCustomer.vue` + مدلش، `AddCustomer.vue` + `bulkCustomer.vue` + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| لیست مشتریان تک | ۶ ردیف | +| لیست نظرات | ۳ ردیف با کدهای عددی درست (sts=1/2/3 + برچسب فارسی) | +| `query/reagent` | ۲ کارشناس فروش | +| گردش تایید نظر | approve→APPROVED، reject→REJECTED، بازگشت به pending ✓ | +| پاسخ ادمین (threading) | `replyToId` به والد وصل شد و در آرایه‌ی `reply` والد ظاهر شد ✓ | +| فیلتر وضعیت با کد عددی | sts=1→۱، sts=2→۲، sts=3→۱ ردیف | +| حذف پاسخ | `{success:true}` | +| حسابدار | **403** روی هر دو | +| کارشناس فروش | **200** روی نظرات و مشتریان تک، **403** روی `wallet/charge` (چون rid `wallet` ندارد) | +| بیلد + هر دو typecheck | تمیز | + +### یافته‌ی مهم درباره‌ی AddCustomer +فایل legacy اش ۲۷۸ خط است ولی **تقریباً همه‌اش کامنت شده**. محتوای زنده فقط یک تب است که `<bulk-customer/>` را در بر می‌گیرد. تب «مشتری تک» و کل فرم درون‌خطی زیرش در خود legacy کامنت‌اند و اینجا هم **زنده نشدند**. فرم واقعی در `bulkCustomer.vue` است (۳۸۵ خط) با فیلدهای: `fname, lname, ncode, password, mobile, store_name, phone, pasazh_name, city_id, mcode, address, reagent_id, percent, gram_level`. + +### شکاف‌های schema در این batch +| مورد | وضعیت | +|---|---| +| `store_name`, `pasazh_name`, `mcode`, `address`, `phone` | **✅ بسته شد** — ستون‌ها اضافه و در create/update وصل شدند | +| آپلود تصویر (`MyUpload`) | **✅ بسته شد** — `ImageUploadInput.vue` در دسته‌بندی، اسلایدر و آواتار کارشناس فروش وصل است | +| `DateInput` جلالی | حذف شد چون فیلد متناظر ندارد (جعل نکردم) | +| `wallet/charge` | legacy مبلغ ریالی می‌گرفت؛ `WalletBonus` گرم طلا می‌خواهد و `expiresAt` اجباری دارد — یک‌ساله گذاشتم | +| ایمیل مشتری تک | ستون ندارد، `null` برمی‌گردد | + +اگر می‌خواهید این ستون‌ها واقعاً به schema اضافه شوند بگویید — الان ۹ تصمیم schema روی هم جمع شده. + +## Batch 6 — محصولات ✅ (۴ از ۵) + +**۴ ستون جدید در schema** — این‌ها واقعاً لازم بودند نه تزئینی: +| ستون | چرا | +|---|---| +| `ProductVariant.qty` | گاردِ «موجود نیست» در سبد خرید بدون آن کار نمی‌کند | +| `ProductVariant.isPreOrder` | همان گارد: تنوع پیش‌سفارشی با موجودی صفر هم سفارش‌پذیر است | +| `Product.productCode` | ستون واقعی در گرید محصولات | +| `Product.showType` | فیلتر واقعی (۱ سایت، ۲ گوشی، ۳ هر دو) | + +هشدار data-loss پوش را بررسی کردم: تنها موردش unique روی ستون **جدید و nullable** بود و Postgres چند NULL را مجاز می‌داند، پس داده‌ای در خطر نبود. بعد از پوش راستی‌آزمایی شد: هر ۵ محصول سالم. + +**ساخته شد:** ماژول `admin-products` (۱۴ route) + `ProductList.vue`، `AddProduct.vue` (هم ایجاد هم ویرایش)، `EditProduct.vue` (wrapper نازک — ۰٫۲۶ کیلوبایت)، `ChangePercent.vue`. به XTable هم قابلیت `hasSelection` + `v-model` اضافه شد که ProductList لازم داشت. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| لیست محصولات | ۵ ردیف با `product_code`, `show_type_str`, تصاویر، تعداد تنوع | +| لیست تنوع‌ها | ۹ ردیف | +| برند/دسته/سینی | ۲ / ۵ / ۱ | +| جزئیات (حالت ویرایش) | `body_html`, `cats`, `ciny_ids`, `variants_all` — همه درست | +| ایجاد با تنوع درون‌خطی | ۲ تنوع ذخیره شد با موجودی و پرچم پیش‌سفارش | +| ویرایش (یکی ویرایش، یکی جدید، یکی حذف) | حذف‌شده **غیرفعال** شد نه پاک — چون OrderItem به آن ارجاع دارد | +| گارد حذف محصول | محصولی که در سفارش استفاده شده **400** با پیام فارسی | +| حسابدار | **403** | +| کارشناس فروش | `products/list` **200**، `change-percent` **403**، `create` **403** | +| بیلد + هر دو typecheck | تمیز | + +### یک باگ در کار خودم که پیدا و اصلاح شد +در همگام‌سازی تنوع‌ها، `keptIds` را **قبل** از ساختن تنوع‌های جدید می‌ساختم، پس تنوع تازه‌ساخته‌شده در آن لیست نبود و بلافاصله غیرفعال می‌شد. تست نشانش داد (`ج جدید` با `active=false` برگشت). اصلاح شد و دوباره تست شد: حالا ویرایش‌شده فعال، جدید فعال، و فقط حذف‌شده غیرفعال است. + +### یک اشتباه در روش تست خودم +برای آزمودن گاردِ حذف، روی محصولات حلقه زدم و delete صدا زدم — که محصولات بدون سفارش را **واقعاً حذف کرد** (۵ تا شد ۱ تا). چون seed idempotent است با یک `db seed` کامل برگشت (۵ محصول، ۹ تنوع، ۳ سفارش، ۳ نظر، ۲ پیش‌سفارش سالم). ولی تست مخرب روی داده‌ی واقعی کار درستی نبود. + +### باقی‌مانده‌ی Batch 6 +`order/create.vue` (۴۵۲ خط) پورت **نشد**. سبد خرید (`stores/cart.ts`) و گرید محصولات که پیش‌نیازش بودند آماده‌اند، ولی خودش شامل انتخاب تنوع، منطق promotion، محاسبه‌ی قیمت لحظه‌ای طلا و ثبت سفارش است — یعنی تقریباً یک batch مستقل. صادقانه: برای اینکه درست دربیاید باید جدا بهش بپردازم. + +### فیلدهایی که رندر نشدند (به‌جای اینکه جمع و بی‌صدا دور ریخته شوند) +`delivery_type`, `message_long_delivery`, `offer_day`, `has_warranty`, `is_exist`, `tags`, دو آپلودر تصویر، و رنگ/اجرت ریالی تنوع — هیچ‌کدام ستون متناظر در schema ندارند. + +## Batch 3 و 6 تکمیل شدند — `order/create.vue` ✅ + +سازنده‌ی سفارش ادمین پورت شد، پس هر دو batch حالا کامل‌اند (۵/۵ و ۵/۵). + +**نکته‌ی معماری:** `createFromAdmin` به `OrdersService.createOrder` موجود واگذار می‌کند نه اینکه منطق را دوباره بنویسد. دلیلش این است که فرمول وزن و گیتِ سقف اعتبار آنجا زندگی می‌کنند؛ پیاده‌سازی موازی باعث می‌شد دو مسیر به مرور از هم دور بیفتند. + +**یک تصمیم امنیتی:** legacy مقادیر `total_price`, `total_weight`, `gold_price_original` را در **مرورگر** حساب می‌کرد و می‌فرستاد. این‌ها نادیده گرفته می‌شوند و سرور از روی snapshot تنوع‌ها بازمحاسبه می‌کند — وگرنه یک کلاینت دستکاری‌شده می‌توانست بدهی طلا را تغییر دهد. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| `order/type` | `{1: ثبت سفارش توسط مسئول فروش, 2: ثبت مشتری}` | +| گرید محصولات شامل `thumb` + `variants_all` | بله (۲ تنوع) | +| `product/variant` (rehydrate سبد) | ۱ محصول با ۲ تنوع | +| ثبت سفارش از طریق builder | `ORD-...` ساخته شد | +| **فرمول وزن** | rash ۸٫۱۳۳ × (۱ + ۲/۱۰۰) × qty ۲ = **۱۶٫۵۹۱۳** — انتظار و واقعیت **دقیقاً یکسان** | +| **گیت سقف اعتبار** | سفارش ۸۲۹ گرمی برای مشتری با سقف ۸۰ → **SUSPENDED** نه rejected (رفتار legacy) ✓ | +| **ثبت بدهی** | بدهی از ۰ به ۸٫۲۹۵۷ رفت، دقیقاً برابر وزن سفارش، فقط یک بار، در `DELIVERED_TO_AGENT` ✓ | +| بیلد + هر دو typecheck | تمیز | + +بعد از تست، ۳ سفارش آزمایشی پاک و بدهی صفر شد؛ ۳ سفارش seed و ۵ محصول و ۹ تنوع سالم ماندند. + +### کوییرک legacy که وفادار پورت شد +| # | مورد | محل | وضعیت | +|---|---|---|---| +| 18 | `totalWeight` وزن‌ها را جمع می‌زند **بدون ضرب در تعداد** | `create.vue` computed | عیناً حفظ شد (ستون «مجموع» هر ردیف درست ضرب می‌کند، فقط جمع کل اشتباه است) | +| 19 | سقف تعداد وقتی موجودی صفر است `Infinity` می‌شود | `create.vue` qty slot | عیناً حفظ شد — همین است که تنوع پیش‌سفارشی را سفارش‌پذیر نگه می‌دارد | + +### ستون‌های قیمت که جعل نشدند +legacy از هر تنوع `price` و `price_with_discount` می‌خواند. کاتالوگ جدید این‌ها را ذخیره نمی‌کند (قیمت طلا لحظه‌ای است و اجرت درصدی). به‌جای ساختن عدد ریالی ساختگی، ستون‌ها وزن و درصد اجرت را نشان می‌دهند. + +**وضعیت: ۱۹ از ۶۹ view. Batch های ۳، ۴، ۵، ۶ کامل.** + +## Batch 7 — دسته‌ها و کالکشن‌ها ✅ + +**۵ ستون جدید در Category:** `type` (۱ سایت، ۲ موبایل)، `imageUrl`، `iconUrl`، `metaTag`، `description` — همه ستون یا فیلد واقعی در گرید و فرم legacy بودند. + +**ساخته شد:** ماژول `admin-categories` (۱۲ route — شامل کالکشن‌ها/«سینی») + `List.vue`، `Add.vue` (هم ایجاد هم ویرایش)، `Edit.vue` (wrapper نازک — ۰٫۲۲ کیلوبایت)، `CategoryTbl.ts`. + +### یک تداخل واقعی در لایه‌ی نگاشت که پیدا و حل شد +`POST category` در legacy **یک endpoint است که دو مصرف‌کننده دارد**: گرید دسته‌بندی‌ها، و picker در فرم محصول که `data.rows` را از همان پاسخ می‌خواند. من قبلاً در Batch 6 یک `admin/products/categories` تکراری ساخته بودم. حذف شد و هر دو روی `admin/categories/list` نشستند. راستی‌آزمایی: پاسخ هم `{rows, query}` دارد (نیاز گرید) و هم `rows[].type_str` (نیاز picker). + +همچنین `AddProduct` و `ChangePercent` اصلاح شدند تا مثل legacy عنوان را با پسوند نوع نمایش بسازند: ``x.title + ` (${x.type_str}) ` ``. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| لیست دسته‌ها | ۵ ردیف با `type_str`، پدر، تعداد محصول | +| درخت (picker پدر) | ۵ گره | +| لیست کالکشن‌ها | ۱ ردیف: «پرفروش‌های این هفته (۳ محصول)» | +| ساخت پدر + فرزند | `parentId` درست ست شد | +| **گارد حلقه: خودش به‌عنوان پدر** | **400** «یک دسته نمی‌تواند پدر خودش باشد» | +| **گارد حلقه: زیرمجموعه به‌عنوان پدر** | **400** «دسته‌ی انتخاب‌شده زیرمجموعه‌ی همین دسته است» | +| گارد حذف: دسته با زیردسته | **400** با شمارش | +| گارد حذف: دسته با محصول | **400** با شمارش | +| حسابدار و کارشناس فروش | **403** (هیچ‌کدام `collection.list` ندارند) | +| یکپارچگی داده بعد از تست | ۵ دسته، ۵ محصول، ۳ سفارش سالم | + +### باگ‌های legacy که عیناً پورت نشدند (داده را خراب می‌کردند) +| # | مورد | وضعیت | +|---|---|---| +| 20 | هیچ گارد حلقه‌ای در انتخاب پدر دسته نبود | اگر دسته‌ای زیرمجموعه‌ی خودش می‌شد، هر رندر بازگشتی درخت قفل می‌کرد. حالا ۴۰۰ می‌دهد. | +| 21 | حذف دسته بدون بررسی وابستگی | محصولات یتیم و زیردسته‌های اشاره‌کننده به پدر ناموجود می‌ساخت. حالا رد می‌شود. | + +### کوییرک‌های legacy که عیناً پورت شدند +`CategoryTbl` روی دو ستون متنی (`title`, `parent.title`) `filterNumber()` صدا می‌زند — حفظ شد. + +### چیزی که جعل نشد +دو آپلودر تصویر (تصویر شاخص / آیکون) به‌صورت ورودی URL رندر شدند، چون schema آدرس ذخیره می‌کند و این پورت هنوز endpoint آپلود ندارد — یک file picker که کاری نمی‌کند بدتر از یک ورودی صادق است. + +**وضعیت: ۲۲ از ۶۹ view. Batch های ۳–۷ کامل.** + +## Batch 8 — اسناد مالی و کیف پول ✅ + +این batch مستقیماً به دفتر کل وصل است، پس بیشترین دقت را خواست. + +**یافته‌ی کلیدی:** یک ردیف «cash» در legacy **همان LedgerEntry** است. چهار نوع سند legacy دقیقاً روی enum موجود می‌نشینند: +| کد | عنوان | LedgerSourceType | تبدیل | +|---|---|---|---| +| ۱ | آب شده | `REPAYMENT_MELTED_GOLD` | `value = original_value × ayar / 750` | +| ۲ | حواله | `REPAYMENT_TRANSFER` | گرم بدون تغییر | +| ۳ | چک | `REPAYMENT_CHEQUE` | گرم بدون تغییر | +| ۴ | پولی | `REPAYMENT_CASH` | `value = price / (price_per_gram / 4.331802)` | + +**تصمیم معماری:** ثبت سند به `LedgerService.postGoldRepayment` واگذار می‌شود نه اینکه ریاضیات دوباره نوشته شود. همین سرویس صاحب تبدیل عیار و مظنه و قواعد تایید/تراز است. + +**مدل‌های جدید:** ۹ ستون متادیتا روی `LedgerEntry` (`labName`, `hallmark`, `accountName`, `bankName`, `transferTo`, `originalValue`, `priceRial`, `receivedAt`) + مدل `Setting` (کلید/مقدار، جایگزین `config_static` — Batch 13 هم لازمش دارد). + +**راستی‌آزمایی اجراشده — کل زنجیره‌ی حسابداری:** +| تست | نتیجه | +|---|---| +| فرمول عیار | ۱۰ گرم با عیار ۹۰۰ → **۱۲** دقیقاً | +| فرمول پولی | ۱۰۰٬۰۰۰٬۰۰۰ با مظنه ۳۵٬۰۰۰٬۰۰۰ → **۱۲٫۳۷۶۶** دقیقاً | +| سند تاییدنشده | تراز را **تکان نمی‌دهد** ✓ | +| تایید سند | بدهی دقیقاً به اندازه‌ی وزن نرمال‌شده **کم** شد (۰ → −۱۲) | +| **لغو تایید (toggle)** | بدهی دقیقاً برگشت (−۱۲ → ۰) ✓ | +| متادیتا (آزمایشگاه، انگ، بانک، حساب) | همه ذخیره و بازخوانی شدند | +| حسابدار | **200** روی اسناد و کیف پول (rid دارد) | +| کارشناس فروش | **403** روی هر دو | +| بیلد + هر دو typecheck | تمیز | + +بعد از تست همه‌ی اسناد آزمایشی پاک شدند و بدهی مشتری به صفر برگشت. + +### دو باگ legacy که عیناً پورت نشدند (داده‌ی حسابداری را خراب می‌کردند) +| # | مورد | وضعیت | +|---|---|---| +| 22 | حذف سند **تاییدشده** | تراز مشتری قبلاً جابه‌جا شده بود؛ حذف آن را بدون رد پا اشتباه می‌گذاشت. حالا ۴۰۰ می‌دهد. | +| 23 | ویرایش **مبلغ** سند تاییدشده | بدهی مشتری را بی‌صدا بازنویسی می‌کرد. حالا ۴۰۰ با پیشنهاد ثبت سند اصلاحی. | + +هر دو تست شدند و پیام فارسی می‌دهند. + +### کوییرک‌های legacy که عیناً پورت شدند +- `list_model` روی دو ستون عددی (`price`, `value`) `filterDate()` صدا می‌زند +- کارت لیست شارژ کیف پول روی rid **`wallet.percent`** گیت شده نه `wallet.list` +- در `manage/wallet.vue` تب «تاریخ تولد» اول نوشته شده ولی `key="2"` دارد، و تب دوم (`key="1"`) پیش‌فرض باز می‌شود +- مسیر تایید سند در سمت کلاینت املای legacy را نگه داشته: `cash/confirem/{id}` + +**وضعیت: ۲۶ از ۶۹ view. Batch های ۳–۸ کامل.** + +## Batch 9 — تخفیف‌ها و استثناها ✅ + +schema جدید **هیچ مفهومی از تخفیف نداشت**. دو مدل اضافه شد: +- `Promotion` با enum های `PromotionScope` (محصول/دسته/مشتری/برند) و `PromotionUsage` (یکبار / تا پایان) +- `PromotionExclusion` — جفت (نوع، آیتم) + +**ساخته شد:** ماژول `admin-promotions` (۱۲ route) + `PromotionForm.vue` (مشترک)، دو wrapper نازک، `promotion_list.vue`، `autopromotion.vue`، `offersIgnore-add-edit.vue`، و `brands-table` روی محصولات. + +### دوباره الگوی کامپوننت مشترک +`promotion_add_edit.vue` و `promotion_tak_add_edit.vue` با diff بررسی شدند: تنها تفاوت‌های واقعی **عنوان کارت** و اینکه نسخه‌ی «تک» گزینه‌ی «مشتری» و کل بلوک مشتری‌اش را **کامنت** کرده است. هر دو wrapper نازک روی `PromotionForm` شدند با prop `allowCustomerScope`. + +### دو یافته که فرضم را اصلاح کردند +**۱. `autopromotion` گزارش نیست، صفحه‌ی تنظیمات است.** اولش آن را به‌صورت خلاصه‌ی تخفیف‌های فعال پیاده کردم. خواندن خود view نشان داد یک سوییچ و یک درصد است برای «تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید». اصلاح شد و در `Setting` ذخیره می‌شود. + +**۲. `offersIgnore` فقط «نوع» را ذخیره نمی‌کند.** اولش `PromotionExclusion` را با یک ستون `type` ساختم. خواندن `handleSubmit` نشان داد `{type, item_id: [...]}` می‌فرستد — یعنی آیتم‌های مشخص. schema به جفت (نوع، آیتم) اصلاح شد. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| ۴ گزینه‌ی scope و ۲ گزینه‌ی usage | برچسب‌های فارسی درست | +| ساخت تخفیف محصولی | `type_customer=2` (عمده)، فقط `product_id` پر، بقیه null ✓ | +| ساخت تخفیف مشتری | `type_customer=1` (تک) — همین فیلد تعیین می‌کند کدام rid و کدام صفحه‌ی ویرایش | +| درصد ۱۵۰ | **400** | +| تاریخ پایان قبل از شروع | **400** | +| scope نامعتبر | **400** | +| استثناها: ارسال ۳ آیتم سپس ۱ آیتم | ۳ → ۱ ردیف (تیک‌نخورده‌ها حذف شدند ✓) | +| استثنای نوع دیگر | نوع قبلی دست نخورد ✓ | +| حذف کل یک نوع | `{success, removed:1}` | +| کارشناس فروش | **403** روی list و auto | +| بیلد + هر دو typecheck | تمیز | + +### یک اشکال محیطی که وقت گرفت +`promotions/list` اول **404** داد در حالی که لاگ نشان می‌داد route ثبت شده. علتش این بود که یک نمونه‌ی قدیمی API هنوز پورت ۳۰۰۰ را گرفته بود و نمونه‌ی جدید با `EADDRINUSE` بالا نیامده بود — یعنی درخواست‌ها به بیلد قدیمی می‌رفتند. با kill کردن همه‌ی پروسه‌های node حل شد. **درس: «route ثبت شده» در لاگ به‌تنهایی یعنی آن نمونه بالا آمده، نه اینکه همان نمونه جواب می‌دهد.** + +### کوییرک legacy که حفظ شد +عنوان کارت در `offersIgnore` «تعریف تخفیف» است — همان رشته‌ای که فرم تخفیف استفاده می‌کند — با اینکه این صفحه **استثنا** تعریف می‌کند. + +### یک تغییر عمدی +`toEdit` در legacy با `window.open(...)` صفحه‌ی ویرایش را در **تب جدید** باز می‌کرد. اینجا ناوبری درون‌برنامه‌ای شد، چون تب جدید state نشست SPA را از دست می‌دهد. + +**وضعیت: ۳۱ از ۶۹ view. Batch های ۳–۹ کامل.** + +## Batch 10 — کاربران و دسترسی‌ها ✅ + +این batch حلقه‌ی ACL را که در Phase 1 باز گذاشته بودیم بست: حالا صفحه‌ی «دسترسی ها» واقعاً جدول `permission(rid, userTypeId)` را مدیریت می‌کند. + +### تصحیح مهم روی Phase 1 — `ALL_RIDS` ناقص بود +در Phase 1 فقط **۵۸ rid** را استخراج کرده بودم، از روی `meta.role` در روترها. حسابرسی این batch نشان داد دو نقطه‌ی اعمال دسترسی وجود دارد: + +| منبع | تعداد | +|---|---| +| `meta.role` در روترها | ۵۸ | +| `$hasPermission('...')` داخل view ها | ۱۰۰ | +| **اجتماع (واقعاً اعمال‌شده)** | **۱۴۴** | +| کلیدهای موجود در `permissionConst.js` | ۱۷۶ | + +یعنی مدیر ارشد seed‌شده **۸۶ دسترسی کم داشت** و نمی‌توانست از عملیات درون‌صفحه‌ای (مثل `order.list.view.edit` یا `comment.state`) استفاده کند. `ALL_RIDS` به‌صورت مکانیکی از هر دو منبع بازتولید شد و seed دوباره اجرا شد. راستی‌آزمایی: مدیر ارشد حالا در لاگین **۱۴۴ rid** می‌گیرد. + +### ناسازگاری‌های واقعی بین درخت و کد (حفظ شدند) +- **۳۶ کلید** در درخت هستند ولی هیچ‌جا چک نمی‌شوند (ورودی‌های مرده در UI) +- **۴ rid** اعمال می‌شوند ولی در درخت **نیستند**، پس از UI قابل اعطا نیستند: + `buy.order.list`, `newsletters`, `persons.create.customer.tak`, `persons.list.sellers.view` + +هر دو عیناً حفظ شدند و در کامنت فایل ثبت شدند. + +**پورت مکانیکی:** `permissionConst.ts` با اسکریپت تبدیل شد — **هر ۱۸۱ برچسب فارسی بایت‌به‌بایت یکسان** (diff خالی)، ۲۲ گروه، ۴ سطح عمق. + +**ساخته شد:** ماژول `admin-users` (۱۳ route) + `Permission.vue`، `ListUser.vue`، `UserCreate.vue`، `UserEdit.vue` (wrapper)، `myData.vue`، `userTbl.ts`. + +### مهم‌ترین راستی‌آزمایی کل پورت +| مرحله | نتیجه | +|---|---| +| کارشناس فروش روی `products/create` | **403** | +| اعطای `product.create` از طریق ماتریس دسترسی | — | +| همان کاربر، توکن تازه | **200 ✓ دسترسی داده شد** | +| لغو همان rid | **403 ✓ دسترسی گرفته شد** | + +یعنی ویرایش ماتریس در UI **بلافاصله رفتار واقعی API را عوض می‌کند** — ACL تزئینی نیست. + +**سایر تست‌ها:** ساخت سطح دسترسی جدید (id بعدی: ۹۰) · ساخت کاربر و لاگین موفق با دقیقاً همان rid ها · موبایل تکراری **400** · حذف حساب خودت **400** · حذف کاربری که سند به نامش ثبت شده **400** با پیام فارسی. + +### یک تست اشتباه خودم +اول گارد حذف را روی یک ادمین seed‌شده تست کردم و «رد نشد». بررسی نشان داد **گارد درست بود** — همه‌ی ledger های seed شده `postedByAdminUserId: null` دارند، پس چیزی برای گرفتن نبود. ولی در این بین یک ادمین واقعی را حذف کرده بودم؛ با `db seed` برگشت (۵ ادمین). بعد تست را درست انجام دادم: کاربر جدید ساختم، **خودش** یک سند ثبت کرد، و آن‌وقت گارد ۴۰۰ داد و بعد از حذف سند، حذف کاربر موفق شد. + +### باگ legacy که اثرش حفظ شد +`myData.vue` مقدار `user.user_type.val` را می‌خواند در حالی که payload عنوان را زیر `title` دارد — یعنی آن سلول برای همه به fallback «آنلاین» می‌افتاد. اینجا عنوان واقعی نشان داده می‌شود با همان fallback. + +**وضعیت: ۳۶ از ۶۹ view. Batch های ۳–۱۰ کامل.** + +## Batch 11 — شعبه‌ها و کارگاه‌ها ✅ + +**۵ ستون جدید:** `Branch.address/phone/isActive` و `Workshop.address/phone/supervisorId` — همه ستون یا عملیات واقعی در legacy بودند (`branch/active/{id}` بدون `isActive` کار نمی‌کرد). + +**ساخته شد:** ماژول `admin-branches` (۶ route) + گسترش `admin-workshops` (۵ route) + `FacilityList.vue` (مشترک)، `ListBranches/ListWorkshop` (wrapper نازک)، `AddBranches/EditBranches`، `AddWorkshop/EditWorkshop`. + +### یک تداخل که پیدا و حل شد +Batch 3 قبلاً `admin/workshops/list` را ساخته بود (برای picker کارگاه در صفحه‌ی سفارش ساختنی). اول داشتم آن را در ماژول جدید تکرار می‌کردم. به‌جایش ماژول موجود را گسترش دادم و منطقش را در `AdminBranchesService` گذاشتم — یک صاحب برای `/admin/workshops`. + +### تفاوت واقعی بین دو فرم که حفظ شد +با diff بررسی شد: لیست‌ها فقط **۱۴ خط** فرق دارند (عنوان، مدل، مسیر ویرایش) پس روی کامپوننت مشترک نشستند. ولی فرم‌ها **واقعاً** متفاوت‌اند: +- **شعبه:** مدیر را از بین ادمین‌های موجود انتخاب می‌کند (`user_id`) +- **کارگاه:** سرپرست را **درجا می‌سازد** از سه فیلد (نام/نام خانوادگی/موبایل) + +هر دو جداگانه پورت شدند چون ادغامشان رفتار را عوض می‌کرد. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| لیست شعب | ۲ ردیف با مدیر و تعداد سفارش | +| لیست کارگاه‌ها | ۱ ردیف | +| ساخت شعبه + انتصاب مدیر | مدیر درست وصل شد | +| toggle وضعیت | `true → false → true` | +| **ساخت کارگاه با سرپرست درجا** | تعداد ادمین ۵ → ۶، سرپرست به‌عنوان `AdminUser` با نقش `WORKSHOP_SUPERVISOR` ساخته شد ✓ | +| round-trip فرم ویرایش | `fullName` درست به سه فیلد شکست | +| سرپرست بدون موبایل | **400** | +| شعبه بدون نام | **400** | +| حسابدار روی شعب | **403** | +| سرپرست کارگاه: کارگاه‌ها **200**، شعب **403** | ✓ | +| بیلد + هر دو typecheck | تمیز | + +این بار تست‌ها فقط ردیف‌هایی را پاک کردند که خودشان ساخته بودند (با نام مشخص)، و بعدش یکپارچگی تأیید شد: ۲ شعبه، ۱ کارگاه، ۵ ادمین، حسابدار با `branchId: null`. + +### یک نکته‌ی امنیتی عمدی +`admin/workshops/list` **بدون گارد دسترسی** است، چون sazنده‌ی سفارش (Batch 3) از آن به‌عنوان picker استفاده می‌کند و کاربری که `workshop.list` ندارد هم باید بتواند کارگاه انتخاب کند. فقط نام و آدرس کارگاه را برمی‌گرداند. اگر می‌خواهید گیت شود بگویید. + +### کوییرک‌های legacy که حفظ شدند +- `ListBranch.js` فلگ `_noFilter` (مفرد) را ست می‌کند که XTable اصلاً نمی‌خواند — نوار فیلتر همچنان دیده می‌شد. حفظ شد. +- `filterNumber()` روی ستون‌های متنی `phone` و `user.name` +- عملیات حذف در هر دو لیست **کامنت شده** است و غایب ماند + +**وضعیت: ۴۲ از ۶۹ view. Batch های ۳–۱۱ کامل.** + +## Batch 12 — فاکتورها (انتقال به شعبه) ✅ + +### یافته‌ی مهم درباره‌ی نام +صفحه‌ی «فاکتور» اصلاً فاکتور فروش نیست. endpoint اش `branch/product` است و کاری که می‌کند این است: **تخصیص محصول از دفتر مرکزی به یک شعبه**، همراه با ثبت اینکه چه کسی تخصیص داده. مدل جدید بر اساس کاری که واقعاً می‌کند ساخته شد (`BranchAllocation` + `BranchAllocationItem`) نه بر اساس اسمش. + +**ساخته شد:** ماژول `admin-invoices` (۶ route)، `stores/invoiceCart.ts`، `ListInvoice.vue`، `AddInvoice.vue` (هم ایجاد هم ویرایش)، `EditInvoice.vue` (wrapper)، دو مدل جدول. همچنین `min_gram`/`max_gram` به ردیف محصول اضافه شد (سبک‌ترین و سنگین‌ترین تنوع). + +### یک باگ legacy که عیناً پورت نشد +`store/cart/cart.js` و `store/invoiceCart/invoiceCart.js` **هر دو** روی کلید `admin_cart_items` می‌نویسند. یعنی ساختن فاکتور، سبد سفارش نیمه‌تمام را پاک می‌کرد و برعکس. راستی‌آزمایی شد (خط ۲۷ هر دو فایل). اینجا سبد فاکتور کلید خودش را دارد (`admin_invoice_items`) — بازتولید از دست رفتن بی‌صدای داده بدتر از یک انحراف مستند بود. + +### یک تداخل که پیدا و حل شد +در Batch 11 یک نگاشت `branch/list` اضافه کرده بودم که به گرید شعب اشاره می‌کرد. در این batch دوباره اضافه شد (این بار به picker). typecheck کلید تکراری را گرفت. بررسی نشان داد **تنها مصرف‌کننده‌ی واقعی** `branch/list` فرم فاکتور است و ورودی Batch 11 حدسی و بی‌استفاده بود — حذف شد. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| ساخت تخصیص با ۳ محصول | `sum_weight` = ۴٫۰۶۶+۵٫۶+۱٫۹ = **۱۱٫۵۶۶** درست | +| ثبت‌کننده | از JWT گرفته شد، نه از body ✓ | +| snapshot وزن | وزن هر محصول در لحظه‌ی تخصیص ذخیره شد | +| ویرایش (جایگزینی مجموعه) | ۳ → ۱ آیتم | +| بدون شعبه / بدون محصول / محصول ناموجود | هر سه **400** | +| حذف | آیتم‌ها cascade شدند | +| `min_gram`/`max_gram` | درست محاسبه شد (سکه: ۴٫۰۶۶ تا ۸٫۱۳۳) | +| حسابدار **200** / کارشناس فروش **403** | ✓ | +| بیلد + هر دو typecheck | تمیز | + +### تصمیم معماری +`EditInvoice.vue` در legacy یک کپی ۳۳۳ خطی از `AddInvoice` است که فقط یک GET اضافه دارد. اینجا `AddInvoice` خودش `:id` را می‌خواند، پس `EditInvoice` یک wrapper نازک شد — همان الگوی Batch های قبل. + +**وضعیت: ۴۵ از ۶۹ view. Batch های ۳–۱۲ کامل.** + +## Batch 13 — متفرقه (شهرها، پیامک، نوتیفیکیشن، خبرنامه، آمار، ثابت‌ها) ✅ + +بزرگ‌ترین batch باقی‌مانده: ۹ view، حدود ۲٬۴۰۰ خط. + +**۵ مدل جدید + یک enum:** `SmsMessage`، `Notification` + `NotificationRecipient` (وضعیت خواندن هر گیرنده)، `NewsletterSubscriber`، `CustomerActivityLog`، و enum `MessageAudience`. همچنین `City.percent` و `City.percentAux`. + +**ساخته شد:** ماژول `admin-misc` (۲۱ route) + ۹ view و ۴ مدل جدول. + +### کدهای عددی مخاطب که از خود view استخراج شدند +`sms/create.vue` هر گزینه را با rid جداگانه گیت می‌کند و کدهای عددی خودش را دارد: +| کد | مخاطب | rid | +|---|---|---| +| −۱ | شماره موبایل | `sms.create.single` | +| ۰ | همه مشتریان | `sms.create.all` | +| ۱ | کارشناسان فروش | `sms.create.reagent` | +| ۲ | مشتریان عمده | `sms.create.omde` | +| ۹ | شماره های موبایل | `sms.create.multiple` | + +**نکته:** rid به نام `sms.create.tak` وجود دارد ولی **هیچ گزینه‌ای در markup ندارد** — یعنی دست‌نیافتنی است. عیناً حفظ شد. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| هر ۵ کد مخاطب پیامک | گیرندگان درست: ۱، ۲، ۶، ۲، ۶ | +| نوتیفیکیشن گروهی | ۶ گیرنده ساخته شد | +| علامت‌گذاری خوانده‌شده | ۰/۶ → **۱/۶** ✓ (وضعیت هر گیرنده جدا) | +| درصدهای شهر (رفت و برگشت) | ۲٫۵ و ۱٫۲۵ درست ذخیره و خوانده شد | +| حذف شهری که مشتری دارد | **400** با شمارش | +| ذخیره و بازخوانی برچسب‌ها | `["طلا","نقره","جواهر"]` ✓ | +| کارشناس فروش روی هر ۳ endpoint | **403** | +| بیلد + هر دو typecheck | تمیز | + +همه‌ی داده‌های آزمایشی پاک شدند (۴ شهر seed دست‌نخورده). + +### شکاف صادقانه: درگاه پیامک وصل نیست +legacy پیام را به یک سرویس خارجی می‌داد. این پورت اعتبارنامه‌ی چنین سرویسی ندارد، پس پیام **ثبت می‌شود** (با مخاطب و تعداد گیرنده‌ی درست) ولی **ارسال نمی‌شود**. لاگ دقیق است، ولی SMS واقعی نمی‌رود. برای فعال‌سازی باید یک provider انتخاب و تنظیم شود. + +### دو تداخل که typecheck گرفت +۱. `branch/list` در Batch 11 حدسی اضافه شده بود (تکراری در Batch 12 کشف شد) +۲. `ciny` در Batch 7 اضافه شده بود و اینجا دوباره — با همان مقدار. حذف شد. + +هر دو نشان می‌دهند لایه‌ی نگاشت با ۳۲ ورودی exact و ۲۶ الگو به اندازه‌ای بزرگ شده که تکرار محتمل است. typecheck هر دو را گرفت. + +### کوییرک‌های legacy که حفظ شدند +- `CityListModel` ستون `level` دارد ولی payload شهرها چنین فیلدی ندارد — سلول همیشه خالی بود +- `newsletter.js` به `fashion/table` اشاره می‌کند ولی خود view به `/ddf` — دو نام بی‌ربط برای یک صفحه؛ هر دو نگاشت شدند +- عملیات حذف در خبرنامه **کامنت** است و غایب ماند + +### تغییر ساختاری عمدی +`Const.vue` در legacy یک کارت ۹۷۷ خطی است با پنج بخش پشت سر هم و بدون راه ناوبری. اینجا به تب تقسیم شد (برندها، برچسب‌ها، اسلایدر، سینی‌ها). هر بخش برچسب‌ها و rid خودش را نگه داشته. + +**وضعیت: ۵۴ از ۶۹ view. Batch های ۳–۱۳ کامل.** + +## Batch 14 — داشبوردها ✅ + +**یافته‌ی مهم: بیشتر این دو صفحه در legacy مرده است.** + +`DashboardECommerce.vue` (۴۹۹ خط) یک قالب Vuexy است که **همه‌ی کارت‌های انگلیسی‌اش کامنت شده‌اند** — خط به خط بررسی شد: +| کارت | وضعیت | +|---|---| +| Congratulations | کامنت | +| Support Tracker | کامنت | +| Product Orders | کامنت | +| Sales Stats | کامنت | +| Activity Timeline | کامنت | + +محتوای زنده فقط این‌هاست: ۳ کارت آمار ۳۰ روزه + «عملکرد کارشناسان فروش» + «میزان فروش» + «سفارشات هفته جاری». + +`DashboardAnalytics.vue` (۳۴۲ خط) هم **تنها یک فیلد** از سرور می‌خواند: `model.lastOrder`. بقیه‌اش داده‌ی دموی Vuexy است که داخل خود کامپوننت تعریف شده. + +### ApexCharts نصب نشد — و این یک یافته است نه میان‌بر +هر ۳ کارت آمار زنده `:hideChart="true"` پاس می‌دهند (۳ از ۳ راستی‌آزمایی شد) و تنها markup مربوط به `<apexchart>` داخل کارت‌های کامنت‌شده است. کتابخانه فقط **import** شده و هرگز استفاده نمی‌شود. پس نصبش یعنی فرستادن یک کتابخانه که هیچ چیز رندر نمی‌کند. + +**ساخته شد:** ماژول `admin-dashboard` (۲ route)، `StatisticsCardLine.vue`، و دو داشبورد. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| داشبورد عمده | ۶ مشتری، ۱۲٫۶۹۸۵ گرم، ۳ سفارش | +| **صحت محاسبات** | مستقل از دیتابیس خام بررسی شد: مجموع وزن سفارش‌های تحویل‌شده = **۱۲٫۶۹۸۵** دقیقاً برابر عدد داشبورد ✓ | +| انتساب به کارشناس فروش | رضا احمدی = ۱۲٫۶۹۸۵ — با بررسی خام یکسان ✓ | +| سفارشات هفته جاری | ۳ ردیف با رنگ وضعیت درست | +| داشبورد تک | فقط `lastOrder` (همان چیزی که view می‌خواند) | +| **حسابدار** | `omde` **200** / `tak` **403** | +| **کارشناس فروش** | `tak` **200** / `omde` **403** | +| بیلد + هر دو typecheck | تمیز | + +آخرین دو ردیف آینه‌ی هم‌اند: دو کاربر مختلف، هر کدام روی یکی ۲۰۰ و روی دیگری ۴۰۳ — دقیقاً بر اساس rid خودشان. + +### چیزی که جعل نشد +legacy برای هر سفارش `parent`/`children` رندر می‌کرد (سفارش‌های تقسیم‌شده). schema جدید چنین رابطه‌ای ندارد، پس هر دو خالی برمی‌گردند نه ساختگی. + +**وضعیت: ۵۶ از ۶۹ view. Batch های ۳–۱۴ کامل.** + +## Batch 15 — حالت شعبه + پایان پورت ✅ + +**۶۹ از ۶۹ view پورت شد. هر دو روتر: ۰ view باقی‌مانده.** + +``` +admin.ts — ported views wired: 64 | pending: 0 +branch.ts — ported views wired: 14 | pending: 0 +``` + +`_NotPorted.vue` (نگهدارنده‌ی جای view های نیامده) حذف شد — دیگر هیچ ارجاعی ندارد. + +**این batch ساخت:** `branch/{login, dashboard, addCustomer, buyList, order/create}`، `apps/customer/{singleCustomer, ReagentCustomer, EditBulkCustomer, EditReagent, EditSingleCustomer}`، `customer/CustomerDetail.vue` (مشترک) + دو wrapper، `report/{order_item, product}`، `reg.vue`، و `components/input/DateInput.vue`. + +### دوباره الگوی کامپوننت مشترک +`ViewOmdeCustomer` (۳۷۶ خط) و `ViewSingleCustomer` (۴۲۵ خط) هر دو همان چهار پنل را نشان می‌دهند. روی یک `CustomerDetail` نشستند با دو prop: نسخه‌ی عمده لاگ فعالیت دارد، نسخه‌ی تک کیف پول. + +### تفاوت واقعی حالت شعبه که حفظ شد +`branch/order/create` اول **مشتری** را می‌پرسد و گرید محصولات را تا انتخاب مشتری پنهان نگه می‌دارد («جهت ثبت سفارش مشتری را انتخاب نمایید»). نسخه‌ی ادمین برعکس است. عیناً پورت شد. + +### یک شکاف legacy که عمداً بسته شد +`branch/dashboard.vue` در legacy **هیچ فراخوانی سروری ندارد** (صفر ارجاع axios) — هر دو جدولش روی آرایه‌های دمو داخل خود کامپوننت بسته شده‌اند. اینجا به endpoint های واقعی وصل شد، چون داشبوردی که ردیف ساختگی نشان می‌دهد بدتر از داشبوردی است که چیزی نشان نمی‌دهد. + +### DateInput جلالی +legacy از `vue-persian-datetime-picker` استفاده می‌کرد که نسخه‌ی Vue 3 ندارد. جایگزین با ورودی متنی + تبدیل `moment-jalaali` ساخته شد: کاربر جلالی می‌نویسد و می‌خواند (۱۴۰۳/۰۵/۱۲)، ارقام فارسی هم پذیرفته می‌شوند، و مقدار ISO به API می‌رود. + +**راستی‌آزمایی اجراشده:** +| تست | نتیجه | +|---|---| +| هر دو روتر | **۰ view باقی‌مانده** | +| بیلد حالت ادمین | موفق | +| **بیلد حالت شعبه** (`--mode branch`) | موفق، و حاوی «فاکتور خرید» (view مخصوص شعبه) ✓ | +| `report/order-item` | ۳ ردیف، مجموع وزن **۱۲٫۶۹۸۵** — همان عددی که داشبورد Batch 14 داد | +| `report/product` | تجمیع درست per-product | +| پنل‌های جزئیات مشتری | سفارش‌ها ۲، اسناد ۴، کیف پول ۰ | +| **فیلتر بازه‌ی تاریخ** | بازه‌ی باز ۳ سفارش، بازه‌ی باریک **۰** ✓ واقعاً فیلتر می‌کند | +| `vue-tsc` و `tsc` | تمیز | + +--- + +# خلاصه‌ی کل پورت + +| | عدد | +|---|---| +| view های پورت‌شده | **۶۹ / ۶۹** | +| ماژول‌های بک‌اند NestJS | ۱۶ | +| مدل‌های Prisma اضافه‌شده | ۱۴ | +| rid های ACL | ۱۴۴ | +| ورودی‌های لایه‌ی نگاشت | ۳۴ exact + ۳۵ الگو | +| باگ/کوییرک legacy ثبت‌شده | ۲۳ | + +**کاهش تکرار با کامپوننت مشترک (طبق تصمیم شما):** +- ۵ لیست عمده: ۳٬۰۷۲ → ۵۷۲ خط +- ۲ فرم تخفیف، ۲ لیست شعبه/کارگاه، ۲ صفحه‌ی جزئیات مشتری: هر جفت روی یک پیاده‌سازی +- ۷ wrapper نازک Edit عیناً wrapper ماندند (۰٫۲۲ تا ۰٫۲۶ کیلوبایت) +- ۴ fork از XTable → یک نسخه + +--- + +# ممیزی نهایی (Phase 16) + +همه‌ی اعداد زیر **اجرا شده‌اند**، نه تخمین. روش هر کدام کنارش آمده. + +## ۱. پوشش view — کامل +| سنجه | عدد | روش | +|---|---|---| +| view هایی که روترهای زنده‌ی legacy ارجاع می‌دهند | **۶۹** | استخراج برنامه‌ای از `router/admin.js` + `router/branch.js` | +| پورت‌شده و موجود روی دیسک | **۶۹** | بررسی وجود فایل برای هر کدام | +| باقی‌مانده | **۰** | — | +| ارجاع به `_NotPorted` | **۰** | فایل هم حذف شد | + +## ۲. وفاداری متادیتای روتر — بایت‌به‌بایت +| فیلد | legacy | پورت | | +|---|---|---|---| +| `path` (admin) | ۷۱ | ۷۱ | ✓ | +| `name` | ۴۶ | ۴۶ | ✓ | +| `role` | ۶۵ | ۶۵ | ✓ | +| `pageTitle` | ۳۵ | ۳۵ | ✓ | +| `breadcrumb` | ۳۲ | ۳۲ | ✓ | +| `no_scroll` | ۴۱ | ۴۱ | ✓ | +| `path` (branch) | ۱۷ | ۱۷ | ✓ | +| `role` (branch) | ۱۴ | ۱۴ | ✓ | + +## ۳. endpoint های زنده +| سنجه | عدد | +|---|---| +| کل route های ثبت‌شده در NestJS | **۱۶۶** | +| از آن، `admin/*` | **۱۵۲** در ۱۶ ماژول | +| route های قدیمی user-app (دست‌نخورده) | ۱۴ | +| **تست دود روی endpoint های خواندنی** | **۵۹/۵۹ پاسخ ۲۰۰، صفر خطا** | + +## ۴. پوشش ACL — کامل +| سنجه | عدد | +|---|---| +| واژگان دسترسی legacy (route + view) | **۱۴۴** | +| `ALL_RIDS` (به مدیر ارشد داده می‌شود) | **۱۴۴** | +| rid های legacy که پوشش داده نشده‌اند | **۰** | +| گارد بک‌اند روی rid ناشناخته | **۰** | +| rid هایی که بک‌اند گارد می‌کند | ۶۳ | +| rid هایی که فرانت چک می‌کند | ۷۷ | + +## ۵. گاردها واقعاً تبعیض قائل می‌شوند +همان ۵۹ endpoint، چهار کاربر واقعی: + +| کاربر | rid | ۲۰۰ | ۴۰۳ | خطا | +|---|---|---|---|---| +| مدیر ارشد (ut ۸۵) | ۱۴۴ | **۵۹** | ۰ | ۰ | +| کارشناس فروش (ut ۸۶) | ۱۲ | ۲۵ | ۳۴ | ۰ | +| حسابدار (ut ۸۷) | ۱۱ | ۱۶ | ۴۳ | ۰ | +| سرپرست کارگاه (ut ۸۸) | ۵ | ۱۲ | ۴۷ | ۰ | + +شیب یکنواخت و بدون هیچ خطای دیگری. **این قوی‌ترین شاهد کل پورت است**: دسترسی واقعاً از روی rid محاسبه می‌شود. + +## ۶. یکپارچگی داده بعد از ۱۳ batch تست +۲۳ جدول شمرده شد؛ همه‌ی داده‌ی seed سالم: ۴ شهر، ۵ ادمین، ۵ سطح دسترسی، ۱۷۶ permission، ۶ مشتری، ۵ محصول، ۹ تنوع، ۳ سفارش، ۵ سند، ۲ پیش‌سفارش، ۳ نظر. +**بررسی یتیم: صفر** (سفارش بدون مشتری، آیتم بدون سفارش، تنوع بدون محصول). + +## ۷. کاهش تکرار (تصمیم «کامپوننت مشترک» شما) +| گروه | legacy | پورت | +|---|---|---| +| ۵ لیست عمده | ۳٬۰۷۲ | ۵۹۰ | +| ۲ صفحه‌ی جزئیات مشتری | ۸۰۱ | ۲۴۵ | +| ۲ فرم تخفیف | ۸۰۳ | ۳۰۴ | +| **جمع این ۹ فایل** | **۴٬۶۷۶** | **۱٬۱۳۹** | + +به‌علاوه: ۴ fork از XTable → یک نسخه؛ ۷ wrapper نازک Edit عیناً wrapper ماندند. + +## ۸. ابعاد کد +۸۰ فایل `.vue` · ۴۲ فایل `.ts` · ۱۶ ماژول بک‌اند · ۳۴ مدل Prisma · لایه‌ی نگاشت با ۷۷ ورودی دقیق + ۲۶ الگو · ۳۶ باگ/کوییرک legacy ثبت‌شده. + +--- + +## آنچه این ممیزی **نمی‌تواند** تأیید کند + +⚠️ **ظاهر رندرشده.** هیچ‌کدام از ۶۹ view چشمی دیده نشده. همه‌ی تأییدها API، بیلد و typecheck بوده‌اند. صفحه‌به‌صفحه باید بررسی شود. + +⚠️ **مسیرهای نوشتن با کاربر کم‌دسترسی.** تست دود فقط خواندنی بود تا داده خراب نشود؛ مسیرهای write در batch خودشان تست شدند ولی نه به‌صورت ماتریسی روی همه‌ی نقش‌ها. + +⚠️ **درگاه پیامک وصل نیست** — پیام ثبت می‌شود، ارسال نمی‌شود. + +## شکاف‌های schema — **هر شش مورد بسته شد** + +این بار به‌جای استنتاج از view ها، منطق را مستقیم از سرور legacy +(`henza_server`) خواندم؛ و یک فرضِ غلطِ قبلی را هم اصلاح کرد. + +| # | شکاف | چه شد | +|---|---|---| +| ۱ | تفکیک بنکدار/مغازه‌دار | enum `CustomerKind` با **۵** مقدار (`Customer::$TYPES`) | +| ۲ | تفکیک تک/عمده | enum `OrderChannel` (`RETAIL`/`WHOLESALE`) | +| ۳ | ستون بایگانی مجزا | `Customer.isArchived` | +| ۴ | دو سوییچ مجزا | `Customer.canPlaceOrders` جدا از `isActive` | +| ۵ | ستون‌های مشتری | `phone`, `address`, `storeName`, `pasazhName`, `zone`, `memberCode` | +| ۶ | آپلود فایل | مدل `UploadedFile` + `POST admin/files/create` + سرو استاتیک `/uploads/` | +| ۷ | آواتار کارشناس فروش | `Customer.avatarFileId` (رابطه به `UploadedFile`) | + +### آنچه از سرور legacy روشن شد + +**منبع:** `AdminController::getBanker/getShopkeeper/getCustomer` و +`MajorCustomerController::indexArchive/indexVerify`. + +- `banker` → `type=4` · `shopkeeper` → `type=3` (**فقط حضوری**) · لیست اصلی عمده → `type=2` +- **تصحیح یک فرض قبلی:** پیش‌تر نوشته بودم لیست اصلی «بدون فیلتر» است؛ نبود. +- هر سه لیست `is_archive=0` هم دارند، پس بایگانی‌کردن مشتری او را از **همه‌ی** لیست‌ها برمی‌دارد. +- `Customer::$TYPES` یک مقدار پنجم دارد (`۵ سرپرست کارگاه`) که فرم `bulkCustomer.vue` هرگز نشان نمی‌داد. enum هر پنج را دارد، اما فرم همچنان همان چهارتای legacy را نشان می‌دهد. +- در legacy دو جدول جدا بود (`orders` و `orders_orders`)؛ هیچ‌کدام فیلتر نوع نداشتند چون **خود جدول** تفکیک‌کننده بود. `Order.channel` همان نقش را بازی می‌کند. + +### دو رفتار legacy که عمداً پورت **نشدند** + +۱. **آپلود بدون احراز هویت.** `file/create` در legacy هیچ middleware نداشت و + گروه `auth:api` هم در `routes/admin.php:12` کامنت است — یعنی آپلود ناشناس + می‌پذیرفت. این باگ امنیتی است، نه قابلیت؛ اینجا ادمینِ واردشده لازم است. + rid اختصاصی نگذاشتم چون واژگان legacy ورودی media/file ندارد. +۲. **ارسال پیامک درون `toggleActive`.** legacy هنگام فعال‌سازی مستقیم SMS + می‌فرستاد. انتقالش به ماژول اعلان‌ها ماند (درگاه پیامک هنوز وصل نیست). + +### تأیید اجرایی + +``` +لیست‌ها: عمده(t2)=۱ · بنکدار(t4)=۲ · حضوری(t3)=۲ · بایگانی=۱ · تاییدنشده=۲ +سوییچ‌ها: toggle-sts فقط sts را برد · toggle-active فقط active را +بایگانی: ردیف از لیست نوع خارج و به بایگانی رفت، بدون دست‌خوردن active +سفارش: خرده=۳ · عمده=۱ +آپلود: ۲۰۰ + فایل از URL خودش بایت‌به‌بایت برگشت · mime بد/حجم زیاد/بدون فایل → ۴۰۰ فارسی · ناشناس → ۴۰۱ +ممیزی: ۵۹/۵۹ endpoint = ۲۰۰ · ACL ۱۴۴/۱۴۴ · ۱۵۴ مسیر ادمین +بیلد: api ✓ · admin ✓ · branch ✓ · هر دو typecheck ✓ +``` + +> تست‌های نوشتن فقط روی دو مشتری‌ای انجام شد که خودم ساختم و بعد حذف کردم؛ +> ردیف‌های seed دست نخوردند (۶ مشتری، همان‌طور که بود). + +### آواتار هم بسته شد ✅ + +`Customer.avatarFileId` → رابطه‌ی نام‌دار `CustomerAvatar` به `UploadedFile`. + +legacy یک **FK** نگه می‌داشت (`image_id`) نه URL، و آن را به شکل تودرتوی +`image: { id, image }` پس می‌داد؛ هر دو نیمه‌ی این قرارداد عیناً حفظ شد، پس فرم +پورت‌شده بدون تغییر کار می‌کند. + +| مرحله | نتیجه | +|---|---| +| آپلود | `id` + `url` برگشت | +| ساخت با `image_id` | `avatarFileId` ذخیره شد | +| خواندن | `image: { id, image }` + پیش‌نمایش ۲۰۰ | +| حذف (`image_id: ''`) | هر دو `null` شدند | +| مشتری بدون آواتار | `null` — **نه** خطا | + +**یک باگ legacy که عمداً تکرار نشد:** مسیر لود در +`ReagentCustomer.vue:280` بدون محافظ به `data.image.id` دست می‌زد، پس باز کردن +کارشناسی که عکس نداشت throw می‌کرد — و `catch` خودش آن را می‌بلعید و فرم +نیمه‌پر می‌ماند. اینجا خواندن guard شده است. diff --git a/docs/CHANGELOG-PORT-HISTORY.md b/docs/CHANGELOG-PORT-HISTORY.md new file mode 100644 index 0000000..6bcd1a5 --- /dev/null +++ b/docs/CHANGELOG-PORT-HISTORY.md @@ -0,0 +1,291 @@ +# تاریخچه‌ی کامل پورت — henza_admin → gold-banking-monorepo + +> این سند خلاصه‌ی کامل و دقیق تمام کارهایی است که تا این لحظه روی پروژه انجام شده، +> از شروع پروژه تا آخرین commit. برای جزئیات ریزتر batch به batch به +> [`ADMIN-PORT-PLAN.md`](./ADMIN-PORT-PLAN.md) مراجعه کنید — آن سند مرجع فنی +> زنده است، این سند خلاصه‌ی روایی و تاریخی. + +**تاریخ نگارش:** ۱۳ شهریور ۱۴۰۵ (۲۰۲۶-۰۹-۱۳) + +--- + +## ۱. هدف پروژه + +پورت کامل و بدون تغییر پنل ادمین legacy جواهرات/طلا (`henza_admin`، Vue 2 + +Vuesax + قالب Vuexy) به `apps/admin-panel` در مونوریپوی +`gold-banking-monorepo` (Vue 3 + Vite + Pinia + ant-design-vue)، همراه با +ساخت بک‌اند NestJS متناظر برای هر بخش. + +### قوانین صریح کارفرما (که در تمام مسیر رعایت شدند) + +- «همه رو با دقت و بدون دست خوردن میخوام» — پورت وفادار، بدون تغییر منطق +- باگ‌های legacy **نباید ساکت اصلاح شوند** — باید عیناً پورت و **جداگانه گزارش** + شوند +- حفظ دقیق قرارداد اعداد فارسی/لاتین و رفتار RTL همان‌طور که در legacy بود +- **`henza_server/` و `henza_pwa/` (و بعداً `henza_admin/`) فقط خواندنی — + هرگز تغییر داده نشوند** +- اول پلن، بعد اجرا batch به batch +- پشتیبانی از هر دو حالت ادمین و شعبه (`isBranch()`) +- ACL «دقیقا پایبند به ساختار» — وفاداری کامل به مدل دانه‌ریز + `permission(rid, ut)` در legacy +- لاگین واقعی بک‌اند، نه mock +- حذف ~۳۹۴ فایل دمو الگوی Vuexy +- «بک‌اند همراه هر بخش» — هر batch با endpoint های خودش می‌آید +- «REST تمیز + لایه‌ی نگاشت» در فرانت‌اند + +--- + +## ۲. مراحل اصلی پروژه (Phase 0 تا ممیزی نهایی) + +### Phase 0-2: زیرساخت +- راه‌اندازی مونوریپو با pnpm workspaces + Turborepo +- طراحی schema اولیه‌ی Prisma (اکنون **۳۵ مدل**) +- ساخت لایه‌ی auth واقعی: `@nestjs/jwt@12` + `@nestjs/passport@10.0.3` + (pin‌شده برای سازگاری با NestJS 10) + bcryptjs +- تبدیل مکانیکی روترهای legacy (`router/admin.js` + `router/branch.js`) به + TypeScript، با حفظ ۱۰۰٪ مسیرها، نام‌ها، `meta.role` و breadcrumb ها +- تبدیل مکانیکی `permissionConst.ts` — تمام ۱۸۱ برچسب فارسی بایت‌به‌بایت + یکسان (با diff تأیید شد) + +### Batch های ۳ تا ۱۵: پورت view به view +پورت کامل **۶۹ view** به همراه بک‌اند متناظر هر batch. جزئیات کامل هر batch +(فایل‌ها، تصمیم‌ها، مشکلات) در `ADMIN-PORT-PLAN.md` ثبت شده. + +### تصمیم‌های کلیدی کارفرما در طول مسیر +1. **کامپوننت مشترک به‌جای کپی فایل‌به‌فایل** برای ۵ لیست عمده‌ی تقریباً + یکسان و ۴ fork از XTable +2. **توکن‌های طراحی از خودِ legacy ادمین** (`secondary #836447`، فونت + `p-yek`) — نه هماهنگ با اپلیکیشن کاربر که قبلاً ساخته شده بود + +### ممیزی نهایی (Phase 16) +پس از تکمیل ۶۹/۶۹ view، یک ممیزی جامع اجرا شد که یافته‌های معماری مهمی را +آشکار کرد: +- «فاکتور» در legacy در واقع تخصیص موجودی شعبه است، نه فاکتور فروش +- ردیف‌های «صندوق» در legacy همان `LedgerEntry` هستند +- هر دو داشبورد عمدتاً کامنت‌شده‌ی دمو Vuexy بودند +- ApexCharts import شده ولی هرگز در markup زنده استفاده نشده +- **۳۶ باگ/کوییرک legacy** ثبت و مستند شد +- **۶ رفتار مخرب داده‌ی legacy عمداً پورت نشدند** (حذف سند تاییدشده، ویرایش + مبلغ تاییدشده، حذف دسته‌بندی دارای وابسته، ایجاد چرخه در دسته‌بندی، یتیم‌کردن + ردیابی ممیزی) — هرکدام با پیام ۴۰۰ فارسی جایگزین شد + +--- + +## ۳. بستن ۷ شکاف schema (جلسه‌ی اخیر) + +پس از ممیزی نهایی، ۷ شکاف باقی‌مانده در schema شناسایی و به ترتیب بسته شد. +برای هر شکاف، منطق مستقیماً از **سرور legacy** (`henza_server`) خوانده شد، +نه فقط از view ها — این روش یک فرض غلط قبلی را هم آشکار کرد. + +| # | شکاف | راه‌حل | +|---|---|---| +| ۱ | تفکیک بنکدار/مغازه‌دار | enum `CustomerKind` با **۵** مقدار (`Customer::$TYPES` سرور) | +| ۲ | تفکیک سفارش تک/عمده | enum `OrderChannel` (`RETAIL` \| `WHOLESALE`) | +| ۳ | ستون بایگانی مجزا | `Customer.isArchived` | +| ۴ | دو سوییچ مجزای مشتری | `Customer.canPlaceOrders` جدا از `isActive` | +| ۵ | ستون‌های تجاری مشتری | `phone`, `address`, `storeName`, `pasazhName`, `zone`, `memberCode` | +| ۶ | endpoint آپلود فایل | مدل `UploadedFile` + `POST admin/files/create` + سرو استاتیک `/uploads/` | +| ۷ | آواتار کارشناس فروش | `Customer.avatarFileId` (رابطه‌ی نام‌دار `CustomerAvatar` به `UploadedFile`) | + +### یافته‌های مهم از خواندن مستقیم سرور legacy + +**منبع:** `AdminController::getBanker/getShopkeeper/getCustomer` و +`MajorCustomerController::indexArchive/indexVerify` در `henza_server`. + +- `banker` (بنکدار) → `type=4` · `shopkeeper` (حضوری) → `type=3` **فقط** · + لیست اصلی عمده (اپلیکیشن) → `type=2` +- **اصلاح یک فرض قبلی:** پیش‌تر نوشته شده بود که لیست اصلی عمده «بدون فیلتر» + است. این درست نبود؛ خوانده و تصحیح شد. +- هر سه لیست نوع، فیلتر `is_archive=0` هم دارند — یعنی بایگانی‌کردن یک مشتری + او را از **همه‌ی** لیست‌های نوع حذف می‌کند، نه فقط از یکی. +- `Customer::$TYPES` سرور یک مقدار پنجم دارد (`۵ سرپرست کارگاه`) که فرم + `bulkCustomer.vue` هرگز نشان نمی‌داد. enum جدید هر پنج مقدار را دارد، اما + فرم پورت‌شده عمداً همان چهارتای legacy را نشان می‌دهد (برای وفاداری کامل + به رفتار دیده‌شده در UI). +- سفارش تک و عمده در legacy در **دو جدول کاملاً جدا** بودند (`orders` و + `orders_orders`)؛ هیچ‌کدام از دو کنترلر فیلتر نوع نداشتند چون **خود جدول** + تفکیک‌کننده بود، پس دو لیست هرگز هم‌پوشانی نداشتند. `Order.channel` همان + نقش را در یک مدل واحد بازی می‌کند. +- آواتار در legacy یک **FK** بود (`image_id`) نه یک URL ساده، و در پاسخ به‌شکل + تودرتوی `image: { id, image }` برمی‌گشت — هر دو نیمه‌ی این قرارداد عیناً + حفظ شد. + +### دو رفتار legacy که عمداً پورت **نشدند** + +۱. **آپلود بدون احراز هویت.** در legacy، `file/create` هیچ middleware نداشت + و گروه `auth:api` هم در `routes/admin.php:12` کامنت بود — یعنی آپلود + ناشناس می‌پذیرفت. این یک باگ امنیتی بود، نه قابلیت؛ در پورت، ادمینِ + واردشده الزامی است. rid اختصاصی هم ساخته نشد چون واژگان ACL موجود در + legacy ورودی media/file ندارد. +۲. **ارسال پیامک درون `toggleActive`.** legacy هنگام فعال‌سازی مستقیم SMS + می‌فرستاد. این به ماژول اعلان‌ها موکول شد (درگاه پیامک هنوز واقعاً وصل + نیست، طبق تصمیم قبلی). +۳. **باگ در بارگذاری آواتار.** مسیر لود legacy در + `ReagentCustomer.vue:280` بدون محافظ به `data.image.id` دست می‌زد، پس باز + کردن یک کارشناس فروش بدون عکس throw می‌کرد (که خودِ `catch` می‌بلعید و + فرم نیمه‌پر می‌ماند). در پورت این خواندن guard شده است. + +### تأیید اجرایی این بخش + +``` +لیست‌ها: عمده(t2)=۱ · بنکدار(t4)=۲ · حضوری(t3)=۲ · بایگانی=۱ · تاییدنشده=۲ +سوییچ‌ها: toggle-sts فقط sts را جابه‌جا کرد؛ toggle-active فقط active +بایگانی: ردیف از لیست نوع خارج و به بایگانی رفت، بدون دست‌خوردن active +سفارش: خرده‌فروشی=۳ · عمده=۱ +آپلود: ۲۰۰ + فایل از URL خودش بایت‌به‌بایت برگشت؛ mime نامعتبر/حجم + زیاد/بدون فایل → ۴۰۰ فارسی؛ درخواست ناشناس → ۴۰۱ +آواتار: آپلود → ساخت با image_id → خواندن image:{id,image} → پیش‌نمایش + ۲۰۰ → حذف → هر دو null؛ مشتری بدون آواتار → null (نه خطا) +ممیزی: ۵۹/۵۹ endpoint خواندنی = ۲۰۰ · ACL ۱۴۴/۱۴۴ رید · ۱۵۴ مسیر ادمین +بیلد: api ✓ · admin ✓ · branch ✓ · هر دو typecheck ✓ +``` + +تست‌های نوشتن فقط روی مشتری‌های تستی‌ای انجام شد که در همان جلسه ساخته و +بعد حذف شدند — داده‌ی seed اصلی هرگز دست‌خورد نکرد. + +--- + +## ۴. لاگین تستی حالت توسعه (جلسه‌ی جاری) + +طبق درخواست صریح، شماره‌ی **۰۹۱۲۸۰۸۱۳۹۲** برای ورود آزمایشی در **هر دو** +اپلیکیشن مجاز شد — فقط برای محیط توسعه: + +- **پنل ادمین:** یک `AdminUser` با نقش super admin (`userTypeId: 85`) ساخته + شد؛ رمز عبور دقیقاً برابر خودِ شماره تلفن است. +- **اپلیکیشن کاربر (user-app):** همان شماره به‌عنوان یک `Customer` تایید‌شده + (`VERIFIED`) اضافه شد. جریان OTP در `LoginView.vue` از قبل stub بود + (هر کد ۴ رقمی برای یک مشتریِ واقعیِ منطبق پذیرفته می‌شود)، پس این مشتری + به‌محض ورود شماره در گام اول شناسایی و قابل ورود است. +- هر دو ردیف داخل خودِ `prisma/seed.ts` با کامنت **DEV-ONLY** مشخص شده‌اند + و هرگز در استقرار واقعی اجرا نمی‌شوند — این فایل فقط با اجرای دستیِ + `npx prisma db seed` روی دیتابیس محلی اجرا می‌شود. + +**✅ فعال و تأییدشده.** `npx prisma db seed` اجرا شد و هر دو ردیف در +دیتابیس محلی ساخته شدند. لاگین به‌صورت زنده تست شد: + +``` +POST /api/admin/auth/login { phone: '09128081392', password: '09128081392' } +→ 200، super admin، ۱۴۴ rid، JWT صادر شد + +GET /api/customers +→ مشتری «کاربر تست توسعه» با mobile=09128081392 در لیست موجود و قابل + شناسایی توسط گام اول OTP در LoginView.vue +``` + +--- + +## ۵. نقد پورت و رفع مشکلات پنل ادمین + +کارفرما گزارش داد که پنل ادمین «هم دیزاین هم کارکرد» مشکل دارد و خواست که +**خودم پورت خودم را نقد کنم**. دو ممیزی مستقل (ظاهر + کارکرد) اجرا شد و +یافته‌های کلیدی‌شان را با خواندن مستقیم فایل‌ها تأیید کردم. + +### سه اشتباه اصلی که مرتکب شده بودم + +**۱. فرض کردم کلاس‌های Tailwind کار می‌کنند.** legacy یک بیلد کامل Tailwind +داشت (`tailwind.config.js`، ~۲۸KB) با مقیاس سفارشی `mb-base: 2.2rem`. من +`tailwindcss` را در `apps/admin-panel` نصب نکردم و `postcss.config` هم +نگذاشتم، ولی ~۱۸۵ کلاس Tailwind در قالب‌ها باقی ماند. بعد به‌جای رفع ریشه‌ای، +همان چند کلاس را **۱۲۴ بار در `<style scoped>` فایل‌ها کپی کردم** — و در این +کپی‌کاری `mb-base` را `1.5rem` نوشتم (۳۲٪ تنگ‌تر از legacy). + +**۲. مرز CSS scoped را نادیده گرفتم.** ۱۷ مورد `icon-class="w-5 h-5 text-primary"` +به `TooltipBtn` پاس داده می‌شد، ولی استایل scoped والد هرگز به فرزند نمی‌رسد و +هیچ `:deep()` در پروژه نبود. `h-5` هم اصلاً هیچ‌جا تعریف نشده بود. یعنی آن +۱۷ آیکون **بی‌رنگ و بی‌اندازه** رندر می‌شدند. + +**۳. لایه‌ی نگاشت را ناقص گذاشتم.** ۱۸ مسیر فرانت به بک‌اند نمی‌رسید — در حالی +که **مسیر بک‌اند هر ۱۸ مورد از قبل وجود داشت**. فقط ورودی مپ را ننوشته بودم. + +### بدترین مورد: تخریب داده + +در `bulkCustomer.vue` (و `ReagentCustomer.vue`) هنگام ویرایش، `Object.assign` +فقط ۹ فیلد را برمی‌گرداند و `store_name`/`pasazh_name`/`mcode`/`phone` را جا +می‌انداخت؛ آن‌ها `''` می‌ماندند، فرم کل `...form` را spread می‌کرد، و بک‌اند +`if (data.x !== undefined)` دارد — پس **هر بار ذخیره‌ی یک مشتری موجود، این +چهار ستون را پاک می‌کرد**. + +### آنچه انجام شد + +| مرحله | کار | +|---|---| +| ۱ | رفع تخریب داده: پرکردن کامل فرم روی ویرایش + ارسال **تفاضلی** (فقط فیلدهای تغییرکرده) در هر دو فرم | +| ۲ | ۹ الگو + ۲ ورودی دقیق به `endpoints.ts` → هر ۱۸ endpoint مرده زنده شد | +| ۳ | `assets/styles/utilities.less` سراسری با **مقادیر legacy**؛ حذف جراحی ۱۲۴ کپی تکراری از ۳۹ فایل | +| ۴ | `<a-config-provider :locale="faIR" direction="rtl">` در `App.vue` | +| ۵ | `reagent_id` و `password` (با bcrypt) در بک‌اند ذخیره می‌شوند | +| ۶ | دکمه‌ی «افزودن کاربر»، رفع تایپوی `color-success`، صادق‌سازی `mainLayoutType` | + +### نکات فنی مهم + +- **گریدها عمداً محلی ماندند.** مقادیر `minmax` در `.grid-2/3/4` بین فایل‌ها + فرق دارد (۲۰۰ تا ۳۲۰ پیکسل)؛ یکسان‌سازی‌شان چیدمان ~۲۶ فرم را بی‌صدا عوض + می‌کرد. فقط قواعدی حذف شدند که بدنه‌شان **دقیقاً** با نسخه‌ی سراسری یکی بود. +- **سه قاعده RTL-flipped هستند و همان‌طور ماندند:** `.ml-1`/`.ml-3` در پورت + `margin-right` می‌دهند و `.mr-4` می‌دهد `margin-left` — چون پنل RTL است. + در فایل سراسری هم عیناً حفظ شد و کامنت‌گذاری شد که «اصلاح» نشود. +- **`percent` عمداً دست‌نخورده ماند.** `Customer` اصلاً ستون `percent` ندارد؛ + این عدد از `AdminUser.wagePercent` کارشناس معرف می‌آید. یعنی ویرایشش از فرم + مشتری، کمیسیون آن کارشناس را برای **همه‌ی** مشتریانش عوض می‌کرد. نیاز به + تصمیم شما دارد. +- **چیدمان افقی حالت شعبه:** `themeConfig` مقدار `horizontal` می‌داد ولی + `Main.vue` همیشه منوی عمودی رندر می‌کرد. کامپوننت‌های `HorizontalNavMenu` + (۴ فایل) هرگز پورت نشده‌اند، پس به‌جای ساختن بی‌سروصدای آن‌ها، نوع به + `'vertical'` محدود شد تا کد ادعای دروغ نکند. پورت چیدمان افقی کار جداست. + +### تأیید اجرایی + +``` +داده‌رفتگی: ویرایش فقط-نام → ۰ فیلد از دست رفت (قبلاً هر ۴ پاک می‌شد) +Stage 5: reagent ذخیره شد · password یک hash واقعی bcrypt شد +endpointها: هر ۱۰ شکل بازیابی‌شده → ۲۰۰ (قبلاً ۴۰۴) +typecheck: admin ✓ · api ✓ +بیلد: admin ✓ · branch ✓ +``` + +> تست‌های نوشتن فقط روی مشتری‌ای انجام شد که خودم ساختم و بعد حذف کردم. + +--- + +## ۶. ابعاد نهایی کد (تا این لحظه) + +| معیار | مقدار | +|---|---| +| فایل‌های `.vue` پورت‌شده (views) | ۸۰ | +| ماژول‌های بک‌اند NestJS | ۲۵ | +| مدل‌های Prisma | ۳۵ | +| ورودی‌های دقیق لایه‌ی نگاشت (endpoints.ts) | ۷۷ + ۲۶ الگو | +| باگ/کوییرک legacy ثبت‌شده | ۳۶ | +| مسیرهای ادمین نگاشت‌شده | ۱۵۴ | +| پوشش ACL (rid) | ۱۴۴ / ۱۴۴ | +| کاهش حجم با کامپوننت مشترک (۵ لیست عمده + ۲ جزئیات مشتری + ۲ فرم تخفیف) | ۴٬۶۷۶ خط → ۱٬۱۳۹ خط | + +--- + +## ۷. آنچه این تاریخچه **نمی‌تواند** تأیید کند + +⚠️ **ظاهر رندرشده در مرورگر — مهم‌تر از همیشه.** هیچ view ای چشمی دیده نشده؛ +همه‌ی تأییدها API و بیلد و typecheck بوده. دو تغییر اخیر بیشترین ریسک بصری را +دارند و **حتماً** باید چشمی دیده شوند: + +- **`direction="rtl"` روی ConfigProvider.** `ant.less` دستی برای ant **۱.x** + نوشته شده؛ حالا ant ۳ هم RTL خودش را اعمال می‌کند. احتمال تداخل یا + دوبار‌اعمال‌شدن هست و بیلد آن را نمی‌گیرد. +- **`mb-base` از `1.5rem` به `2.2rem` رفت** (مقدار واقعی legacy). فاصله‌ی + بین کارت‌ها در ۱۳ view عمداً بازتر می‌شود — این تغییر خواسته‌شده است، نه باگ. + +به‌علاوه آواتار، اسلایدر، و فرم دسته‌بندی که uploader واقعی گرفتند. + +⚠️ **مسیرهای نوشتن با نقش‌های کم‌دسترسی.** دود‌تست فقط روی مسیرهای خواندنی +انجام شد تا داده خراب نشود؛ مسیرهای نوشتن در همان batch خودشان تست شدند اما +نه به‌صورت ماتریسی روی همه‌ی نقش‌ها. + +⚠️ **درگاه پیامک واقعی وصل نیست** — پیام ثبت می‌شود اما ارسال نمی‌شود. + +--- + +## مرجع + +برای جزئیات فنی کامل هر batch — شامل کد دقیق تغییرات، دلایل هر تصمیم +معماری، و متن کامل دیالوگ تصمیم‌گیری — به +[`docs/ADMIN-PORT-PLAN.md`](./ADMIN-PORT-PLAN.md) مراجعه کنید. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc528ee..2c039b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,27 @@ importers: apps/admin-panel: dependencies: + '@ant-design/icons-vue': + specifier: ^6.1.0 + version: 6.1.0(vue@3.5.42(typescript@5.9.3)) '@gold-bank/shared-types': specifier: workspace:* version: link:../../packages/shared-types + '@vueuse/core': + specifier: ^9.13.0 + version: 9.13.0(vue@3.5.42(typescript@5.9.3)) + ant-design-vue: + specifier: ^3.2.20 + version: 3.2.20(vue@3.5.42(typescript@5.9.3)) + axios: + specifier: ^1.7.7 + version: 1.20.0 + lodash: + specifier: ^4.17.21 + version: 4.18.1 + moment-jalaali: + specifier: ^0.10.0 + version: 0.10.5 pinia: specifier: ^2.2.0 version: 2.3.1(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) @@ -30,9 +48,21 @@ importers: specifier: ^4.4.0 version: 4.6.4(vue@3.5.42(typescript@5.9.3)) devDependencies: + '@types/lodash': + specifier: ^4.17.0 + version: 4.17.25 '@vitejs/plugin-vue': specifier: ^5.1.0 version: 5.2.4(vite@5.4.21(@types/node@20.19.43)(less@4.9.1)(terser@5.51.2))(vue@3.5.42(typescript@5.9.3)) + autoprefixer: + specifier: ^10.4.2 + version: 10.5.5(postcss@8.5.28) + less: + specifier: ^4.1.2 + version: 4.9.1 + postcss: + specifier: ^8.4.5 + version: 8.5.28 typescript: specifier: ^5.5.0 version: 5.9.3 @@ -54,18 +84,33 @@ importers: '@nestjs/core': specifier: ^10.4.0 version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^12.0.1 + version: 12.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/passport': + specifier: ^10.0.3 + version: 10.0.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^10.4.0 version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@prisma/client': specifier: ^5.19.0 version: 5.22.0(prisma@5.22.0) + bcryptjs: + specifier: ^3.0.3 + version: 3.0.3 class-transformer: specifier: ^0.5.1 version: 0.5.1 class-validator: specifier: ^0.14.1 version: 0.14.4 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -76,12 +121,18 @@ importers: '@nestjs/cli': specifier: ^10.4.5 version: 10.4.9 + '@types/bcryptjs': + specifier: ^3.0.0 + version: 3.0.0 '@types/express': specifier: ^4.17.21 version: 4.17.25 '@types/node': specifier: ^20.14.0 version: 20.19.43 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 prisma: specifier: ^5.19.0 version: 5.22.0 @@ -956,6 +1007,17 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/jwt@12.0.1': + resolution: {integrity: sha512-AOvjo0JIuVMNr+6JgbEz4fP+4afclGR5ffZnHtjOFs4+HhzerUYKcIVUL06u5uQYQi/ra8WHO+NZR7OFdQeGvA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + + '@nestjs/passport@10.0.3': + resolution: {integrity: sha512-znJ9Y4S8ZDVY+j4doWAJ8EuuVO7SkQN3yOBmzxbGaXbvcSwFDAdGJ+OMCg52NdzIO4tQoN4pYKx8W6M0ArfFRQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + passport: ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-express@10.4.22': resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==} peerDependencies: @@ -1242,6 +1304,10 @@ packages: cpu: [arm64] os: [win32] + '@types/bcryptjs@3.0.0': + resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} + deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -1269,15 +1335,33 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1455,6 +1539,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1558,6 +1646,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} @@ -1573,6 +1664,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: @@ -1603,6 +1697,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@3.0.3: + resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1633,6 +1731,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1726,6 +1827,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1883,6 +1988,10 @@ packages: defined@1.0.1: resolution: {integrity: sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1922,6 +2031,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2103,6 +2215,15 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2118,6 +2239,10 @@ packages: typescript: '>3.6.0' webpack: ^5.11.0 + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2253,6 +2378,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2504,6 +2633,16 @@ packages: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + less@4.9.1: resolution: {integrity: sha512-orp15PfJvvNDIqJdVWzMI9Sjpjp3VTiw3sfvbB+67LlISTEn8uVT2EdYSuyl02BLvaftv6sdk9Umnxmm5rckmg==} engines: {node: '>=18'} @@ -2533,9 +2672,30 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -2780,6 +2940,17 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + passport-jwt@4.0.1: + resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -2808,6 +2979,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2895,6 +3069,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} @@ -4609,6 +4787,17 @@ snapshots: transitivePeerDependencies: - encoding + '@nestjs/jwt@12.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/passport@10.0.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -4846,6 +5035,10 @@ snapshots: '@turbo/windows-arm64@2.10.12': optional: true + '@types/bcryptjs@3.0.0': + dependencies: + bcryptjs: 3.0.3 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -4885,14 +5078,37 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.43 + + '@types/lodash@4.17.25': {} + '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 '@types/parse-json@4.0.2': {} + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 4.17.25 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 4.17.25 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -5137,6 +5353,12 @@ snapshots: acorn@8.18.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv-formats@2.1.1(ajv@8.12.0): optionalDependencies: ajv: 8.12.0 @@ -5254,6 +5476,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + at-least-node@1.0.0: {} autoprefixer@10.5.5(postcss@8.5.28): @@ -5269,6 +5493,16 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): dependencies: '@babel/compat-data': 7.29.7 @@ -5301,6 +5535,8 @@ snapshots: baseline-browser-mapping@2.11.21: {} + bcryptjs@3.0.3: {} + binary-extensions@2.3.0: {} bl@4.1.0: @@ -5351,6 +5587,8 @@ snapshots: node-releases: 2.0.54 update-browserslist-db: 1.3.2(browserslist@4.28.9) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -5442,6 +5680,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@2.20.3: {} commander@4.1.1: {} @@ -5584,6 +5826,8 @@ snapshots: defined@1.0.1: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} destroy@1.2.0: {} @@ -5616,6 +5860,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} ejs@3.1.10: @@ -5894,6 +6142,8 @@ snapshots: transitivePeerDependencies: - supports-color + follow-redirects@1.16.0: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -5920,6 +6170,14 @@ snapshots: typescript: 5.7.2 webpack: 5.97.1 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -6063,6 +6321,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -6321,6 +6586,30 @@ snapshots: jsonpointer@5.0.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + less@4.9.1: dependencies: copy-anything: 3.0.5 @@ -6350,9 +6639,23 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: optional: true + lodash.once@4.1.1: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -6572,6 +6875,19 @@ snapshots: parseurl@1.3.3: {} + passport-jwt@4.0.1: + dependencies: + jsonwebtoken: 9.0.3 + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-browserify@1.0.1: {} path-key@3.1.1: {} @@ -6594,6 +6910,8 @@ snapshots: path-type@4.0.0: {} + pause@0.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -6671,6 +6989,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + prr@1.0.1: optional: true