feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
+2
View File
@@ -7,3 +7,5 @@ dist/
*.log
.DS_Store
apps/api/generated/
apps/api/storage/uploads/*
!apps/api/storage/uploads/.gitkeep
+12
View File
@@ -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
+6 -3
View File
@@ -1,12 +1,15 @@
<!doctype html>
<html lang="en">
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gold Bank — Admin Panel</title>
<title>هنزاگلد — پنل مدیریت</title>
</head>
<body>
<div id="app"></div>
<div id="app">
<!-- Removed by router.afterEach, ported from legacy henza_admin/src/router.js:147-153 -->
<div id="loading-bg"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+15 -2
View File
@@ -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"
+93 -18
View File
@@ -1,23 +1,98 @@
<script setup lang="ts"></script>
<template>
<header>
<h2>Gold Bank — Admin</h2>
<nav>
<router-link to="/">Dashboard</router-link>
</nav>
</header>
<main>
<router-view />
</main>
<!--
ConfigProvider supplies ant-design-vue's OWN strings and text direction.
Without it ant rendered its built-in copy — pagination ("items/page"), table
filter menus ("Reset"/"OK"), Select's "No Data", Popconfirm's default
buttons — in ENGLISH inside an otherwise fully Farsi RTL panel.
NOTE this covers ant's own chrome only. Jalali dates still come from
moment-jalaali via components/input/DateInput.vue, which is unaffected.
-->
<a-config-provider :locale="faIR" direction="rtl">
<div id="app" :class="vueAppClasses">
<router-view @setAppClasses="setAppClasses" />
</div>
</a-config-provider>
</template>
<style scoped>
header {
padding: 1rem;
border-bottom: 1px solid #ddd;
<script setup lang="ts">
// Ported from legacy henza_admin/src/App.vue.
// Dropped (Vue-2-only or dead in legacy): the VeeValidate Farsi dictionary (`$validator`
// has no Vue 3 equivalent and validation is re-done per-form with ant-design-vue rules),
// the commented-out Firebase/service-worker block, and the `$vs.rtl` watcher (Vuesax).
// RTL now comes from themeConfig.rtl directly, which is the same value legacy fed Vuesax.
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { notification } from 'ant-design-vue';
import faIR from 'ant-design-vue/es/locale/fa_IR';
import themeConfig from './themeConfig';
import { useLayoutStore } from './stores/layout';
import { userName } from './utils';
const layout = useLayoutStore();
const vueAppClasses = ref<string[]>([]);
function toggleClassInBody(className: string) {
if (className === 'dark') {
if (document.body.className.match('theme-semi-dark')) document.body.classList.remove('theme-semi-dark');
document.body.classList.add('theme-dark');
} else if (className === 'semi-dark') {
if (document.body.className.match('theme-dark')) document.body.classList.remove('theme-dark');
document.body.classList.add('theme-semi-dark');
} else {
if (document.body.className.match('theme-dark')) document.body.classList.remove('theme-dark');
if (document.body.className.match('theme-semi-dark')) document.body.classList.remove('theme-semi-dark');
}
}
main {
padding: 1rem;
function setAppClasses(classesStr: string) {
vueAppClasses.value.push(classesStr);
}
</style>
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);
});
</script>
-2
View File
@@ -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;
-39
View File
@@ -1,39 +0,0 @@
/// <reference types="../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
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 */
-48
View File
@@ -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<CustomerSummary[]>;
getCustomerBalance: (id: string) => Promise<CustomerBalanceDto>;
listProducts: () => Promise<ProductSummary[]>;
createOrder: (dto: CreateOrderDto) => Promise<OrderDetail>;
getOrder: (id: string) => Promise<OrderDetail>;
updateOrderStatus: (id: string, status: string, note?: string) => Promise<OrderDetail>;
};
-20
View File
@@ -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 }) }),
};
+71 -58
View File
@@ -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<T>(path: string, options?: RequestInit): Promise<T> {
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<CustomerSummary[]>('/customers'),
getCustomerBalance: (id: string) => request<CustomerBalanceDto>(`/accounting/customers/${id}/balance`),
listProducts: () => request<ProductSummary[]>('/catalog/products'),
createOrder: (dto: CreateOrderDto) =>
request<OrderDetail>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
getOrder: (id: string) => request<OrderDetail>(`/orders/${id}`),
updateOrderStatus: (id: string, status: string, note?: string) =>
request<OrderDetail>(`/orders/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status, note }) }),
const headers: Record<string, string> = {
'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;
+267
View File
@@ -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<string, string> = {
// ── 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));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 425.2 425.2" style="enable-background:new 0 0 425.2 425.2;" xml:space="preserve">
<style type="text/css">
.st0{fill:#21284E;}
.st1{fill:#896B43;}
.st2{fill:#999998;}
</style>
<g id="XMLID_22_">
<g id="XMLID_125_">
<path id="XMLID_135_" class="st0" d="M109.6,299.5v0.7H93.5v-0.7l0.7-0.1c1.4-0.1,2.5-0.5,3.1-1.2c0.6-0.8,0.9-1.9,0.9-3.6v-15.4
H75.5v15.4c0,1.6,0.3,2.8,0.9,3.6s1.7,1.2,3.1,1.2l0.7,0.1v0.7H64.2v-0.7l0.7-0.1c1.4-0.1,2.5-0.5,3.1-1.2
c0.6-0.8,0.9-1.9,0.9-3.6v-30.2c0-1.6-0.3-2.8-0.9-3.6c-0.6-0.8-1.7-1.2-3.1-1.2l-0.7-0.1v-0.7h16.1v0.7l-0.7,0.1
c-1.4,0.1-2.5,0.5-3.1,1.2c-0.6,0.8-0.9,2-0.9,3.6v13.3h22.8v-13.3c0-1.6-0.3-2.8-0.9-3.6c-0.6-0.8-1.7-1.2-3.1-1.2l-0.7-0.1v-0.7
h16.1v0.7l-0.7,0.1c-1.4,0.1-2.5,0.5-3.1,1.2s-0.9,1.9-0.9,3.6v30.2c0,1.6,0.3,2.8,0.9,3.6s1.7,1.2,3.1,1.2L109.6,299.5z"/>
<path id="XMLID_133_" class="st0" d="M155.1,284.3L155.1,284.3l-0.3-0.8c-0.5-1.6-1.3-2.7-2.5-3.5c-1.2-0.7-2.9-1.1-5-1.1h-7.4
v19.6h9.9c2.4,0,4.4-0.5,6.1-1.6s3.1-2.8,4.4-5.2l0.9-1.6h0.5c-0.2,1.4-0.5,3-0.8,4.7s-0.6,3.2-0.8,4.6H160l-0.1,0.8h-31.3v-0.7
l0.7-0.1c1.4-0.1,2.5-0.5,3.1-1.2c0.6-0.8,0.9-1.9,0.9-3.6v-30.2c0-1.6-0.3-2.8-0.9-3.6c-0.6-0.8-1.7-1.2-3.1-1.2l-0.7-0.1v-0.7
h31.3V269h-0.5l-0.5-1.6c-0.8-2.4-1.9-4.1-3.4-5.2c-1.5-1.1-3.4-1.6-5.8-1.6H140v16.8h7.4c2.1,0,3.8-0.3,5-1s2-1.7,2.5-3.1
l0.2-0.7h0.5v11.6L155.1,284.3L155.1,284.3z"/>
<path id="XMLID_131_" class="st0" d="M180.8,259.6l-0.1-0.7h10.8l24.2,31.8v-26.2c0-1.6-0.3-2.8-0.9-3.6c-0.6-0.8-1.7-1.2-3.1-1.2
l-0.7-0.1v-0.7h11.4v0.7l-0.7,0.1c-1.4,0.1-2.5,0.5-3.1,1.2c-0.6,0.8-0.9,1.9-0.9,3.6v37.1h-2c-4.7-6.2-9.4-12.3-14-18.5
c-4.7-6.1-9.4-12.3-14.1-18.5v30.1c0,1.6,0.3,2.8,0.9,3.6c0.6,0.8,1.7,1.2,3.1,1.2l0.7,0.1v0.7h-11.4v-0.7l0.7-0.1
c1.4-0.1,2.5-0.5,3.1-1.2c0.6-0.8,0.9-1.9,0.9-3.6v-30.2c0-1.6-0.3-2.8-0.9-3.6c-0.6-0.8-1.7-1.2-3.1-1.2L180.8,259.6z"/>
<path id="XMLID_129_" class="st0" d="M273.1,289.1h-0.2c0.2,0,0.3,0.1,0.4,0.1c0.3,0.2,0.4,0.3,0.4,0.4c-0.2,1.5-0.5,3.1-0.8,4.9
s-0.6,3.4-0.8,4.9l-0.1,0.9h-30.7l23.8-39.7h-11.5c-1.9,0-3.6,0.5-5.1,1.3c-1.5,0.9-2.9,2.3-4.3,4.2l-1.3,2h-0.5
c0.4-1.3,0.7-2.6,1.2-4.2s0.8-2.9,1.2-4.2h-0.1l0.2-0.9h29.1L250,298.5h12.7c2.2,0,4-0.5,5.4-1.5s2.6-2.6,3.6-4.8L273.1,289.1z"/>
<path id="XMLID_126_" class="st0" d="M292.9,300.3v-0.7l0.8-0.1c2.6-0.1,4.4-1.5,5.6-4.2l14.2-37.4h1.6l16.1,36.8
c0.9,1.9,1.8,3.2,2.6,3.9s2.1,1,3.6,1v0.7h-17.7v-0.7l1.5-0.1c1.6-0.1,2.6-0.5,3.1-1.2c0.4-0.7,0.3-1.9-0.4-3.6l-3.8-9h-15.2
l-3.1,8.5c-0.6,1.8-0.7,3.1-0.1,4s1.8,1.3,3.7,1.4l1,0.1v0.7H292.9z M319.4,283.9l-7.4-17.5l-6.4,17.5H319.4z"/>
</g>
</g>
<g id="XMLID_3031_">
<path id="XMLID_3149_" class="st1" d="M176.7,153.1c18.5,0,29.8,6.9,30.1,23.7c0.3-16.8,11.6-23.7,30.1-23.7v0
c-18.5,0-29.8-6.9-30.1-23.7C206.5,146.2,195.2,153.1,176.7,153.1"/>
<g id="XMLID_3102_">
<g id="XMLID_31_">
<path id="XMLID_32_" class="st0" d="M175.6,148.1v43.4c-0.1,14.1-3.3,24-9.4,29.5c-3.8,3.3-8.9,5.7-15.3,7.1l-0.8-1.5
c5-2.6,8.7-6.7,11.1-12.3s3.6-13.2,3.6-22.9v-89.1c0-2.9-1-5.3-3.1-7.4c-2-2.1-4.6-3.1-7.6-3.1h-2v-1.7h36.2V92h-2.2
c-2.8,0-5.2,1-7.2,2.9c-2,1.9-3.2,4.3-3.4,7.2v43.1"/>
</g>
</g>
<g id="XMLID_3033_">
<g id="XMLID_30_">
<path id="XMLID_34_" class="st0" d="M238.9,145.2v-43.1c-0.2-2.9-1.4-5.3-3.4-7.2c-2.1-1.9-4.4-2.9-7.2-2.9h-2.2v-1.7h36.2V92h-2
c-3,0-5.5,1-7.6,3.1c-2.1,2-3.1,4.5-3.1,7.4v89.1c0,9.7,1.2,17.4,3.6,22.9s6.1,9.6,11.1,12.3l-0.8,1.5
c-6.4-1.4-11.5-3.8-15.3-7.1c-6.2-5.5-9.3-15.4-9.4-29.5v-43.4"/>
</g>
</g>
</g>
<g id="XMLID_1_">
<g id="XMLID_160_">
<path id="XMLID_175_" class="st2" d="M140,323.8v4.5c-0.3,0.3-0.6,0.5-0.9,0.6s-0.6,0.3-1,0.4s-0.7,0.2-1.1,0.2s-0.8,0-1.2,0
c-0.8,0-1.5-0.1-2.2-0.5c-0.7-0.3-1.3-0.7-1.8-1.2c-0.5-0.5-0.9-1.1-1.2-1.8s-0.4-1.4-0.4-2.2c0-0.5,0.1-1,0.2-1.5
s0.3-0.9,0.6-1.4c0.2-0.4,0.6-0.8,0.9-1.1c0.4-0.4,0.7-0.6,1.1-0.9s0.9-0.4,1.4-0.6c0.5-0.1,1-0.2,1.5-0.2c0.4,0,0.8,0,1.2,0.1
s0.8,0.2,1.1,0.4c0.4,0.2,0.7,0.4,1,0.6c0.3,0.2,0.6,0.5,0.9,0.8l-0.3,0.3c-0.1,0.1-0.3,0.2-0.4,0.3s-0.3,0.1-0.5,0.1
c-0.2,0-0.4-0.1-0.6-0.2c-0.3-0.2-0.7-0.4-1.1-0.5c-0.4-0.1-0.8-0.2-1.3-0.2c-0.6,0-1.1,0.1-1.6,0.3s-0.9,0.5-1.3,0.9
c-0.4,0.4-0.7,0.8-0.9,1.3s-0.3,1-0.3,1.6s0.1,1.1,0.3,1.6s0.5,0.9,0.9,1.3s0.8,0.7,1.4,0.9c0.5,0.2,1.1,0.3,1.6,0.3
c0.5,0,0.9-0.1,1.4-0.2c0.4-0.1,0.8-0.3,1.2-0.5v-2h-0.6c-0.2,0-0.4,0-0.5-0.1c-0.1-0.1-0.3-0.1-0.4-0.3s-0.2-0.2-0.2-0.4
s-0.1-0.3-0.1-0.5v-0.3L140,323.8L140,323.8z"/>
<path id="XMLID_172_" class="st2" d="M160.3,318.3h1.5l4.9,11.2h-0.9c-0.2,0-0.5-0.1-0.7-0.2s-0.4-0.3-0.4-0.5l-0.8-1.8h-5.7
l-0.8,1.8c-0.1,0.2-0.2,0.4-0.4,0.5c-0.2,0.1-0.4,0.2-0.7,0.2h-0.9L160.3,318.3z M160.9,320.8l-2,4.7h4.4l-2-4.7
c-0.1-0.2-0.1-0.4-0.1-0.7c0,0.1,0,0.2-0.1,0.4S161,320.7,160.9,320.8z"/>
<path id="XMLID_170_" class="st2" d="M182.5,329.5v-11.2h1.5v9.6h4.8v1.5L182.5,329.5L182.5,329.5z"/>
<path id="XMLID_168_" class="st2" d="M205.2,329.5v-11.2h1.5v9.6h4.8v1.5L205.2,329.5L205.2,329.5z"/>
<path id="XMLID_166_" class="st2" d="M234.8,329.5h-6.9v-11.2h6.9v1.5h-5.4v3.2h4.3v1.5h-4.3v3.3h5.4V329.5z"/>
<path id="XMLID_163_" class="st2" d="M253.3,329.5h-1.5v-11.2h4c0.5,0,0.9,0.1,1.3,0.3c0.4,0.2,0.7,0.4,1,0.7
c0.3,0.3,0.5,0.6,0.7,1s0.3,0.8,0.3,1.3c0,0.4-0.1,0.7-0.2,1c-0.1,0.3-0.3,0.6-0.5,0.9s-0.4,0.5-0.7,0.7c-0.3,0.2-0.6,0.4-0.9,0.5
l3.5,4.8h-1.1c-0.3,0-0.6-0.1-0.8-0.2c-0.2-0.1-0.4-0.3-0.6-0.5l-2.9-3.9h-1.7V329.5z M253.3,319.9v3.4h2.5c0.2,0,0.5,0,0.7-0.1
c0.2-0.1,0.4-0.2,0.5-0.4c0.2-0.2,0.3-0.3,0.4-0.5s0.1-0.4,0.1-0.7s0-0.5-0.1-0.7s-0.2-0.4-0.4-0.5c-0.2-0.2-0.3-0.3-0.5-0.4
c-0.2-0.1-0.4-0.1-0.7-0.1L253.3,319.9L253.3,319.9z"/>
<path id="XMLID_161_" class="st2" d="M280.6,329.5H279v-4.7c0-0.1,0-0.2,0-0.3c0-0.1,0-0.2-0.1-0.2s-0.1-0.2-0.1-0.2
c-0.1-0.1-0.1-0.2-0.2-0.3l-4-5.4h1.1c0.3,0,0.6,0.1,0.8,0.2c0.2,0.1,0.4,0.3,0.6,0.5l2.6,3.6c0,0.1,0.1,0.1,0.1,0.2s0,0.1,0,0.2
c0,0,0-0.1,0-0.2s0-0.1,0.1-0.2l2.6-3.6c0.1-0.2,0.3-0.4,0.6-0.5s0.5-0.2,0.8-0.2h1.1l-4,5.4c-0.1,0.1-0.1,0.2-0.2,0.3
s-0.1,0.2-0.1,0.2s-0.1,0.2-0.1,0.2c0,0.1,0,0.2,0,0.3L280.6,329.5L280.6,329.5z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

+426
View File
@@ -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;
}
@@ -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%;
}
@@ -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';
@@ -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 `<style scoped>` block — 100+ copies
// across 49 files, which (a) drifted in value and (b) could not reach child
// components at all, because a scoped rule never applies inside a child.
//
// This file replaces all of that with ONE global definition, using LEGACY's
// values rather than the drifted ones.
// ─────────────────────────────────────────────────────────────────────────
// ── Design tokens ────────────────────────────────────────────────────
// Legacy themeConfig.js + vue.config.js modifyVars. These are also fed to the
// ant LESS build in vite.config.ts; declaring them here as custom properties
// makes them reachable from plain CSS too (App.vue:65 reads --primary-color).
//
// NOTE ant.less:5-7 declares `--primary: #41b883` / `--accent: #35495e` —
// ant-design-pro boilerplate in Vue's own palette, inherited verbatim from
// legacy. `--accent` drives @border-color-base, i.e. EVERY ant border. It is
// left alone here (changing it is a visual decision, not a bug fix), but the
// Henza tokens below are what application code should use.
:root {
--primary-color: #1c2d56;
--secondary-color: #836447;
--third-color: #f5cb55;
--success-color: #28c76f;
--danger-color: #ea5455;
--warning-color: #ff9f43;
}
// ── Text colour ──────────────────────────────────────────────────────
.text-primary { color: var(--primary-color); }
.text-secondary { color: var(--secondary-color); }
.text-third { color: var(--third-color); }
.text-success { color: var(--success-color); }
.text-danger { color: var(--danger-color); }
.text-warning { color: var(--warning-color); }
// ── Spacing ──────────────────────────────────────────────────────────
// legacy tailwind.config.js:628,664 — `base` is 2.2rem, NOT the 1.5rem the
// per-file copies drifted to.
.mb-base { margin-bottom: 2.2rem; }
.mt-base { margin-top: 2.2rem; }
.p-base { padding: 2.2rem; }
.mb-5 { margin-bottom: 1.25rem; }
.mx-1 { margin: 0 0.25rem; }
// RTL-FLIPPED. The ported views deliberately invert these: the panel is RTL,
// so a Tailwind `ml-*` from the legacy template must push to the RIGHT to keep
// the original visual gap. Verified against the per-file copies this layer
// replaces — .ml-1/.ml-3 were `margin-right`, .mr-4 was `margin-left`, in
// every occurrence. Keep the logical inversion; do NOT "correct" it.
.ml-1 { margin-right: 0.25rem; }
.ml-3 { margin-right: 0.75rem; }
.mr-4 { margin-left: 1rem; }
// ── Sizing ───────────────────────────────────────────────────────────
// Tailwind's 0.25rem scale. `h-5` was used 17 times and defined NOWHERE, so
// every `icon-class="w-5 h-5 …"` rendered at inherited size.
.w-5 { width: 1.25rem; }
.h-5 { height: 1.25rem; }
.w-6 { width: 1.5rem; }
.h-6 { height: 1.5rem; }
.w-full { width: 100%; }
// ── Layout ───────────────────────────────────────────────────────────
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 0.5rem; }
.cursor-pointer { cursor: pointer; }
// Responsive auto-fit grids. These are a port-ism (legacy used Tailwind's
// explicit `grid-cols-N` + breakpoint prefixes); kept because every ported
// form already binds them.
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
.grid-4 { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
// ── Card chrome ──────────────────────────────────────────────────────
// Legacy's <vx-card> (vuexy/components/vxCard.scss:1-11) carried a soft shadow
// and 8px radius. <a-card> is a flat 1px-bordered box, which is the single
// most visible difference across the 54 card sites in the port.
body #app .ant-card {
border-radius: 0.5rem;
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.1);
}
@@ -0,0 +1,87 @@
<template>
<!--
Ported from legacy henza_admin/src/components/input/DateInput.vue — the
Jalali date picker used by the report filters.
Legacy wrapped vue-persian-datetime-picker, which has no Vue 3 build. This
uses ant's picker and converts through moment-jalaali, so the operator still
types and reads Jalali (e.g. ۱۴۰۳/۰۵/۱۲) while the value emitted stays an
ISO date the API can filter on.
-->
<a-form-item :label="label">
<a-input
:value="displayValue"
:placeholder="placeholder"
allow-clear
@update:value="onType"
/>
<small v-if="hint" class="hint">{{ hint }}</small>
</a-form-item>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import moment from 'moment-jalaali';
const props = withDefaults(
defineProps<{
label?: string;
/** ISO date string, or undefined when empty. */
modelValue?: string;
placeholder?: string;
}>(),
{ placeholder: 'مثال: ۱۴۰۳/۰۵/۱۲' },
);
const emit = defineEmits<{ (e: 'update:modelValue', v: string | undefined): void }>();
const typed = ref<string | undefined>(undefined);
const hint = ref('');
/** ISO in -> Jalali out, for display. */
const displayValue = computed(() => {
if (typed.value !== undefined) return typed.value;
if (!props.modelValue) return '';
const m = moment(props.modelValue);
return m.isValid() ? m.format('jYYYY/jMM/jDD') : '';
});
watch(
() => props.modelValue,
() => {
// A change from outside overrides whatever was half-typed.
typed.value = undefined;
},
);
/**
* Accepts Jalali (۱۴۰۳/۰۵/۱۲ or 1403/05/12) and emits the ISO equivalent.
* Persian digits are normalised first so either numeral set works.
*/
function onType(raw: string) {
typed.value = raw;
if (!raw) {
hint.value = '';
emit('update:modelValue', undefined);
return;
}
const latin = raw.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
const m = moment(latin, 'jYYYY/jMM/jDD');
if (m.isValid()) {
hint.value = '';
emit('update:modelValue', m.format('YYYY-MM-DD'));
} else {
hint.value = 'تاریخ معتبر نیست';
}
}
</script>
<style scoped>
.hint {
color: #ea5455;
font-size: 11px;
}
</style>
@@ -0,0 +1,101 @@
<template>
<!--
Ported from legacy henza_admin/src/components/statistics-cards/StatisticsCardLine.vue.
The legacy component renders an ApexCharts sparkline under the figure, but
EVERY live caller passes :hideChart="true" (verified: 3 of 3 on the wholesale
dashboard), and the only apexchart markup in either dashboard sits inside
commented-out Vuexy demo cards. So the chart branch is not ported and
vue-apexcharts is not a dependency — adding it would ship a library nothing
renders.
-->
<a-card class="stat-card">
<div class="stat-row">
<span class="stat-icon" :class="`icon-${color}`">
<component :is="iconComponent" />
</span>
<div>
<h2 class="stat-value">{{ statistic }}</h2>
<span class="stat-title">{{ statisticTitle }}</span>
</div>
</div>
</a-card>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
ShoppingOutlined,
TeamOutlined,
DollarOutlined,
BarChartOutlined,
} from '@ant-design/icons-vue';
const props = withDefaults(
defineProps<{
icon?: string;
statistic?: string | number;
statisticTitle?: string;
color?: string;
/** Kept for call-site parity; the chart branch is not ported (see above). */
hideChart?: boolean;
}>(),
{ color: 'primary', hideChart: true },
);
/** feather name -> ant icon, matching the icons the live cards ask for */
const ICONS: Record<string, unknown> = {
UsersIcon: TeamOutlined,
ShoppingBagIcon: ShoppingOutlined,
DollarSignIcon: DollarOutlined,
BarChartIcon: BarChartOutlined,
};
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined);
</script>
<style scoped>
.stat-card {
height: 100%;
}
.stat-row {
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
font-size: 22px;
flex-shrink: 0;
}
.icon-primary {
background: rgba(28, 45, 86, 0.1);
color: #1c2d56;
}
.icon-warning {
background: rgba(255, 159, 67, 0.12);
color: #ff9f43;
}
.icon-success {
background: rgba(40, 199, 111, 0.12);
color: #28c76f;
}
.icon-danger {
background: rgba(234, 84, 85, 0.12);
color: #ea5455;
}
.stat-value {
margin: 0;
font-size: 20px;
color: #1c2d56;
}
.stat-title {
font-size: 12px;
color: #888;
}
</style>
@@ -0,0 +1,80 @@
<template>
<!--
Ported from legacy henza_admin/src/components/tooltip-btn/TooltipBtn.vue.
Same API (title, popTitle, icon, iconClass) and same behaviour: with popTitle
it confirms first, without it fires immediately.
Legacy rendered <feather-icon icon="ListIcon">. vue-feather has no Vue 3
build, so the feather NAME is mapped to an ant-design icon here. Ported views
keep writing icon="ListIcon" unchanged.
-->
<div>
<a-tooltip :title="title">
<a-popconfirm
v-if="popTitle"
ok-text="بله"
cancel-text="خیر"
:title="popTitle"
@confirm="onClick"
>
<component :is="iconComponent" class="cursor-pointer" :class="iconClass" />
</a-popconfirm>
<div v-else>
<component
:is="iconComponent"
class="cursor-pointer"
:class="iconClass"
@click="onClick"
/>
</div>
</a-tooltip>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import {
CameraOutlined,
CheckOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
FileOutlined,
FileTextOutlined,
PlusOutlined,
UnorderedListOutlined,
CloseOutlined,
} from '@ant-design/icons-vue';
const props = withDefaults(
defineProps<{
title?: string;
popTitle?: string;
icon?: string;
iconClass?: string;
}>(),
{ iconClass: 'w-5 h-5' },
);
const emit = defineEmits<{ (e: 'click'): void }>();
/** feather name -> ant component */
const ICONS: Record<string, unknown> = {
ListIcon: UnorderedListOutlined,
FileIcon: FileOutlined,
TrashIcon: DeleteOutlined,
EditIcon: EditOutlined,
CheckIcon: CheckOutlined,
CameraIcon: CameraOutlined,
EyeIcon: EyeOutlined,
PlusIcon: PlusOutlined,
XIcon: CloseOutlined,
FileTextIcon: FileTextOutlined,
};
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? UnorderedListOutlined);
function onClick() {
emit('click');
}
</script>
@@ -0,0 +1,119 @@
<template>
<!--
Ports legacy henza_admin/src/components/input/SingleUploadInput.vue (and the
near-identical MyUpload.vue / UploadInput.vue), which posted multipart to
`file/create` and emitted the response once it carried an `id`.
Legacy shipped FOUR of these wrappers differing only in list type and the
emitted event; this is the single shared version, matching how the rest of
this port deduplicates legacy's copy-paste components.
The bound value is the stored file's URL, which is what the schema keeps.
-->
<div class="image-upload">
<a-upload
:file-list="fileList"
list-type="picture-card"
:max-count="1"
accept="image/*"
:custom-request="doUpload"
@remove="handleRemove"
@preview="handlePreview"
>
<div v-if="fileList.length === 0">
<a-spin v-if="uploading" size="small" />
<div v-else>
<plus-outlined />
<div style="margin-top: 8px">{{ placeholder }}</div>
</div>
</div>
</a-upload>
<a-modal :open="previewVisible" :footer="null" @cancel="previewVisible = false">
<img :src="previewSrc" style="width: 100%" alt="" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import api from '../../api/client';
const props = withDefaults(
defineProps<{
/** The stored file URL. */
modelValue?: string | null;
/** Free-form tag recorded on the upload, e.g. 'category' or 'product'. */
purpose?: string;
placeholder?: string;
}>(),
{ modelValue: null, purpose: undefined, placeholder: 'بارگذاری' },
);
const emit = defineEmits<{
(e: 'update:modelValue', v: string | null): void;
/** Legacy emitted the whole response object as `ok`. */
(e: 'ok', file: Record<string, unknown>): void;
}>();
const uploading = ref(false);
const previewVisible = ref(false);
const previewSrc = ref('');
interface UiFile {
uid: string;
name: string;
status: 'done';
url: string;
}
const fileList = ref<UiFile[]>([]);
/** Keeps the thumbnail in step when the parent loads an existing record. */
watch(
() => props.modelValue,
(url) => {
if (!url) {
fileList.value = [];
} else if (fileList.value[0]?.url !== url) {
fileList.value = [{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url }];
}
},
{ immediate: true },
);
const placeholder = computed(() => props.placeholder);
async function doUpload({ file }: { file: File }) {
const form = new FormData();
form.append('file', file);
if (props.purpose) form.append('purpose', props.purpose);
uploading.value = true;
try {
// Let the browser set the multipart boundary — overriding Content-Type here
// is the classic way to break a multipart POST.
const { data } = await api.post('file/create', form);
emit('update:modelValue', data.url);
emit('ok', data);
} catch (e) {
const msg =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
'بارگذاری فایل ناموفق بود';
message.error(msg);
} finally {
uploading.value = false;
}
}
function handleRemove() {
emit('update:modelValue', null);
return true;
}
function handlePreview(file: UiFile) {
previewSrc.value = file.url;
previewVisible.value = true;
}
</script>
@@ -0,0 +1,365 @@
<template>
<!--
Ported from legacy henza_admin/src/components/x-table/XTable.vue (572 lines).
Preserved: the server contract (POST {search, filters, page, limit, select,
titles, report_title}), the { rows, query } response shape, the filter bar,
the column show/hide picker, search, sorting and the named cell slots.
Dropped: the fullscreen toggle and the excel/json/print export buttons —
exports needed a legacy endpoint that returned a file url, which the new
backend does not implement. The buttons are omitted rather than left as
dead controls that silently fail.
-->
<div class="x-table">
<div class="x-table__toolbar">
<a-input-search
v-if="!model._hideSearch"
v-model:value="query.search"
placeholder="جستجو"
allow-clear
style="max-width: 260px"
@search="onSearch"
/>
<div class="x-table__actions">
<a-button v-if="!model._noFilters" :type="activeFilter ? 'primary' : 'default'" @click="toggleVisibleFilter">
فیلتر
<a-badge v-if="query.filters.length" :count="query.filters.length" />
</a-button>
<a-dropdown :trigger="['click']">
<a-button>ستون‌ها</a-button>
<template #overlay>
<a-menu>
<a-menu-item v-for="col in cols" :key="col.key">
<a-checkbox v-model:checked="col.show">{{ col.title }}</a-checkbox>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button class="refresh-btn" :loading="loading" @click="fetch()">بارگذاری مجدد</a-button>
</div>
</div>
<!-- filter bar: one row per active filter -->
<div v-if="activeFilter" class="x-table__filters">
<div v-for="(f, index) in query.filters" :key="f.id" class="x-table__filter-row">
<a-select
v-model:value="f.field"
placeholder="ستون"
style="width: 180px"
:options="filterableOptions"
/>
<a-select v-model:value="f.op" placeholder="شرط" style="width: 130px" :options="opOptions" />
<!--
A column declared with filterSelect({...}) gets a dropdown of its
value->label map; a date column gets a date picker; everything else
gets a free-text box, as in legacy.
-->
<a-select
v-if="selectItemsFor(f.field)"
v-model:value="f.value"
placeholder="مقدار"
style="width: 200px"
:options="selectItemsFor(f.field)"
/>
<a-date-picker
v-else-if="isDateField(f.field)"
v-model:value="f.value"
placeholder="مقدار"
style="width: 200px"
value-format="YYYY-MM-DD"
/>
<a-input v-else v-model:value="f.value" placeholder="مقدار" style="width: 200px" />
<a-input
v-if="f.op === 'between'"
v-model:value="f.value2"
placeholder="تا"
style="width: 200px"
/>
<a-button danger @click="removeFilter(index)">حذف</a-button>
</div>
<div class="x-table__filter-actions">
<a-button @click="addFilterRow">افزودن فیلتر</a-button>
<a-button type="primary" @click="doFilter">اعمال</a-button>
</div>
</div>
<a-table
:columns="antColumns"
:data-source="data"
:loading="loading"
:pagination="paginationConfig"
:locale="locale"
:row-selection="rowSelection"
row-key="id"
size="middle"
bordered
@change="onTableChange"
>
<template #bodyCell="{ column, record, text }">
<!--
Legacy used named scoped slots (renderSlot('opt') -> slot="opt").
Vue 3 routes them through #bodyCell, and the slot name is forwarded to
the parent under the same name, so ported views keep their markup.
-->
<template v-if="slotNameFor(column)">
<slot :name="slotNameFor(column)" :text="text" :record="record" />
</template>
</template>
</a-table>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import api from '../../api/client';
import type { XTbl } from './model';
const props = withDefaults(
defineProps<{
model: XTbl;
/** legacy prop: renders a checkbox column (ProductList uses this) */
hasSelection?: boolean;
/** legacy used v-model for the selected rows */
modelValue?: Record<string, unknown>[];
}>(),
{ hasSelection: false, modelValue: () => [] },
);
const emit = defineEmits<{
(e: 'query', q: unknown): void;
(e: 'update:modelValue', rows: Record<string, unknown>[]): void;
}>();
/** Only present when hasSelection is set, so other tables are unaffected. */
const rowSelection = computed(() =>
props.hasSelection
? {
selectedRowKeys: props.modelValue.map((r) => r.id as string),
onChange: (_keys: (string | number)[], rows: Record<string, unknown>[]) => {
emit('update:modelValue', rows);
},
}
: undefined,
);
interface FilterRow {
id: number;
field?: string;
op?: string;
value?: unknown;
value2?: unknown;
}
const data = ref<Record<string, unknown>[]>([]);
const loading = ref(false);
const activeFilter = ref(false);
const sort = ref<string | undefined>(undefined);
const sortDirection = ref<'asc' | 'desc'>('asc');
// legacy data.query
const query = reactive({
search: undefined as string | undefined,
filters: [] as FilterRow[],
total: 0,
limit: 10,
page: 1,
});
const locale = { emptyText: 'هیج رکوردی یافت نشد.' };
const opOptions = [
{ value: '=', label: 'برابر' },
{ value: '!=', label: 'نامساوی' },
{ value: 'like', label: 'شامل' },
{ value: '>', label: 'بزرگتر' },
{ value: '>=', label: 'بزرگتر مساوی' },
{ value: '<', label: 'کوچکتر' },
{ value: '<=', label: 'کوچکتر مساوی' },
{ value: 'between', label: 'بین' },
];
const cols = computed(() => props.model?.cols ?? []);
const visibleCols = computed(() => cols.value.filter((c) => c.show));
const filterableOptions = computed(() =>
cols.value.filter((c) => c.filtered).map((c) => ({ value: c.__fkey ?? c.key, label: c.title })),
);
/** legacy `order` computed: '+field' / '-field' */
const order = computed(() =>
sort.value ? `${sortDirection.value === 'asc' ? '+' : '-'}${sort.value}` : undefined,
);
const antColumns = computed(() =>
visibleCols.value.map((c) => ({
key: c.key,
title: c.title,
dataIndex: c.dataIndex.includes('.') ? c.dataIndex.split('.') : c.dataIndex,
sorter: c.__sorter,
width: c.width,
__slot: c.scopedSlots?.customRender,
})),
);
function slotNameFor(column: { __slot?: string }) {
return column.__slot;
}
/** Resolves a filter row's column back to its Xtc definition. */
function colForField(field?: string) {
if (!field) return undefined;
return cols.value.find((c) => (c.__fkey ?? c.key) === field);
}
/** Dropdown options for a filterSelect column, or undefined for other types. */
function selectItemsFor(field?: string) {
const col = colForField(field);
if (col?.filterType !== 'select' || !col.filterItems) return undefined;
return Object.entries(col.filterItems).map(([value, label]) => ({ value, label }));
}
function isDateField(field?: string) {
return colForField(field)?.filterType === 'date';
}
const paginationConfig = computed(() =>
props.model?._hidePagination
? false
: {
current: query.page,
pageSize: query.limit,
total: query.total,
showSizeChanger: true,
},
);
/** legacy requestData() */
function requestData(op?: string) {
const titles: Record<string, string> = {};
visibleCols.value.forEach((c) => {
titles[c.key] = c.title;
});
const select = visibleCols.value.map((c) => c.key);
const base = {
search: query.search,
filters: query.filters.filter((f) => f.field && f.op),
total: query.total,
limit: query.limit,
page: query.page,
order: order.value,
select,
titles,
report_title: props.model?.title,
};
return op ? { ...base, op } : base;
}
/** legacy fetch() + getDataUsage() in 'laravel' mode */
async function fetch(op?: string) {
if (!props.model) return;
try {
loading.value = true;
const { data: res } = await api.post(props.model.url, requestData(op));
data.value = res.rows;
query.total = res.query?.total ?? 0;
query.page = res.query?.page ?? query.page;
query.limit = res.query?.limit ?? query.limit;
emit('query', res.query);
} catch (error) {
console.log(error);
throw error;
} finally {
loading.value = false;
}
}
function onSearch(value: string) {
query.search = value;
query.page = 1;
fetch();
}
function toggleVisibleFilter() {
activeFilter.value = !activeFilter.value;
if (activeFilter.value && query.filters.length === 0) addFilterRow();
}
/** legacy addFilterRow(): id is a random float used only as a list key */
function addFilterRow() {
query.filters.push({ id: parseFloat(`${query.filters.length}.${Math.floor(Math.random() * 1000)}`) });
}
function removeFilter(index: number) {
query.filters.splice(index, 1);
}
function doFilter() {
activeFilter.value = false;
query.page = 1;
fetch();
}
function onTableChange(
pagination: { current?: number; pageSize?: number },
_filters: unknown,
sorter: { columnKey?: string; order?: string },
) {
query.page = pagination.current ?? 1;
query.limit = pagination.pageSize ?? query.limit;
if (sorter?.columnKey && sorter.order) {
sort.value = String(sorter.columnKey);
sortDirection.value = sorter.order === 'ascend' ? 'asc' : 'desc';
} else {
sort.value = undefined;
}
fetch();
}
onMounted(() => {
// legacy applied model.defaultFilters before the first fetch
for (const df of props.model?.defaultFilters ?? []) {
query.filters.push({ id: Math.random(), ...df });
}
fetch();
});
defineExpose({ fetch });
</script>
<style scoped>
.x-table__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.x-table__actions {
display: flex;
gap: 8px;
}
.x-table__filters {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.x-table__filter-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.x-table__filter-actions {
display: flex;
gap: 8px;
}
.refresh-btn {
transition: all 0.15s ease;
}
</style>
@@ -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<string | number, string>;
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<string | number, string>) {
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;
@@ -0,0 +1,89 @@
<template>
<!--
Replacement for legacy TheNavbarVertical.vue / TheNavbarHorizontal.vue.
Legacy carried a Vuexy/Vuesax toolbar with a theme customizer, search overlay,
notification dropdown, bookmarks bar and i18n switcher. Of those, only the
pieces with real backing survive: the sidebar toggle, the page title, and the
user dropdown with logout. The rest were template demo features.
-->
<a-layout-header class="the-navbar">
<div class="nav-left">
<a-button type="text" @click="$emit('toggle-sidebar')">
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</a-button>
<span class="page-title">{{ pageTitle }}</span>
</div>
<div class="nav-right">
<a-dropdown>
<span class="user-chip">
<a-avatar size="small" style="background-color: #1c2d56">
{{ initial }}
</a-avatar>
<span class="user-name">{{ displayName }}</span>
</span>
<template #overlay>
<a-menu>
<a-menu-item key="my-data" @click="router.push('/my-data')">
اطلاعات من
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" @click="onLogout">خروج</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue';
import { useAuthStore } from '../../stores/auth';
defineProps<{ collapsed?: boolean }>();
defineEmits<{ (e: 'toggle-sidebar'): void }>();
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const pageTitle = computed(() => (route.meta?.pageTitle as string) ?? '');
const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'کاربر');
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
function onLogout() {
auth.logout();
router.push('/auth/login');
}
</script>
<style scoped>
.the-navbar {
height: 61px; /* legacy ara/ant.less @layout-header-height */
background: #fff;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f0f0;
}
.nav-left,
.nav-right {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 16px;
color: #1c2d56;
}
.user-chip {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
</style>
@@ -0,0 +1,149 @@
<template>
<!--
Replacement for legacy henza_admin/src/layouts/components/vertical-nav-menu/
(VerticalNavMenu.vue + VerticalNavMenuGroup/Item/Header, ~700 lines of Vuesax-
specific markup). Vuesax has no Vue 3 build, so the tree is rendered with
ant-design-vue's a-menu instead.
What is preserved exactly: the menu DATA (navMenuItems.ts, byte-exact), the
permission filtering rule, the header/group/item nesting, and the Farsi labels.
What changes: the DOM/classes, because the underlying component library changed.
-->
<a-layout-sider
:collapsed="collapsed"
:trigger="null"
collapsible
width="260"
class="vertical-nav-menu"
theme="light"
>
<div class="nav-header">
<span v-if="!collapsed" class="nav-title">{{ title }}</span>
</div>
<a-menu
mode="inline"
theme="light"
:selected-keys="selectedKeys"
:open-keys="openKeys"
@update:openKeys="(k: string[]) => (openKeys = k)"
@click="onClick"
>
<template v-for="(node, i) in visibleMenu" :key="node.url || node.header || i">
<!-- header node: a section divider carrying `items` -->
<a-menu-item-group v-if="node.header" :title="node.i18n || node.header">
<template v-for="(child, j) in visibleChildren(node.items)" :key="child.url || j">
<a-sub-menu v-if="child.submenu" :key="subKey(child, j)">
<template #title>{{ child.i18n || child.name }}</template>
<a-menu-item
v-for="(leaf, k) in visibleChildren(child.submenu)"
:key="leaf.url || `${j}-${k}`"
>
{{ leaf.i18n || leaf.name }}
</a-menu-item>
</a-sub-menu>
<a-menu-item v-else :key="child.url || `${j}`">
{{ child.i18n || child.name }}
</a-menu-item>
</template>
</a-menu-item-group>
<!-- group node -->
<a-sub-menu v-else-if="node.submenu" :key="subKey(node, i)">
<template #title>{{ node.i18n || node.name }}</template>
<a-menu-item
v-for="(leaf, k) in visibleChildren(node.submenu)"
:key="leaf.url || `${i}-${k}`"
>
{{ leaf.i18n || leaf.name }}
</a-menu-item>
</a-sub-menu>
<!-- leaf node -->
<a-menu-item v-else :key="node.url || `${i}`">
{{ node.i18n || node.name }}
</a-menu-item>
</template>
</a-menu>
</a-layout-sider>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getMenu, type NavMenuNode } from '../navMenuItems';
import { useUsersStore } from '../../stores/users';
withDefaults(defineProps<{ title?: string; collapsed?: boolean }>(), {
title: 'هنزا گلد',
collapsed: false,
});
const route = useRoute();
const router = useRouter();
const users = useUsersStore();
/**
* Legacy's visibility rule, from the v-if on each nav item:
* a node with no permissionId is ALWAYS visible; otherwise it needs the rid.
*
* That "no permissionId = public" rule is why the banker-list and shopkeeper-list
* items (whose permissionId is commented out in the legacy data) show for everyone.
* Preserved deliberately — see docs/ADMIN-PORT-PLAN.md.
*/
function canSee(node: NavMenuNode): boolean {
if (!node.permissionId) return true;
return Boolean(users.hasPermission(node.permissionId));
}
function visibleChildren(nodes?: NavMenuNode[]): NavMenuNode[] {
return (nodes ?? []).filter(canSee);
}
/** Hides a group whose children are all hidden, as legacy did. */
function hasVisibleContent(node: NavMenuNode): boolean {
if (node.header) return visibleChildren(node.items).length > 0;
if (node.submenu) return visibleChildren(node.submenu).length > 0;
return true;
}
const visibleMenu = computed(() =>
getMenu().filter((n) => canSee(n) && hasVisibleContent(n)),
);
const subKey = (node: NavMenuNode, i: number) => `sub-${node.name ?? ''}-${i}`;
const selectedKeys = ref<string[]>([route.path]);
const openKeys = ref<string[]>([]);
watch(
() => route.path,
(p) => {
selectedKeys.value = [p];
},
);
function onClick({ key }: { key: string | number }) {
const path = String(key);
if (path.startsWith('/') && path !== route.path) {
router.push(path);
}
}
</script>
<style scoped>
.vertical-nav-menu {
min-height: 100vh;
border-left: 1px solid #f0f0f0;
}
.nav-header {
height: 61px; /* legacy ara/ant.less @layout-header-height */
display: flex;
align-items: center;
justify-content: center;
}
.nav-title {
font-size: 18px;
color: #1c2d56;
}
</style>
@@ -0,0 +1,6 @@
<template>
<!-- Ported verbatim from legacy henza_admin/src/layouts/full-page/FullPage.vue -->
<div class="layout--full-page">
<router-view></router-view>
</div>
</template>
@@ -0,0 +1,93 @@
<template>
<!--
Ported from legacy henza_admin/src/layouts/main/Main.vue (281 lines).
Structure preserved: nav menu + navbar + breadcrumb/pageTitle header +
routed content. Dropped as template-only or Vue-2-only: VxTour, TheCustomizer,
vue-backtotop, and the Vuesax transition wrappers.
-->
<a-layout class="layout--main" :class="{ 'no-scroll': isAppPage }">
<VerticalNavMenu :collapsed="collapsed" />
<a-layout>
<TheNavbar :collapsed="collapsed" @toggle-sidebar="collapsed = !collapsed" />
<a-layout-content id="content-area">
<div class="content-wrapper">
<!-- legacy router-header: shown only when the route declares one -->
<div v-if="breadcrumb || pageTitle" class="router-header">
<h2 class="content-area__heading">{{ routeTitle }}</h2>
<a-breadcrumb v-if="breadcrumb" class="breadcrumb">
<a-breadcrumb-item v-for="(crumb, i) in breadcrumb" :key="i">
<router-link v-if="crumb.url && !crumb.active" :to="crumb.url">
{{ crumb.title }}
</router-link>
<span v-else>{{ crumb.title }}</span>
</a-breadcrumb-item>
</a-breadcrumb>
</div>
<div class="content-area__content">
<router-view @changeRouteTitle="changeRouteTitle" />
</div>
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import VerticalNavMenu from '../components/VerticalNavMenu.vue';
import TheNavbar from '../components/TheNavbar.vue';
interface Crumb {
title: string;
url?: string;
active?: boolean;
}
const route = useRoute();
const collapsed = ref(false);
const pageTitle = computed(() => route.meta?.pageTitle as string | undefined);
const breadcrumb = computed(() => route.meta?.breadcrumb as Crumb[] | undefined);
// legacy meta.no_scroll -> the 'no-scroll' body class
const isAppPage = computed(() => Boolean(route.meta?.no_scroll));
// legacy: views could override the heading via @changeRouteTitle
const overriddenTitle = ref<string | undefined>(undefined);
const routeTitle = computed(() => overriddenTitle.value ?? pageTitle.value ?? '');
function changeRouteTitle(title: string) {
overriddenTitle.value = title;
}
watch(
() => route.path,
() => {
overriddenTitle.value = undefined;
},
);
</script>
<style scoped>
.layout--main {
min-height: 100vh;
}
#content-area {
padding: 16px;
background: #f8f8f8;
}
.router-header {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
}
.content-area__heading {
margin: 0;
color: #1c2d56;
}
</style>
@@ -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;
-1
View File
@@ -1 +0,0 @@
export {};
-5
View File
@@ -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');
+17 -3
View File
@@ -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');
File diff suppressed because it is too large Load Diff
+182
View File
@@ -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'
}
},
]
-1
View File
@@ -1 +0,0 @@
export declare const router: import("vue-router").Router;
-12
View File
@@ -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 },
],
});
+123 -8
View File
@@ -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<string[] | undefined> {
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;
+95
View File
@@ -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();
},
},
});
+125
View File
@@ -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);
}
},
},
});
@@ -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<string, unknown>;
}
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 = [];
}
},
},
});
+37
View File
@@ -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;
},
},
});
-9
View File
@@ -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;
}>;
-15
View File
@@ -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;
},
},
});
-16
View File
@@ -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;
},
},
});
+114
View File
@@ -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<string | number, string>,
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;
},
},
});
+43
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
// moment-jalaali ships no type declarations of its own.
declare module 'moment-jalaali' {
import moment from 'moment';
export default moment;
}
+72
View File
@@ -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<T>(val: T) {
return val;
}
+19
View File
@@ -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';
@@ -0,0 +1,64 @@
<template>
<!--
Ported from legacy henza_admin/src/views/DashboardAnalytics.vue — the retail
dashboard ("آمار تک فروشی").
Legacy's file is 342 lines but reads only ONE field from the server:
`model.lastOrder`. Everything else on the page is Vuexy demo data defined in
the component itself (analyticsData.js series, radial bars, order-type
colours) and renders nothing real — so only the live table is ported.
-->
<a-spin :spinning="loading">
<a-card title="سفارشات هفته جاری">
<a-table
size="small"
:data-source="model.lastOrder"
:columns="columns"
:pagination="{ pageSize: 15 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'sts_str'">
<a-tag :color="tagColor(record.statusColor)">{{ record.sts_str }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">
{{ jdate(record.caj) }}
</template>
</template>
</a-table>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { jdate } from '../utils/filters';
import api from '../api/client';
const loading = ref(false);
const model = reactive<Record<string, any>>({ lastOrder: [] });
const columns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'وضعیت', dataIndex: 'sts_str' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
function tagColor(c: string) {
return { success: 'green', warning: 'orange', danger: 'red', primary: 'blue' }[c] ?? '';
}
onMounted(async () => {
try {
loading.value = true;
const { data } = await api.get('get-analytics-tak');
Object.assign(model, data);
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
</script>
@@ -0,0 +1,142 @@
<template>
<!--
Ported from legacy henza_admin/src/views/DashboardECommerce.vue — the
wholesale dashboard ("آمار عمده فروشی").
Legacy's file is a Vuexy template whose English demo cards are all COMMENTED
OUT (Congratulations, Support Tracker, Product Orders, Sales Stats, Activity
Timeline — verified line by line). The live content is the three 30-day stat
cards plus three Farsi tables, and that is what is ported.
-->
<a-spin :spinning="loading">
<div class="grid-3">
<StatisticsCardLine
icon="UsersIcon"
color="warning"
:statistic="`${omdeData.customerCount} نفر `"
statistic-title="تعداد مشتریان ثبت نامی در 30 روز گذشته"
/>
<StatisticsCardLine
icon="ShoppingBagIcon"
:statistic="`${omdeData.allgram} گرم `"
statistic-title="طلای فروش رفته در 30 روز گذشته"
/>
<StatisticsCardLine
icon="ShoppingBagIcon"
color="warning"
:statistic="`${omdeData.ordercount} سفارش `"
statistic-title="تعداد سفارشات 30 روز گذشته"
/>
</div>
<div class="grid-2 mt-4">
<a-card title="عملکرد کارشناسان فروش">
<a-table
size="small"
:data-source="omdeData.reagent"
:columns="agentColumns"
:pagination="false"
row-key="id"
/>
</a-card>
<a-card title="میزان فروش">
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="تعداد سفارش (۳۰ روز)">
{{ omdeData.sumLastOrder }}
</a-descriptions-item>
<a-descriptions-item label="مجموع وزن (۳۰ روز)">
{{ omdeData.sumLastOrderMonth }} گرم
</a-descriptions-item>
</a-descriptions>
</a-card>
</div>
<a-card title="سفارشات هفته جاری" class="mt-4">
<a-table
size="small"
:data-source="omdeData.lastOrder"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'sts_str'">
<a-tag :color="tagColor(record.statusColor)">{{ record.sts_str }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">
{{ jdate(record.caj) }}
</template>
</template>
</a-table>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import StatisticsCardLine from '../components/statistics-cards/StatisticsCardLine.vue';
import { jdate } from '../utils/filters';
import api from '../api/client';
const loading = ref(false);
const omdeData = reactive<Record<string, any>>({
customerCount: 0,
allgram: 0,
ordercount: 0,
reagent: [],
lastOrder: [],
sumLastOrder: 0,
sumLastOrderMonth: 0,
});
const agentColumns = [
{ title: 'کارشناس فروش', dataIndex: 'name' },
{ title: 'میزان فروش (گرم)', dataIndex: 'grams_sell' },
{ title: 'تعداد سفارش', dataIndex: 'order_count' },
];
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'وضعیت', dataIndex: 'sts_str' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
function tagColor(c: string) {
return { success: 'green', warning: 'orange', danger: 'red', primary: 'blue' }[c] ?? '';
}
onMounted(async () => {
try {
loading.value = true;
const { data } = await api.get('get-analytics');
Object.assign(omdeData, data);
// legacy sorts both lists client-side after the fetch
omdeData.reagent.sort((a: any, b: any) => Number(b.grams_sell) - Number(a.grams_sell));
omdeData.lastOrder.sort((a: any, b: any) => Number(b.w) - Number(a.w));
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -1,73 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api, type CustomerSummary } from '../api/client';
import { useSessionStore } from '../stores/session';
const customers = ref<CustomerSummary[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const router = useRouter();
const session = useSessionStore();
onMounted(async () => {
try {
customers.value = await api.listCustomers();
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
});
function goToNewOrder(customerId: string) {
session.selectCustomer(customerId);
router.push({ name: 'order-new' });
}
</script>
<template>
<section>
<h1>Customers</h1>
<p v-if="loading">Loading…</p>
<p v-else-if="error" class="error">{{ error }}</p>
<table v-else>
<thead>
<tr>
<th>Name</th>
<th>Mobile</th>
<th>Gold debt (g)</th>
<th>Rial debt</th>
<th>Credit limit (g)</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in customers" :key="c.id">
<td>{{ c.fullName }}</td>
<td>{{ c.mobile }}</td>
<td>{{ c.goldDebtGrams }}</td>
<td>{{ c.fiatDebtRial }}</td>
<td>{{ c.goldCreditLimitGrams }}</td>
<td><button @click="goToNewOrder(c.id)">New order</button></td>
</tr>
</tbody>
</table>
</section>
</template>
<style scoped>
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid #ddd;
}
.error {
color: #b00020;
}
</style>
-2
View File
@@ -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;
@@ -1,97 +0,0 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
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 */
@@ -1,63 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api, type OrderDetail } from '../api/client';
const route = useRoute();
const order = ref<OrderDetail | null>(null);
const error = ref<string | null>(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 as string);
}
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 as Error).message;
}
}
onMounted(load);
</script>
<template>
<section v-if="order">
<h1>Order {{ order.orderNumber }}</h1>
<p>Status: <strong>{{ order.status }}</strong></p>
<p v-if="order.isSuspendedOverCreditLimit" class="error">Suspended — credit limit exceeded at placement.</p>
<p>Total gold weight: {{ order.totalGoldWeightGrams }} g (wage: {{ order.wageWeightGrams }} g)</p>
<p>Flat Rial charge: {{ order.flatRialCharge }}</p>
<p>Delivered at: {{ order.deliveredAt ?? '—' }}</p>
<button v-if="STATUS_FLOW.indexOf(order.status) < STATUS_FLOW.length - 1" @click="advance">
Advance to next status
</button>
<p v-if="error" class="error">{{ error }}</p>
<h2>Status history</h2>
<ul>
<li v-for="(e, i) in order.statusEvents" :key="i">{{ e.toStatus }} — {{ e.createdAt }}</li>
</ul>
</section>
</template>
<style scoped>
.error {
color: #b00020;
}
</style>
-2
View File
@@ -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;
@@ -1,98 +0,0 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
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 */
-112
View File
@@ -1,112 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { CreateOrderItemDto } from '@gold-bank/shared-types';
import { api, type ProductSummary } from '../api/client';
import { useSessionStore } from '../stores/session';
const session = useSessionStore();
const router = useRouter();
const products = ref<ProductSummary[]>([]);
const items = reactive<CreateOrderItemDto[]>([
{ productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 },
]);
const submitting = ref(false);
const error = ref<string | null>(null);
onMounted(async () => {
products.value = await api.listProducts();
});
function addItem() {
items.push({ productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 });
}
function removeItem(index: number) {
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 as Error).message;
} finally {
submitting.value = false;
}
}
</script>
<template>
<section>
<h1>New order</h1>
<p v-if="!session.selectedCustomerId" class="error">
No customer selected — go back to the dashboard and pick one.
</p>
<p v-else>Customer: {{ session.selectedCustomerId }}</p>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Qty</th>
<th>Wage %</th>
<th>Discount %</th>
<th>Flat Rial</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in items" :key="idx">
<td>
<select v-model="item.productVariantId">
<option value="" disabled>Select…</option>
<template v-for="p in products" :key="p.id">
<option v-for="v in p.variants" :key="v.id" :value="v.id">
{{ p.title }} — {{ v.title }} ({{ v.rashWeightGrams }}g)
</option>
</template>
</select>
</td>
<td><input v-model.number="item.quantity" type="number" min="1" /></td>
<td><input v-model.number="item.wagePercent" type="number" step="0.1" /></td>
<td><input v-model.number="item.discountPercent" type="number" step="0.1" /></td>
<td><input v-model.number="item.flatRialCharge" type="number" step="1000" /></td>
<td><button @click="removeItem(idx)">Remove</button></td>
</tr>
</tbody>
</table>
<button @click="addItem">Add item</button>
<br /><br />
<button :disabled="submitting" @click="submit">Place order</button>
<p v-if="error" class="error">{{ error }}</p>
</section>
</template>
<style scoped>
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
th,
td {
text-align: left;
padding: 0.4rem;
}
.error {
color: #b00020;
}
</style>
-2
View File
@@ -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;
@@ -1,163 +0,0 @@
/// <reference types="../../node_modules/.vue-global-types/vue_3.5_0_0_0.d.ts" />
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 */
@@ -0,0 +1,185 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/category/Add.vue.
Serves BOTH create and edit — Edit.vue is only a wrapper around it.
Legacy's distinctive touch, preserved: once "نوع نمایش" is chosen every
subsequent label is suffixed with (سایت) or (موبایل), so the operator can see
which storefront they are editing.
The two image uploaders (تصویر شاخص / آیکون) are real file pickers, posting
to the ported `file/create` endpoint and storing the returned URL — the same
contract legacy's SingleUploadInput.vue used.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="`دسته بندی ${typeSuffix}`" class="mb-base">
<a-form-item
label="نوع نمایش"
:validate-status="err.type ? 'error' : ''"
:help="err.type"
>
<a-radio-group v-model:value="form.type">
<a-radio :value="1">سایت</a-radio>
<a-radio :value="2">اپلیکشن موبایل</a-radio>
</a-radio-group>
</a-form-item>
<div class="grid-2">
<a-form-item
:label="`عنوان ${typeSuffix}`"
:validate-status="err.title ? 'error' : ''"
:help="err.title"
>
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item :label="`پدر دسته ${typeSuffix}`">
<a-select
v-model:value="form.parent_id_id"
allow-clear
show-search
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="c in parentOptions" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="`تصویر شاخص ${typeSuffix}`">
<ImageUploadInput v-model="form.image" purpose="category.image" placeholder="تصویر" />
</a-form-item>
<a-form-item :label="`آیکون ${typeSuffix}`">
<ImageUploadInput v-model="form.icon" purpose="category.icon" placeholder="آیکون" />
</a-form-item>
<a-form-item :label="`متا تگ ${typeSuffix}`">
<a-input v-model:value="form.meta" />
</a-form-item>
</div>
<a-form-item :label="`شرح دسته ${typeSuffix}`">
<a-textarea v-model:value="form.body_html" :auto-size="{ minRows: 3, maxRows: 8 }" />
</a-form-item>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../../api/client';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const allCategories = ref<{ id: string; title: string; type: number }[]>([]);
const form = reactive<Record<string, any>>({
type: 1,
title: '',
parent_id_id: undefined,
image: '',
icon: '',
meta: '',
body_html: '',
});
const err = reactive<Record<string, string>>({});
/** legacy: `${type==1?'(سایت)':type==2?'(موبایل)':''}` */
const typeSuffix = computed(() =>
form.type === 1 ? '(سایت)' : form.type === 2 ? '(موبایل)' : '',
);
/**
* Only categories of the same storefront can be a parent, and a category can
* never be offered as its own parent.
*/
const parentOptions = computed(() =>
allCategories.value.filter((c) => c.type === form.type && c.id !== id),
);
// Switching storefront invalidates a parent picked from the other one.
watch(
() => form.type,
() => {
if (form.parent_id_id && !parentOptions.value.some((c) => c.id === form.parent_id_id)) {
form.parent_id_id = undefined;
}
},
);
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.type) err.type = 'لطفا نوع نمایش دسته بندی را انتخاب کنید';
if (!form.title) err.title = 'لطفا مقدار را وارد کنید';
return Object.keys(err).length === 0;
}
async function handleSubmit() {
if (!validate()) return;
try {
loading.value = true;
if (id) {
await api.put(`category/${id}`, { ...form, id });
} else {
await api.post('category/', form);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/category/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('admin/categories/tree');
allCategories.value = data;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`admin/categories/${id}`);
Object.assign(form, {
type: data.type ?? 1,
title: data.title ?? '',
parent_id_id: data.parent_id_id ?? undefined,
image: data.image ?? '',
icon: data.icon ?? '',
meta: data.meta ?? '',
body_html: data.body_html ?? '',
});
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
</style>
@@ -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;
@@ -0,0 +1,12 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/category/Edit.vue.
Legacy's entire file is <Add/> — a 12-line wrapper, and it stays one here.
Add.vue reads the :id route param to switch between create and edit.
-->
<Add />
</template>
<script setup lang="ts">
import Add from './Add.vue';
</script>
@@ -0,0 +1,73 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/category/List.vue.
Farsi copy and permission gates preserved. The alternate commented-out `opt`
slot in legacy stays absent.
-->
<div id="page-user-list">
<a-card title="لیست دسته بندی ها">
<XTable ref="tbl" :model="model">
<template #image="{ text }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="text && text.image ? text.image : noImage"
/>
</template>
<template #opt="{ record }">
<a-tooltip v-if="hasPermission('collection.list.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('collection.list.remove')" title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
>
<template #title><p>آیا مطمئن به حذف دسته مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</template>
</XTable>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildCategoryModel } from './CategoryTbl';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const noImage = '/img/no-items.png';
const model = buildCategoryModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
async function confirmDelete(id: string) {
try {
await api.delete(`category/${id}`);
message.info('دسته حذف شد');
tbl.value?.fetch();
} catch (e: any) {
// The backend refuses while products or child categories still reference it.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف دسته', okText: 'بستن' });
}
}
function toEdit(id: string) {
router.push(`/category/edit/${id}`);
}
</script>
@@ -0,0 +1,30 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/AddCustomer.vue.
Legacy's file is 278 lines but almost all of it is COMMENTED OUT: the live
markup is a single tab wrapping <bulk-customer/>. The "مشتری تک" tab and the
whole inline form beneath it are commented out in the original and stay
absent here rather than being revived.
-->
<div id="page-user-view">
<div id="user-data">
<a-card class="mb-base">
<a-tabs v-if="hasPermission('persons.create')">
<a-tab-pane v-if="hasPermission('persons.create.client')" key="omde" tab="مشتری عمده">
<BulkCustomer />
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</div>
</template>
<script setup lang="ts">
import BulkCustomer from './bulkCustomer.vue';
import { useUsersStore } from '../../../stores/users';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
</script>
@@ -0,0 +1,12 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/EditBulkCustomer.vue —
a 19-line wrapper whose entire template is <Customer /> (bulkCustomer).
bulkCustomer reads the :id route param to switch between create and edit.
-->
<BulkCustomer />
</template>
<script setup lang="ts">
import BulkCustomer from './bulkCustomer.vue';
</script>
@@ -0,0 +1,11 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/EditReagent.vue — a
19-line wrapper over <ReagentCustomer />, which reads the :id route param.
-->
<ReagentCustomer />
</template>
<script setup lang="ts">
import ReagentCustomer from './ReagentCustomer.vue';
</script>
@@ -0,0 +1,12 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/EditSingleCustomer.vue
— a 16-line wrapper over <Customer /> (singleCustomer), which reads the :id
route param to switch between create and edit.
-->
<SingleCustomerForm />
</template>
<script setup lang="ts">
import SingleCustomerForm from './singleCustomer.vue';
</script>
@@ -0,0 +1,278 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/ReagentCustomer.vue —
the sales-expert ("کارشناس فروش") form. Structurally the wholesale customer
form plus a نوع کاربری selector and a commission percentage.
The avatar uploader (عکس پروفایل) is live: legacy's MyUpload posted to
`file/create` and kept the returned `id` in `image_id`, reading it back as a
nested `image: { id, image }`. Both halves of that contract are preserved.
LEGACY BUG, not reproduced: legacy's load path dereferenced `data.image.id`
unguarded (ReagentCustomer.vue:280), so opening an agent with no avatar threw
— swallowed by its own try/catch, leaving the form half-filled. The read is
guarded here.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-4">
<a-form-item label="عکس پروفایل" class="avatar-cell">
<ImageUploadInput
v-model="form.image_url"
purpose="customer.avatar"
placeholder="عکس"
@ok="onAvatar"
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
/>
</a-form-item>
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="نوع کاربری">
<a-select v-model:value="form.type">
<a-select-option v-for="t in userTypes" :key="t.id" :value="t.id">
{{ t.title }}
</a-select-option>
</a-select>
</a-form-item>
</div>
</a-card>
<a-card title="اطلاعات تجاری" class="mb-base">
<div class="grid-4">
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="تلفن">
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="درصد روی محصول">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</a-form-item>
<a-form-item label="سقف اعتبار">
<a-input-number v-model:value="form.gram_level" class="w-full" />
</a-form-item>
</div>
</a-card>
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-2">
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="onCityChange"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</div>
</a-card>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../../api/client';
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const cities = ref<{ id: string; title: string }[]>([]);
const userTypes = ref<{ id: number; title: string }[]>([]);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
ncode: '',
password: '',
mobile: '',
type: undefined,
mcode: '',
store_name: '',
phone: '',
pasazh_name: '',
city_id: undefined,
address: '',
percent: undefined,
gram_level: undefined,
// legacy kept the FK in `image_id`; `image_url` only drives the thumbnail.
image_id: '',
image_url: null,
});
const err = reactive<Record<string, string>>({});
/** Snapshot of the server's record, captured after the edit form is populated. */
const initial = reactive<Record<string, any>>({});
/** The subset of `form` that differs from `initial`, minus `omit` keys. */
function diffFromInitial(omit: string[] = []): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(form)) {
if (omit.includes(k)) continue;
if (JSON.stringify(v) !== JSON.stringify(initial[k])) out[k] = v;
}
return out;
}
/** Legacy `uploadIndexImage` — MyUpload emitted the whole response object. */
function onAvatar(file: Record<string, unknown>) {
form.image_id = (file.id as string) ?? '';
}
const REQUIRED = 'لطفا مقدار را وارد کنید';
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
/**
* legacy: picking a city pre-fills the agent's commission from that city's
* percent_aux (the sales-expert rate, as opposed to the wholesale rate).
*/
async function onCityChange(cityId: string) {
if (!cityId) return;
try {
const { data } = await api.get(`cities/get-percent-aux/${cityId}`);
if (data !== null && data !== undefined) form.percent = Number(data);
} catch (e) {
console.log(e);
}
}
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.fname) err.fname = REQUIRED;
if (!form.lname) err.lname = REQUIRED;
if (!form.mobile) err.mobile = REQUIRED;
if (Object.keys(err).length) return;
try {
loading.value = true;
// `image_url` is presentation only — the server stores the FK (`image_id`).
const { image_url: _unusedAvatarUrl, ...allFields } = form;
// On EDIT send only touched fields — see bulkCustomer.vue for why.
const fields = id ? diffFromInitial(['image_url']) : allFields;
const payload = {
...fields,
id: id ?? null,
name: `${form.fname} ${form.lname}`.trim(),
national_code: form.ncode,
};
if (id) {
await api.put(`customer/${id}`, payload);
} else {
await api.post('customer/create', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.go(-1);
} catch (e: any) {
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const [c, t] = await Promise.all([api.post('cities/list'), api.get('const/__list__ut')]);
cities.value = c.data.rows ?? [];
userTypes.value = t.data ?? [];
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
Object.assign(form, {
fname: data.fname ?? '',
lname: data.lname ?? '',
ncode: data.national_code ?? '',
mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined,
address: data.address ?? '',
percent: data.percent === null ? undefined : Number(data.percent),
gram_level: data.gram_level === null ? undefined : Number(data.gram_level),
// guarded, unlike legacy — an agent with no avatar must not throw
image_id: data.image?.id ?? '',
image_url: data.image?.image ?? null,
// Previously omitted — same silent-overwrite bug as bulkCustomer.vue:
// the form spread these back as '' and wiped good data on every save.
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
phone: data.phone ?? '',
type: data.type ?? undefined,
});
// Baseline for the dirty-diff in handleSubmit.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
/* Legacy put the avatar in the first grid cell spanning two rows
(md:row-span-2), with the uploader centred inside it. */
.avatar-cell {
grid-row: span 2;
display: flex;
justify-content: center;
}
</style>
@@ -0,0 +1,280 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/bulkCustomer.vue.
This is the real wholesale-customer form; AddCustomer.vue is only a tab
wrapper around it (the rest of AddCustomer's markup is commented out in
legacy). Field names, Farsi labels and validation messages are preserved.
Dropped: the MyUpload avatar widget and the Jalali DateInput, which belong to
fields the redesigned Customer schema has no column for. Flagged in
docs/ADMIN-PORT-PLAN.md rather than faked.
The نوع کاربری selector is live again now that Customer.type exists; its four
options are legacy's own list, byte for byte.
-->
<a-form layout="vertical" :model="form" @submit.prevent="handleSubmitOmde">
<a-spin :spinning="loading">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-4">
<a-form-item label="نام" :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item label="نام خانوادگی" :validate-status="err.lname ? 'error' : ''" :help="err.lname">
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item label="کد ملی">
<a-input v-model:value="form.ncode" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.mobile ? 'error' : ''"
:help="err.mobile"
>
<a-input v-model:value="form.mobile" />
</a-form-item>
<a-form-item label="رمز عبور">
<a-input-password v-model:value="form.password" />
</a-form-item>
<a-form-item label="تلفن ثابت">
<a-input v-model:value="form.phone" />
</a-form-item>
</div>
</a-card>
<a-card title="اطلاعات تجاری" class="mb-base">
<div class="grid-4">
<a-form-item label="نوع کاربری">
<a-select
v-model:value="form.type"
show-search
:filter-option="filterUserType"
style="width: 100%; direction: rtl !important"
>
<a-select-option v-for="item in userTypes" :key="item.id" :value="item.id">
{{ item.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="نام مغازه">
<a-input v-model:value="form.store_name" />
</a-form-item>
<a-form-item label="نام پاساژ">
<a-input v-model:value="form.pasazh_name" />
</a-form-item>
<a-form-item label="کد مشتری">
<a-input v-model:value="form.mcode" />
</a-form-item>
<a-form-item label="کارشناس فروش">
<a-select v-model:value="form.reagent_id" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id">
{{ r.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="درصد">
<a-input-number v-model:value="form.percent" class="w-full" />
</a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)">
<a-input-number v-model:value="form.gram_level" class="w-full" />
</a-form-item>
</div>
</a-card>
<a-card title="اطلاعات تکمیلی" class="mb-base">
<div class="grid-2">
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterCity"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
</div>
</a-card>
<div>
<a-button type="primary" class="mr-4" @click="handleSubmitOmde">ثبت اطلاعات</a-button>
</div>
</a-spin>
</a-form>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../../api/client';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const cities = ref<{ id: string; title: string }[]>([]);
const reagent = ref<{ id: string; name: string }[]>([]);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
ncode: '',
password: '',
mobile: '',
store_name: '',
phone: '',
pasazh_name: '',
city_id: undefined,
mcode: '',
address: '',
// legacy defaulted to no selection; 2 (مغازه دار اپلیکیشن) is what the
// backend falls back to when the field is absent.
type: undefined,
reagent_id: undefined,
percent: undefined,
gram_level: undefined,
});
/**
* Legacy bulkCustomer.vue:239-244, verbatim. The server's canonical map
* (Customer::$TYPES) also has 5 => سرپرست کارگاه, which this form never offered;
* that omission is preserved.
*/
const userTypes = [
{ id: 1, title: 'کارشناس فروش' },
{ id: 2, title: 'مغازه دار اپلیکیشن' },
{ id: 3, title: 'مغازه دار حضوری' },
{ id: 4, title: 'بنکدار' },
];
function filterUserType(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
const err = reactive<Record<string, string>>({});
/**
* Snapshot of the record as the server returned it, captured after the edit
* form is populated. Empty on create, where every field is intentionally new.
*/
const initial = reactive<Record<string, any>>({});
/** The subset of `form` that differs from `initial`. */
function diffFromInitial(): Record<string, any> {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(form)) {
if (JSON.stringify(v) !== JSON.stringify(initial[k])) out[k] = v;
}
return out;
}
function filterCity(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
// legacy required: fname, lname, mobile — message 'لطفا مقدار را وارد کنید'
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید';
return Object.keys(err).length === 0;
}
async function handleSubmitOmde() {
if (!validate()) return;
try {
loading.value = true;
// Legacy composed fname+lname separately; the schema has one fullName, so
// they are joined here and split back on read.
// On EDIT send only the fields the operator actually touched. Spreading the
// whole form means any field the edit path failed to repopulate is sent as
// '' and overwrites good data — see the note on Object.assign below.
const changed = id ? diffFromInitial() : { ...form };
const payload = {
...changed,
id: id ?? null,
name: `${form.fname} ${form.lname}`.trim(),
national_code: form.ncode,
};
if (id) {
await api.put(`customer/${id}`, payload);
} else {
await api.post('customer/create', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.go(-1);
} catch (e: any) {
// legacy surfaced the server's mobile-uniqueness error specifically
const mobileErr = e?.response?.data?.errors?.mobile?.[0];
message.error(mobileErr ?? e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('query/reagent');
reagent.value = data;
} catch (e) {
console.log(e);
}
try {
const { data } = await api.post('cities/list');
cities.value = data.rows;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
Object.assign(form, {
fname: data.fname ?? '',
lname: data.lname ?? '',
ncode: data.national_code ?? '',
mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined,
address: data.address ?? '',
percent: data.percent ?? undefined,
gram_level: data.gram_level ?? undefined,
reagent_id: data.reagent?.id ?? undefined,
// These five were previously OMITTED here. Because handleSubmitOmde
// spread the whole form and the server applies any key that is not
// `undefined`, saving an existing customer overwrote each of them with
// the empty string the reactive initializer left behind — silent data
// loss on every wholesale-customer edit.
store_name: data.store_name ?? '',
pasazh_name: data.pasazh_name ?? '',
mcode: data.mcode ?? '',
phone: data.phone ?? '',
type: data.type ?? undefined,
});
// Baseline for the dirty-diff in handleSubmitOmde.
Object.assign(initial, JSON.parse(JSON.stringify(form)));
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,137 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/customer/singleCustomer.vue —
the retail ("تک") customer form. Used directly in admin mode and wrapped by
branch/addCustomer.vue in branch mode.
Legacy posts to `single` / `single/{id}`; the mapping layer points both at
the customer endpoints.
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="id ? 'ویرایش مشتری' : 'افزودن مشتری'" class="mb-base">
<div class="grid-3">
<a-form-item label="نام " :validate-status="err.fname ? 'error' : ''" :help="err.fname">
<a-input v-model:value="form.fname" />
</a-form-item>
<a-form-item
label="نام خانوادگی"
:validate-status="err.lname ? 'error' : ''"
:help="err.lname"
>
<a-input v-model:value="form.lname" />
</a-form-item>
<a-form-item
label="شماره تلفن همراه"
:validate-status="err.phone ? 'error' : ''"
:help="err.phone"
>
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="شهر">
<a-select
v-model:value="form.city_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../../api/client';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const cities = ref<{ id: string; title: string }[]>([]);
const form = reactive<Record<string, any>>({
fname: '',
lname: '',
phone: '',
city_id: undefined,
});
const err = reactive<Record<string, string>>({});
const REQUIRED = 'لطفا مقدار را وارد کنید';
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.fname) err.fname = REQUIRED;
if (!form.lname) err.lname = REQUIRED;
if (!form.phone) err.phone = REQUIRED;
if (Object.keys(err).length) return;
try {
loading.value = true;
const payload = {
...form,
// one fullName in the schema; the two inputs are joined on submit
name: `${form.fname} ${form.lname}`.trim(),
mobile: form.phone,
};
if (id) {
await api.put(`single/${id}`, payload);
} else {
await api.post('single', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.go(-1);
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.post('cities/list');
cities.value = data.rows ?? [];
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
const parts = String(data.name ?? '').trim().split(' ');
Object.assign(form, {
fname: data.fname || parts[0] || '',
lname: data.lname || parts.slice(1).join(' '),
phone: data.mobile ?? '',
city_id: data.city_id ?? undefined,
});
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
</style>
@@ -0,0 +1,373 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/product/AddProduct.vue (1,044 lines).
Serves BOTH create and edit — EditProduct.vue is only a wrapper around this
component in legacy, and the same is true here.
Preserved: the Farsi labels, the show_type map, the inline variant table with
its add/edit modal, and the permission gates on the variant actions.
Fields with no column in the redesigned schema are NOT rendered rather than
collected and silently dropped — listed in docs/ADMIN-PORT-PLAN.md:
delivery_type, message_long_delivery, offer_day, has_warranty, is_exist,
tags, the two image uploaders, and the variant colour/rial-wage fields.
-->
<div id="page-user-view">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-spin :spinning="loading">
<a-card title="اطلاعات پایه" class="mb-base">
<div class="grid-3">
<a-form-item label="کد محصول">
<a-input v-model:value="form.product_code" />
</a-form-item>
<a-form-item
label="نام محصول"
:validate-status="err.title ? 'error' : ''"
:help="err.title"
>
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item label="نوع نمایش">
<a-select v-model:value="form.show_type">
<a-select-option :value="1">سایت</a-select-option>
<a-select-option :value="2">گوشی</a-select-option>
<a-select-option :value="3">سایت و گوشی</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="دسته بندی">
<a-select v-model:value="form.category_id" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="c in categories" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="برند">
<a-select v-model:value="form.brand_id" allow-clear placeholder="انتخاب کنید">
<a-select-option v-for="b in brands" :key="b.id" :value="b.id">
{{ b.title }}
</a-select-option>
</a-select>
</a-form-item>
<!-- legacy "سینی ها" — the Collection grouping -->
<a-form-item label="سینی ها">
<a-select
v-model:value="form.ciny_ids"
mode="multiple"
allow-clear
placeholder="انتخاب کنید"
>
<a-select-option v-for="c in collections" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<div class="grid-1">
<a-form-item label="شرح محصول (سایت)">
<a-textarea v-model:value="form.body_html" :auto-size="{ minRows: 4, maxRows: 10 }" />
</a-form-item>
</div>
<div class="grid-3">
<a-form-item label="نمایش محصول">
<a-switch v-model:checked="form.is_active" />
</a-form-item>
</div>
</a-card>
<!-- تنوع ها -->
<a-card v-if="hasPermission('product.list.variants')" class="mb-8 overflow-auto">
<template #title>
<div class="flex">
<div class="flex-1">تنوع ها</div>
<a-button
v-if="hasPermission('product.list.variants.add')"
type="primary"
@click="showModal()"
>
افزودن تنوع
</a-button>
</div>
</template>
<a-table
:columns="variantColumns"
:data-source="variants"
:pagination="false"
size="small"
row-key="__key"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'op'">
<a-tooltip v-if="hasPermission('product.list.variants.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="showModal(record, index)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('product.list.variants.remove')" title="حذف">
<DeleteOutlined class="cursor-pointer mx-1 text-danger" @click="variants.splice(index, 1)" />
</a-tooltip>
</template>
<template v-else-if="column.dataIndex === 'is_active'">
<a-tag :color="record.is_active ? 'green' : 'red'">
{{ record.is_active ? 'فعال' : 'غیر فعال' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'is_pre_order'">
<a-tag :color="record.is_pre_order ? 'blue' : ''">
{{ record.is_pre_order ? 'بله' : 'خیر' }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
<div>
<a-button type="primary" class="mr-4" @click="handleSubmit">ثبت اطلاعات</a-button>
</div>
</a-spin>
</a-form>
<!-- variant add/edit -->
<a-modal v-model:open="variantModal" :title="editingIndex === null ? 'افزودن تنوع' : 'ویرایش تنوع'">
<a-form layout="vertical">
<a-form-item label="عنوان">
<a-input v-model:value="variantForm.title" />
</a-form-item>
<a-form-item label="وزن">
<a-input-number v-model:value="variantForm.grams" class="w-full" :step="0.001" />
</a-form-item>
<a-form-item label="اجرت درصدی">
<a-input-number v-model:value="variantForm.percentage_pay" class="w-full" :step="0.1" />
</a-form-item>
<a-form-item label="تعداد">
<a-input-number v-model:value="variantForm.qty" class="w-full" />
</a-form-item>
<a-form-item label="فعال">
<a-switch v-model:checked="variantForm.is_active" />
</a-form-item>
<a-form-item label=" پیش سفارش">
<a-switch v-model:checked="variantForm.is_pre_order" />
</a-form-item>
</a-form>
<template #footer>
<a-button @click="variantModal = false">انصراف</a-button>
<a-button type="primary" @click="saveVariant">ثبت</a-button>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const route = useRoute();
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const id = route.params.id as string | undefined;
const loading = ref(false);
const brands = ref<{ id: string; title: string }[]>([]);
const categories = ref<{ id: string; title: string }[]>([]);
const collections = ref<{ id: string; title: string }[]>([]);
const form = reactive<Record<string, any>>({
title: '',
product_code: '',
show_type: 3,
category_id: undefined,
brand_id: undefined,
ciny_ids: [] as string[],
body_html: '',
is_active: true,
});
const err = reactive<Record<string, string>>({});
// ── variants (edited in memory, saved with the product)
interface VariantRow {
__key: string;
id?: string;
title: string;
grams?: number;
percentage_pay?: number;
qty?: number;
is_active: boolean;
is_pre_order: boolean;
}
const variants = ref<VariantRow[]>([]);
const variantModal = ref(false);
const editingIndex = ref<number | null>(null);
const variantForm = reactive<VariantRow>({
__key: '',
title: '',
grams: undefined,
percentage_pay: undefined,
qty: 0,
is_active: true,
is_pre_order: false,
});
const variantColumns = [
{ title: 'عنوان', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams' },
{ title: 'اجرت درصدی', dataIndex: 'percentage_pay' },
{ title: 'تعداد', dataIndex: 'qty' },
{ title: 'فعال', dataIndex: 'is_active' },
{ title: ' پیش سفارش', dataIndex: 'is_pre_order' },
{ title: 'عملیات', dataIndex: 'op' },
];
function showModal(record?: VariantRow, index?: number) {
if (record && index !== undefined) {
editingIndex.value = index;
Object.assign(variantForm, record);
} else {
editingIndex.value = null;
Object.assign(variantForm, {
__key: `v-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
id: undefined,
title: '',
grams: undefined,
percentage_pay: undefined,
qty: 0,
is_active: true,
is_pre_order: false,
});
}
variantModal.value = true;
}
function saveVariant() {
if (!variantForm.title) {
message.error('عنوان تنوع الزامی است');
return;
}
const row: VariantRow = { ...variantForm };
if (editingIndex.value === null) {
variants.value.push(row);
} else {
variants.value[editingIndex.value] = row;
}
variantModal.value = false;
}
function validate(): boolean {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.title) err.title = 'لطفا مقدار را وارد کنید';
return Object.keys(err).length === 0;
}
async function handleSubmit() {
if (!validate()) return;
try {
loading.value = true;
const payload = {
...form,
cats: form.category_id ? [form.category_id] : [],
variants: variants.value.map((v) => ({
id: v.id,
title: v.title,
grams: v.grams,
percentage_pay: v.percentage_pay,
qty: v.qty,
is_active: v.is_active,
is_pre_order: v.is_pre_order,
})),
};
if (id) {
await api.put(`product/update/${id}`, payload);
} else {
await api.post('product/create', payload);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/apps/product/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const [b, c, col] = await Promise.all([
api.get('brand'),
api.post('category'),
api.get('admin/products/collections'),
]);
brands.value = b.data;
// legacy read data.rows and suffixed each title with its storefront:
// title: x.title + ` (${x.type_str}) `
categories.value = (c.data.rows ?? c.data).map((x: any) => ({
id: x.id,
title: x.type_str ? `${x.title} (${x.type_str}) ` : x.title,
type: x.type,
}));
collections.value = col.data;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`product/show/${id}`);
Object.assign(form, {
title: data.title ?? '',
product_code: data.product_code ?? '',
show_type: data.show_type ?? 3,
category_id: data.category?.id ?? undefined,
brand_id: data.brand?.id ?? undefined,
ciny_ids: data.ciny_ids ?? [],
body_html: data.body_html ?? '',
is_active: data.is_active ?? true,
});
variants.value = (data.variants_all ?? []).map((v: any, i: number) => ({
__key: v.id ?? `v-${i}`,
id: v.id,
title: v.title,
grams: Number(v.grams),
percentage_pay: Number(v.percentage_pay),
qty: v.qty,
is_active: v.is_active,
is_pre_order: v.is_pre_order,
}));
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.grid-1 {
display: grid;
grid-template-columns: 1fr;
}
.flex-1 {
flex: 1;
}
.mb-8 {
margin-bottom: 2rem;
}
.overflow-auto {
overflow: auto;
}
</style>
@@ -0,0 +1,141 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/product/ChangePercent.vue.
Pick a category and/or brand, see how many variants match, then apply a new
wage percentage to all of them. Farsi copy preserved.
-->
<div id="page-user-list">
<a-card title="تغییر درصد محصولات">
<a-form layout="vertical">
<div class="grid-3">
<a-form-item label="دسته بندی">
<a-select
v-model:value="form.category_id"
allow-clear
show-search
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="getCount"
>
<a-select-option v-for="c in categories" :key="c.id" :value="c.id">
{{ c.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="برند">
<a-select
v-model:value="form.brand_id"
allow-clear
show-search
placeholder="انتخاب کنید"
:filter-option="filterOption"
@change="getCount"
>
<a-select-option v-for="b in brands" :key="b.id" :value="b.id">
{{ b.title }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="تعداد یافت شده">
<a-input :value="count" disabled />
</a-form-item>
<a-form-item label="اجرت درصدی">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</a-form-item>
</div>
<a-popconfirm
title="آیا از اعمال تغییرات مطمئن هستید؟"
ok-text="بله"
cancel-text="خیر"
@confirm="applyChange"
>
<a-button type="primary" :loading="changeLoading">اعمال تغییرات</a-button>
</a-popconfirm>
</a-form>
</a-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import api from '../../../api/client';
const brands = ref<{ id: string; title: string }[]>([]);
const categories = ref<{ id: string; title: string }[]>([]);
const count = ref<number | string>('-');
const changeLoading = ref(false);
const form = reactive<Record<string, any>>({
category_id: undefined,
brand_id: undefined,
percent: undefined,
});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
.toLowerCase()
.indexOf(input.toLowerCase()) >= 0;
}
async function getCount() {
try {
const { data } = await api.post('change/percent/count', {
category_id: form.category_id,
brand_id: form.brand_id,
});
count.value = data.count;
} catch (e) {
console.log('e ==> ', e);
}
}
async function applyChange() {
if (form.percent === undefined || form.percent === null) {
message.error('لطفا اجرت درصدی را وارد کنید');
return;
}
try {
changeLoading.value = true;
const { data } = await api.post('change/all', {
percent: form.percent,
category_id: form.category_id,
brand_id: form.brand_id,
});
Modal.success({ title: 'تغییرات با موفقیت اعمال شد', okText: 'بستن' });
count.value = data.updated;
} catch (e) {
Modal.success({ title: 'خطا در اعمال تغییرات' });
console.log(e);
} finally {
changeLoading.value = false;
}
}
onMounted(async () => {
try {
const [b, c] = await Promise.all([api.get('brand'), api.post('category')]);
brands.value = b.data;
// `POST category` returns the grid envelope; the picker reads data.rows.
categories.value = (c.data.rows ?? c.data).map((x: any) => ({
id: x.id,
title: x.type_str ? `${x.title} (${x.type_str}) ` : x.title,
}));
} catch (e) {
console.log(e);
}
getCount();
});
</script>
<style scoped>
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,16 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/product/EditProduct.vue.
Legacy's file is 71 lines but its entire template is <AddProduct /> — the
rest is page-level SCSS. It is one of the thin Edit wrappers identified in
the port plan, and stays a wrapper here. AddProduct reads the :id route param
to decide between create and edit.
-->
<div>
<AddProduct />
</div>
</template>
<script setup lang="ts">
import AddProduct from './AddProduct.vue';
</script>
@@ -0,0 +1,168 @@
<template>
<!--
Ported from legacy henza_admin/src/views/apps/product/ProductList.vue.
Farsi copy, permission gates, row selection and the bulk-change panel are
preserved. The commented-out "تنوع ها" action stays absent, as in legacy.
-->
<div id="page-user-list">
<a-card title="لیست محصولات">
<h6>تعداد {{ selectedRows.length }} محصول انتخاب شده است.</h6>
<XTable
ref="tblProducts"
v-model="selectedRows"
:model="model"
:has-selection="true"
>
<template #image="{ record }">
<img
v-if="record.ProductImage && record.ProductImage.length"
:src="record.ProductImage[0].image"
class="product-thumb"
alt=""
/>
<span v-else>-</span>
</template>
<template #show_type="{ record }">
{{ record.show_type_str }}
</template>
<template #op="{ record }">
<div v-if="!is_branch" class="w-48 flex">
<a-tooltip v-if="hasPermission('product.list.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('product.list.view')" title="اطلاعات جامع">
<ExclamationCircleOutlined
class="cursor-pointer mx-1 text-warning"
@click="showProductModal(record)"
/>
</a-tooltip>
<a-tooltip v-if="hasPermission('product.list.remove')" title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
>
<template #title><p>آیا مطمئن به حذف محصول مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</div>
</template>
</XTable>
<!-- اطلاعات جامع -->
<a-modal v-model:open="visibleProduct" title="اطلاعات جامع محصول" width="70%">
<div v-if="tempRow">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="نام محصول">{{ tempRow.title }}</a-descriptions-item>
<a-descriptions-item label="کد محصول">{{ tempRow.product_code || '-' }}</a-descriptions-item>
<a-descriptions-item label="برند">{{ tempRow.brand?.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="دسته بندی">
{{ tempRow.category?.title || '-' }}
</a-descriptions-item>
<a-descriptions-item label="نوع نمایش">{{ tempRow.show_type_str }}</a-descriptions-item>
<a-descriptions-item label="تعداد تنوع">
{{ tempRow.variants_all?.length ?? tempRow.variants_count ?? 0 }}
</a-descriptions-item>
</a-descriptions>
<a-table
v-if="tempRow.variants_all?.length"
class="mt-4"
size="small"
:data-source="tempRow.variants_all"
:columns="variantColumns"
:pagination="false"
row-key="id"
/>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visibleProduct = false">بستن</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import {
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildProductListModel } from './productListModel';
import { useUsersStore } from '../../../stores/users';
import { isBranch } from '../../../utils';
import api from '../../../api/client';
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildProductListModel();
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const selectedRows = ref<Record<string, any>[]>([]);
const is_branch = computed(() => isBranch());
const visibleProduct = ref(false);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const variantColumns = [
{ title: 'عنوان', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams' },
{ title: 'اجرت درصدی', dataIndex: 'percentage_pay' },
{ title: 'تعداد', dataIndex: 'qty' },
];
async function showProductModal(record: Record<string, any>) {
const { data } = await api.get(`admin/products/${record.id}`);
tempRow.value = data;
visibleProduct.value = true;
}
async function confirmDelete(id: string) {
try {
await api.post(`delete/product/${id}`);
message.info('محصول حذف شد');
tblProducts.value?.fetch();
} catch (e: any) {
// The backend refuses when the product appears in past orders; show its
// Farsi explanation rather than a generic failure.
Modal.error({
title: e?.response?.data?.message ?? 'خطا در حذف محصول',
okText: 'بستن',
});
}
}
function toEdit(id: string) {
router.push(`/apps/product/edit/${id}`);
}
</script>
<style scoped>
.w-48 {
width: 12rem;
}
.mt-4 {
margin-top: 1rem;
}
.product-thumb {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
}
</style>
@@ -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;
+161
View File
@@ -0,0 +1,161 @@
<template>
<!--
Ported from legacy henza_admin/src/views/auth/Login.vue.
Same two fields, same Farsi copy, same logo, same submit gate. Vuesax's
vs-input/vs-button and VeeValidate become ant-design-vue equivalents, and
the loading overlay becomes the button's own loading state.
-->
<div id="page-login">
<a-card class="login-card" :bordered="false">
<div class="login-grid">
<div class="login-form">
<div class="vx-card__title">
<h4>ورود به محیط مدیریت</h4>
<p>لطفا شماره تلفن همراه خود به همراه رمز عبور وارد کنید.</p>
</div>
<a-form layout="vertical" class="mt-8" @submit.prevent="loginJWT">
<a-form-item
label="موبایل"
:validate-status="mobileError ? 'error' : ''"
:help="mobileError"
>
<a-input
v-model:value="mobile"
autocomplete="off"
size="large"
@blur="touchedMobile = true"
/>
</a-form-item>
<a-form-item
label="رمز عبور"
:validate-status="passwordError ? 'error' : ''"
:help="passwordError"
>
<a-input-password
v-model:value="password"
size="large"
@blur="touchedPassword = true"
@pressEnter="loginJWT"
/>
</a-form-item>
<a-button
type="primary"
size="large"
:disabled="!validateForm"
:loading="loading"
@click="loginJWT"
>
ورود
</a-button>
</a-form>
</div>
<div class="login-art">
<img src="../../assets/images/logo/henza-icon.svg" alt="login" />
</div>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { useAuthStore } from '../../stores/auth';
const router = useRouter();
const auth = useAuthStore();
const mobile = ref('');
const password = ref('');
const loading = ref(false);
const touchedMobile = ref(false);
const touchedPassword = ref(false);
// legacy VeeValidate rules: mobile 'required|min:8', password 'required|min:5',
// both validated on blur.
const mobileError = computed(() => {
if (!touchedMobile.value) return '';
if (!mobile.value) return 'وارد کردن موبایل الزامی است';
if (mobile.value.length < 8) return 'مقدار ورودی صحیح نیست';
return '';
});
const passwordError = computed(() => {
if (!touchedPassword.value) return '';
if (!password.value) return 'وارد کردن رمز عبور الزامی است';
if (password.value.length < 5) return 'مقدار ورودی صحیح نیست';
return '';
});
// legacy: !errors.any() && mobile !== '' && password !== ''
const validateForm = computed(
() => !mobileError.value && !passwordError.value && mobile.value !== '' && password.value !== '',
);
async function loginJWT() {
if (!validateForm.value || loading.value) return;
loading.value = true;
try {
// legacy also sent token_mobile from localStorage for Firebase push, which is
// inactive in this port; the field is omitted rather than sent as null.
await auth.login({ phone: mobile.value, password: password.value });
// legacy did a hard `window.location.href = '/admin/my-data'`; a router push
// reaches the same route without discarding the freshly-populated store.
await router.push('/my-data');
} catch (e) {
console.log(e);
message.error('خطا در ورود');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
#page-login {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.login-card {
width: 100%;
max-width: 900px;
}
.login-grid {
display: grid;
grid-template-columns: 1fr;
align-items: center;
}
@media (min-width: 992px) {
.login-grid {
grid-template-columns: 1fr 1fr;
}
}
.login-form {
background-color: #eff2f7; /* legacy inline style */
padding: 2rem;
min-height: 505px; /* legacy .login-tabs-container */
}
.login-art {
display: none;
text-align: center;
}
@media (min-width: 992px) {
.login-art {
display: block;
}
}
.login-art img {
max-width: 70%;
margin: 0 auto;
}
.mt-8 {
margin-top: 2rem;
}
</style>
@@ -0,0 +1,122 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/AddBranches.vue.
Serves BOTH create and edit — EditBranches.vue is a wrapper over it.
A branch's manager is an EXISTING admin picked from user/list. (The workshop
form differs: it creates its supervisor inline — see AddWorkshop.vue.)
-->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card :title="id ? 'ویرایش شعبه' : 'افزودن شعبه'" class="mb-base">
<div class="grid-2">
<a-form-item label="نام شعبه" :validate-status="err.name ? 'error' : ''" :help="err.name">
<a-input v-model:value="form.name" />
</a-form-item>
<a-form-item label="تلفن شعبه">
<a-input v-model:value="form.phone" />
</a-form-item>
<a-form-item label="مدیر">
<a-select
v-model:value="form.user_id"
show-search
allow-clear
placeholder="انتخاب کنید"
:filter-option="filterOption"
>
<a-select-option v-for="u in adminUsers" :key="u.id" :value="u.id">
{{ u.name }}
</a-select-option>
</a-select>
</a-form-item>
</div>
<a-form-item label="آدرس">
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 2, maxRows: 5 }" />
</a-form-item>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../api/client';
const route = useRoute();
const router = useRouter();
const id = route.params.id as string | undefined;
const loading = ref(false);
const adminUsers = ref<{ id: string; name: string }[]>([]);
const form = reactive<Record<string, any>>({
name: '',
phone: '',
address: '',
user_id: undefined,
});
const err = reactive<Record<string, string>>({});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
}
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.name) {
err.name = 'لطفا مقدار را وارد کنید';
return;
}
try {
loading.value = true;
if (id) {
await api.put(`branch/${id}`, form);
} else {
await api.post('branch', form);
}
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/branch/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.get('user/list');
adminUsers.value = data;
} catch (e) {
console.log(e);
}
if (id) {
try {
const { data } = await api.get(`branch/${id}`);
Object.assign(form, {
name: data.name ?? '',
phone: data.phone ?? '',
address: data.address ?? '',
user_id: data.user_id ?? undefined,
});
} catch (e) {
console.log(e);
}
}
});
</script>
<style scoped>
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
</style>
@@ -0,0 +1,9 @@
<template>
<!-- Ported from legacy views/branch/EditBranches.vue. AddBranches reads the
:id route param to switch between create and edit. -->
<AddBranches />
</template>
<script setup lang="ts">
import AddBranches from './AddBranches.vue';
</script>
@@ -0,0 +1,72 @@
<template>
<!--
Shared implementation of legacy ListBranches.vue and ListWorkshop.vue.
Verified by diff: the two files differ by 14 lines — the card title, the
model import, and the edit route. Parameterised rather than duplicated.
Legacy quirks preserved:
- the delete action is COMMENTED OUT in both files and stays absent
- the status tooltip is labelled "وضعیت" and toggles on click
- legacy binds `slot-scope="text,record"` then reads `text.record.id`,
i.e. the row arrives as the FIRST argument; the Vue 3 slot gives us
`record` directly, which is the same row.
-->
<a-card :title="title">
<XTable ref="tbl" :model="model">
<template #actions="{ record }">
<div class="flex gap-2">
<a-tooltip title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="toEdit(record.id)" />
</a-tooltip>
<a-tooltip title="وضعیت">
<CheckOutlined
v-if="record.is_active"
class="cursor-pointer mx-1 text-success"
@click="setOffer(record.id)"
/>
<CloseOutlined v-else class="cursor-pointer mx-1 text-danger" @click="setOffer(record.id)" />
</a-tooltip>
</div>
</template>
</XTable>
</a-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { CheckOutlined, CloseOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import api from '../../api/client';
import type { XTbl } from '../../components/x-table/model';
const props = defineProps<{
title: string;
model: XTbl;
/** Where the edit pencil navigates, e.g. '/branch/edit' or '/workshop/edit'. */
editPath: string;
/** The toggle endpoint, e.g. 'branch/active' or 'workshop/active'. */
activePath: string;
}>();
const router = useRouter();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
function toEdit(id: string) {
router.push(`${props.editPath}/${id}`);
}
/** legacy setOffer(): the status toggle */
async function setOffer(id: string) {
try {
await api.get(`${props.activePath}/${id}`);
message.success('تغییرات ثبت شد');
tbl.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در تغییر وضعیت');
}
}
</script>
@@ -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;
@@ -0,0 +1,17 @@
<template>
<!-- Ported from legacy views/branch/ListBranches.vue — a thin wrapper over the
shared facility list (see FacilityList.vue for why). -->
<FacilityList
title="لیست شعب"
:model="model"
edit-path="/branch/edit"
active-path="branch/active"
/>
</template>
<script setup lang="ts">
import FacilityList from './FacilityList.vue';
import { buildBranchListModel } from './ListBranch';
const model = buildBranchListModel();
</script>
@@ -0,0 +1,13 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/addCustomer.vue — a 19-line
wrapper whose entire template is <singleCustomer />. Kept as a wrapper.
-->
<div>
<SingleCustomerForm />
</div>
</template>
<script setup lang="ts">
import SingleCustomerForm from '../apps/customer/singleCustomer.vue';
</script>
@@ -0,0 +1,79 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/buyList.vue — the branch's
purchase invoices: the product allocations head office sent to this branch.
The backend scopes allocations by the caller's branch, so a branch user sees
only their own without the view having to filter.
-->
<div id="page-user-list">
<a-card title="فاکتور خرید">
<XTable ref="tbl" :model="model">
<template #opt="{ record }">
<a-tooltip title="مشاهده">
<EyeOutlined class="cursor-pointer mx-1 text-primary" @click="view(record)" />
</a-tooltip>
</template>
</XTable>
<a-modal v-model:open="visible" title="مشاهده فاکتور" width="70%">
<div v-if="tempRow">
<a-descriptions bordered size="small" :column="2">
<a-descriptions-item label="شعبه">{{ tempRow.name }}</a-descriptions-item>
<a-descriptions-item label="ثبت کننده">
{{ tempRow.allocator_user?.name ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="تاریخ">{{ jdate(tempRow.caj) }}</a-descriptions-item>
<a-descriptions-item label="تعداد آیتم">{{ tempRow.count_item }}</a-descriptions-item>
</a-descriptions>
<a-table
class="mt-4"
size="small"
:data-source="tempRow.products"
:columns="productColumns"
:pagination="false"
row-key="id"
/>
</div>
<a-empty v-else />
<template #footer>
<a-button @click="visible = false">بستن</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { EyeOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildBranchBuyListModel } from './orderListModel';
import { jdate } from '../../utils/filters';
import api from '../../api/client';
const model = buildBranchBuyListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const visible = ref(false);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const productColumns = [
{ title: 'کد محصول', dataIndex: 'product_code' },
{ title: 'نام محصول', dataIndex: 'title' },
{ title: 'وزن', dataIndex: 'grams' },
];
async function view(record: Record<string, any>) {
const { data } = await api.get(`branch/product/${record.id}`);
tempRow.value = data;
visible.value = true;
}
</script>
<style scoped>
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,92 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/dashboard.vue — the branch
home screen: this week's orders and best-selling products.
LEGACY GAP, deliberately closed: the legacy file declares both tables against
demo arrays defined in the component and makes NO server call at all
(verified: zero axios references in the file). The cards are wired to real
endpoints here, because a dashboard showing placeholder rows is worse than
one showing nothing. Recorded in docs/ADMIN-PORT-PLAN.md.
-->
<a-spin :spinning="loading">
<a-card title="سفارشات هفته جاری">
<a-table
size="small"
:data-source="lastOrder"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'sts_str'">
<a-tag :color="tagColor(record.statusColor)">{{ record.sts_str }}</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">
{{ jdate(record.caj) }}
</template>
</template>
</a-table>
</a-card>
<a-card title="محصولات پر فروش" class="mt-4">
<a-table
size="small"
:data-source="topProducts"
:columns="productColumns"
:pagination="false"
row-key="id"
/>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { jdate } from '../../utils/filters';
import api from '../../api/client';
const loading = ref(false);
const lastOrder = ref<Record<string, any>[]>([]);
const topProducts = ref<Record<string, any>[]>([]);
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'مشتری', dataIndex: 'customer' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'وضعیت', dataIndex: 'sts_str' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const productColumns = [
{ title: 'کد محصول', dataIndex: 'product_code' },
{ title: 'نام محصول', dataIndex: 'title' },
{ title: 'تعداد تنوع', dataIndex: 'variants_count' },
];
function tagColor(c: string) {
return { success: 'green', warning: 'orange', danger: 'red', primary: 'blue' }[c] ?? '';
}
onMounted(async () => {
try {
loading.value = true;
const [o, p] = await Promise.all([
api.get('get-analytics-tak'),
api.post('products', { page: 1, limit: 5 }),
]);
lastOrder.value = o.data.lastOrder ?? [];
topProducts.value = p.data.rows ?? [];
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.mt-4 {
margin-top: 1rem;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/login.vue — the branch-mode
sign-in. A different layout from the admin login (centred logo, no split
illustration panel), but the same auth action underneath.
Legacy's right-hand illustration panel is COMMENTED OUT and stays absent.
-->
<div class="flex gap-6 w-full justify-center h-screen branch-login-page">
<div class="flex flex-col h-screen items-center justify-center">
<img
src="../../assets/images/logo/henza-icon.svg"
width="150"
alt="login"
class="mx-auto mb-5"
/>
<a-form layout="vertical" class="login-form" @submit.prevent="handleSubmit">
<a-form-item
label="نام کاربری"
class="w-full"
:validate-status="err.phone ? 'error' : ''"
:help="err.phone"
>
<a-input v-model:value="form.phone" size="large" />
</a-form-item>
<a-form-item
label="رمز عبور"
class="w-full"
:validate-status="err.password ? 'error' : ''"
:help="err.password"
>
<a-input-password
v-model:value="form.password"
size="large"
@press-enter="handleSubmit"
/>
</a-form-item>
<a-button
size="large"
type="primary"
class="mt-8 w-full"
:loading="loading"
@click="handleSubmit"
>
ورود
</a-button>
</a-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { useAuthStore } from '../../stores/auth';
const router = useRouter();
const auth = useAuthStore();
const loading = ref(false);
const form = reactive({ phone: '', password: '' });
const err = reactive<Record<string, string>>({});
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.phone) err.phone = 'نام کاربری را وارد نمایید';
if (!form.password) err.password = 'رمز عبور را وارد نمایید';
if (Object.keys(err).length) return;
try {
loading.value = true;
await auth.login({ phone: form.phone, password: form.password });
// legacy did a hard window.location.href = '/branch/dashboard'; a router
// push reaches the same route without discarding the populated store.
await router.push('/dashboard');
} catch (e) {
console.log(e);
message.error('خطا در ورود');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.branch-login-page {
display: flex;
width: 100%;
justify-content: center;
min-height: 100vh;
}
.flex-col { flex-direction: column; }
.justify-center { justify-content: center; }
.h-screen { min-height: 100vh; }
.gap-6 { gap: 1.5rem; }
.mx-auto { margin: 0 auto; }
.mb-5 { margin-bottom: 1.25rem; }
.mt-8 { margin-top: 2rem; }
.login-form { width: 320px; max-width: 90vw; }
</style>
@@ -0,0 +1,252 @@
<template>
<!--
Ported from legacy henza_admin/src/views/branch/order/create.vue — the
branch-mode order builder.
It differs from the admin builder (views/order/create.vue) in flow, not in
substance: the CUSTOMER is picked first and the product grid stays hidden
until one is chosen ("جهت ثبت سفارش مشتری را انتخاب نمایید"). Preserved.
It also drops the admin version's `$hasPermission('order.create')` wrapper —
branch mode gates the whole route instead. Preserved.
-->
<a-spin :spinning="loading">
<a-card title="ثبت سفارش">
<span>جهت ثبت سفارش مشتری را انتخاب نمایید</span>
<a-form layout="vertical" class="mt-4" @submit.prevent="submitOrder">
<a-form-item
label="مشتری"
class="customer-field"
:validate-status="err.customer_id ? 'error' : ''"
:help="err.customer_id"
>
<a-select
v-model:value="form.customer_id"
show-search
option-filter-prop="children"
:filter-option="filterOption"
>
<a-select-option v-for="item in customers" :key="item.id" :value="item.id">
{{ `${item.id}-${item.fname} ${item.lname}` }}
</a-select-option>
</a-select>
</a-form-item>
<!-- legacy: the grid appears only once a customer is chosen -->
<div v-if="form.customer_id != null">
<h6>تعداد {{ items.length }} محصول انتخاب شده است.</h6>
<XTable ref="tblProducts" :model="cols">
<template #image="{ record }">
<img
width="90px"
height="90px"
style="object-fit: scale-down"
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
/>
</template>
<template #op="{ record }">
<a-dropdown>
<a-button style="margin-left: 8px">
افزودن
<DownOutlined />
</a-button>
<template #overlay>
<a-menu @click="onVariantPick(record, $event)">
<a-menu-item v-for="item in record.variants_all" :key="item.id">
افزودن تنوع {{ item.title }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</XTable>
<a-card v-if="items.length > 0" title="سبد سفارش" class="mt-4">
<a-table
:columns="columns"
:data-source="items"
bordered
:pagination="false"
row-key="vid"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.dataIndex === 'delete'">
<CloseOutlined class="cursor-pointer" @click="cart.remove(record)" />
</template>
<template v-else-if="column.dataIndex === 'product'">
<div class="flex items-center">
<img :src="getImage(record.product)" width="50px" />
{{ record.product.title }}
</div>
</template>
<template v-else-if="column.dataIndex === 'qty'">
<a-input-number
v-model:value="items[index].qty"
:min="1"
:max="maxQtyFor(index)"
@change="onQtyChange(index, $event)"
/>
</template>
<template v-else-if="column.dataIndex === 'weight'">
{{ getVariant(record).grams }}
</template>
<template v-else-if="column.dataIndex === 'total'">
{{ rowWeight(record) }} گرم
</template>
</template>
</a-table>
<div class="totals">
<a-tag color="blue">مجموع وزن: {{ totalWeight }} گرم</a-tag>
</div>
<a-form-item label="توضیحات">
<a-textarea v-model:value="form.note" :auto-size="{ minRows: 2, maxRows: 4 }" />
</a-form-item>
<a-button type="primary" :disabled="items.length < 1" @click="submitOrder">
ثبت سفارش
</a-button>
</a-card>
</div>
</a-form>
</a-card>
</a-spin>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { CloseOutlined, DownOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { buildProductListModel } from '../../apps/product/productListModel';
import { useCartStore, type CartItem, type CartVariant } from '../../../stores/cart';
import api from '../../../api/client';
const router = useRouter();
const cart = useCartStore();
const noImage = '/img/no-items.png';
const cols = buildProductListModel();
const tblProducts = ref<{ fetch: () => Promise<void> } | null>(null);
const loading = ref(false);
const customers = ref<{ id: string; fname: string; lname: string }[]>([]);
const form = reactive<Record<string, any>>({ customer_id: undefined, note: '' });
const err = reactive<Record<string, string>>({});
const items = computed(() => cart.items);
const columns = [
{ title: 'حذف', dataIndex: 'delete', width: 60, align: 'center' },
{ title: 'محصول', dataIndex: 'product' },
{ title: 'تعداد', dataIndex: 'qty', align: 'center', width: 140 },
{ title: 'وزن', dataIndex: 'weight', align: 'center', width: 90 },
{ title: 'مجموع', dataIndex: 'total', align: 'center' },
];
function getVariant(item: CartItem): CartVariant & Record<string, any> {
return (item.product.variants_all.find((x) => x.id === item.vid) ?? {}) as CartVariant;
}
function getImage(product: Record<string, any>) {
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
}
/** Same as the admin builder: 0 stock means uncapped, which keeps pre-orders orderable. */
function maxQtyFor(index: number): number {
const item = items.value[index];
if (!item) return Infinity;
const stock = Number(getVariant(item).qty ?? 0);
return stock > 0 ? stock : Infinity;
}
function onQtyChange(index: number, value: unknown) {
cart.updateItemQuantity({ index, quantity: Number(value) });
}
function onVariantPick(record: Record<string, any>, e: { key: string | number }) {
cart.add({
vid: String(e.key),
pid: record.id,
product: record as any,
qty: 1,
promotion_id: record.promotion && record.promotion.id ? record.promotion.id : null,
});
message.info('اضافه شد');
}
function rowWeight(record: CartItem) {
return Number(getVariant(record).grams ?? 0) * record.qty;
}
/** legacy sums the gram figures WITHOUT multiplying by qty — preserved. */
const totalWeight = computed(() => {
let total = 0;
for (const item of items.value) total += Number(getVariant(item).grams ?? 0);
return total;
});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
.toLowerCase()
.includes(input.toLowerCase());
}
async function submitOrder() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.customer_id) {
err.customer_id = 'مقدار را وارد نمایید';
return;
}
try {
loading.value = true;
const order_items = items.value.map((x) => {
const v = getVariant(x);
return { vid: x.vid, qty: x.qty, percent: v.percentage_pay };
});
await api.post('order', {
customer_id: form.customer_id,
note: form.note,
sts: 1,
order_items,
});
message.info('عملیات با موفقیت انجام شد');
cart.clear();
router.push('/order/list');
} catch (e: any) {
console.log(e);
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
onMounted(async () => {
try {
const { data } = await api.post('single-customer', { page: 1, limit: 500 });
customers.value = data.rows ?? [];
} catch (e) {
console.log(e);
}
await cart.init();
});
</script>
<style scoped>
.customer-field {
max-width: 320px;
}
.mt-4 {
margin-top: 1rem;
}
.totals {
margin: 1rem 0;
}
</style>
@@ -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;
@@ -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;
@@ -0,0 +1,120 @@
<template>
<!--
Ported from legacy henza_admin/src/views/city/cityList.vue.
The two percent columns are edited in a modal that legacy loads through two
separate GETs (get-percent / get-percent-aux) before opening.
-->
<div id="page-user-list">
<a-card title="لیست شهر ها">
<XTable ref="citiesTable" :model="model">
<template #percent="{ text }">
{{ text ?? '-' }}
</template>
<template #percent_aux="{ text }">
{{ text ?? '-' }}
</template>
<template #opt="{ record }">
<a-tooltip v-if="hasPermission('cities.list.view.edit')" title="ویرایش">
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="edit(record)" />
</a-tooltip>
<a-tooltip v-if="hasPermission('cities.list.remove')" title="حذف">
<a-popconfirm
placement="top"
ok-text="بله"
cancel-text="خیر"
@confirm="confirmDelete(record.id)"
>
<template #title><p>آیا مطمئن به حذف شهر مورد نظر هستید ؟</p></template>
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
</a-popconfirm>
</a-tooltip>
</template>
</XTable>
<a-modal v-model:open="visible" title="ویرایش شهر" @ok="save">
<a-form layout="vertical">
<a-form-item label="نام شهر">
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item label="درصد مشتری عمده">
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" />
</a-form-item>
<a-form-item label="درصد کارشناس فروش">
<a-input-number v-model:value="form.percent_aux" class="w-full" :step="0.1" />
</a-form-item>
</a-form>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
import XTable from '../../components/x-table/XTable.vue';
import { buildCityListModel } from './CityListModel';
import { useUsersStore } from '../../stores/users';
import api from '../../api/client';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildCityListModel();
const citiesTable = ref<{ fetch: () => Promise<void> } | null>(null);
const visible = ref(false);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const form = reactive<Record<string, any>>({ title: '', percent: null, percent_aux: null });
/**
* legacy edit(): fetches the two percents separately before opening the modal.
* Both are already on the row here, but the legacy calls are kept so the two
* endpoints stay exercised and the values are always current.
*/
async function edit(row: Record<string, any>) {
tempRow.value = row;
form.title = row.title ?? '';
try {
const [p, pa] = await Promise.all([
api.get(`cities/get-percent/${row.id}`),
api.get(`cities/get-percent-aux/${row.id}`),
]);
form.percent = p.data === null ? null : Number(p.data);
form.percent_aux = pa.data === null ? null : Number(pa.data);
} catch (e) {
form.percent = row.percent === null ? null : Number(row.percent);
form.percent_aux = row.percent_aux === null ? null : Number(row.percent_aux);
}
visible.value = true;
}
async function save() {
try {
await api.put(`cities/${tempRow.value?.id}`, {
title: form.title,
percent: form.percent,
percent_aux: form.percent_aux,
});
message.success('تغییرات ثبت شد');
visible.value = false;
citiesTable.value?.fetch();
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت تغییرات');
}
}
async function confirmDelete(id: string) {
try {
await api.delete(`cities/${id}`);
message.success('شهر حذف شد');
citiesTable.value?.fetch();
} catch (e: any) {
// The backend refuses while customers, branches or addresses reference it.
Modal.error({ title: e?.response?.data?.message ?? 'خطا در حذف شهر', okText: 'بستن' });
}
}
</script>
@@ -0,0 +1,52 @@
<template>
<!-- Ported from legacy henza_admin/src/views/city/createCity.vue. -->
<a-spin :spinning="loading">
<a-form layout="vertical" @submit.prevent="handleSubmit">
<a-card title="افزودن شهر">
<div class="grid-2">
<a-form-item label="نام شهر" :validate-status="err.title ? 'error' : ''" :help="err.title">
<a-input v-model:value="form.title" />
</a-form-item>
<a-form-item label="استان">
<a-input v-model:value="form.province" />
</a-form-item>
</div>
<a-button type="primary" @click="handleSubmit">ثبت اطلاعات</a-button>
</a-card>
</a-form>
</a-spin>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import api from '../../api/client';
const router = useRouter();
const loading = ref(false);
const form = reactive<Record<string, any>>({ title: '', province: '' });
const err = reactive<Record<string, string>>({});
async function handleSubmit() {
Object.keys(err).forEach((k) => delete err[k]);
if (!form.title) {
err.title = 'لطفا مقدار را وارد کنید';
return;
}
try {
loading.value = true;
await api.post('cities', form);
message.info('اطلاعات با موفقیت به ثبت رسید.');
router.push('/city/list');
} catch (e: any) {
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
</style>
@@ -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;
@@ -0,0 +1,273 @@
<template>
<!--
Ported from legacy henza_admin/src/views/customer/Comments/Comments.vue.
Farsi copy, permission gates, slot names and the status dropdown are
preserved. The inline avatar SVG is replaced by an ant icon; the hardcoded
legacy asset path (/admin/img/henza-icon.2b1e8c97.svg — a build hash that no
longer exists) becomes the bundled logo.
-->
<div id="page-user-list">
<a-card title="لیست دیدگاه ها">
<XTable ref="tbl" :model="model">
<template #customer="{ text }">
{{ text && text.fname && text.lname ? `${text.fname} ${text.lname}` : 'بدون نام' }}
</template>
<template #productName="{ record }">
<a :href="productLink(record)" target="_blank">{{ record.product.title }}</a>
</template>
<template #sts="{ text }">
<a-tag v-if="text == 1">در انتظار تایید</a-tag>
<a-tag v-else-if="text == 2" color="green">تایید شده</a-tag>
<a-tag v-else color="red">عدم تایید</a-tag>
</template>
<template #reply="{ text }">
<a-tag v-if="text && text.length > 0 && text[0].comment !== null" color="green">
پاسخ داده شده
</a-tag>
<a-tag v-else>بدون پاسخ</a-tag>
</template>
<template #changeState="{ record }">
<a-dropdown v-if="hasPermission('comment.state')">
<a-tag
style="cursor: pointer"
:color="record.sts === 1 ? '' : record.sts === 2 ? 'green' : record.sts === 3 ? 'red' : ''"
@click="(e: Event) => e.preventDefault()"
>
{{ record.sts === 1 ? 'در انتظار تایید' : record.sts === 2 ? 'تایید' : 'عدم تایید' }}
<DownOutlined />
</a-tag>
<template #overlay>
<a-menu>
<a-menu-item>
<div
@click="
record.sts !== 2
? changeSts(record.id, 2)
: message.error('حالت فعلی تایید می باشد')
"
>
تایید
</div>
</a-menu-item>
<a-menu-item class="red">
<div
@click="
record.sts !== 3
? changeSts(record.id, 3)
: message.error('حالت فعلی عدم تایید می باشد')
"
>
عدم تایید
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<template #opt="{ record }">
<div class="flex">
<TooltipBtn
v-if="hasPermission('comment.view')"
icon-class="w-5 h-5 text-primary"
class="mr-3"
icon="EyeIcon"
title="مشاهده دیدگاه"
@click="viewComment(record)"
/>
<TooltipBtn
v-if="hasPermission('comment.remove')"
icon-class="w-5 h-5 text-danger"
class="mr-3"
icon="TrashIcon"
pop-title="دیدگاه حذف شود؟"
@click="deleteComment(record.id)"
/>
</div>
</template>
</XTable>
<!-- مشاهده دیدگاه -->
<a-modal v-model:open="visibleComment" title="مشاهده دیدگاه" width="80%">
<div v-if="tempRow" class="comments-list">
<ul>
<li class="comment-item">
<span class="img">
<UserOutlined style="font-size: 48px; color: #1c2d56" />
</span>
<div class="comment-body">
<h5>
{{
tempRow.customer && tempRow.customer.fname && tempRow.customer.lname
? `${tempRow.customer.fname} ${tempRow.customer.lname}`
: 'بدون نام'
}}
</h5>
<span class="time">
ارسال شده در تاریخ : {{ tempRow.caj ? String(tempRow.caj).split('-')[0] : null }}
</span>
<a-form-item style="width: 100%">
<a-textarea
v-model:value="commentText"
:disabled="!hasPermission('comment.view.edit')"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
</div>
<ul>
<li class="comment-item">
<img src="../../../assets/images/logo/henza-icon.svg" alt="comment author" class="img border" />
<div class="comment-body">
<h5>ادمین</h5>
<span v-if="tempRow.reply && tempRow.reply[0]" class="time">
ارسال شده در تاریخ :
{{ tempRow.reply[0].caj ? String(tempRow.reply[0].caj).split('-')[0] : '' }}
</span>
<a-form-item style="width: 100%">
<a-textarea
v-model:value="replyText"
:disabled="!hasPermission('comment.view.edit')"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
<a-button
v-if="(showChangeReplyBtn || showChangeCommentBtn) && hasPermission('comment.view.edit')"
class="mr-4"
@click="submitChanges(tempRow)"
>
ثبت تغییرات
</a-button>
</div>
</li>
</ul>
</li>
</ul>
</div>
<template #footer>
<a-button @click="visibleComment = false">بستن</a-button>
</template>
</a-modal>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { DownOutlined, UserOutlined } from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import TooltipBtn from '../../../components/tooltip-btn/TooltipBtn.vue';
import { buildCommentListModel } from './CommentListModel';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const model = buildCommentListModel();
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const tempRow = ref<Record<string, any> | undefined>(undefined);
const visibleComment = ref(false);
const commentText = ref('');
const replyText = ref('');
const showChangeCommentBtn = computed(() => commentText.value !== (tempRow.value?.comment ?? ''));
const showChangeReplyBtn = computed(
() => replyText.value !== (tempRow.value?.reply?.[0]?.comment ?? ''),
);
/**
* legacy built a storefront link from the product title:
* https://henzagold.com/product/<title-with-dashes>/<id>
* The host is kept because it points at the live public site, not the API.
*/
function productLink(record: Record<string, any>) {
const slug = String(record.product?.title ?? '').split(' ').join('-');
return `https://henzagold.com/product/${slug}/${record.product?.id}`;
}
async function changeSts(id: string, sts: number) {
try {
await api.post('comment/sts', { id, sts });
message.info('تغییرات ثبت شد ');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
function viewComment(record: Record<string, any>) {
tempRow.value = record;
commentText.value = record.comment ?? '';
replyText.value = record.reply?.[0]?.comment ?? '';
visibleComment.value = true;
}
async function deleteComment(id: string) {
try {
await api.delete(`comment/${id}`);
message.info('دیدگاه حذف شد');
tbl.value?.fetch();
} catch (e) {
message.error('خطا در حذف');
}
}
async function submitChanges(row: Record<string, any>) {
try {
await api.post('comment', {
product_id: row.product_id,
customer_id: row.customer_id,
comment: replyText.value,
reply_to: row.id,
});
message.info('تغییرات ثبت شد ');
visibleComment.value = false;
tbl.value?.fetch();
} catch (e) {
message.error('خطا در ثبت تغییرات');
}
}
</script>
<style scoped>
.mr-3 {
margin-left: 0.75rem;
}
.comments-list ul {
list-style: none;
padding-right: 0;
}
.comment-item {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.comment-item .img {
width: 65px;
height: 65px;
display: flex;
align-items: center;
justify-content: center;
}
.comment-body {
flex: 1;
min-width: 240px;
}
.comment-body .time {
font-size: 12px;
color: #888;
}
.border {
border: 1px solid grey;
border-radius: 50%;
}
</style>
@@ -0,0 +1,210 @@
<template>
<!--
Shared implementation of legacy ViewOmdeCustomer.vue (376 lines) and
ViewSingleCustomer.vue (425 lines) — the customer detail screens.
Both show the same four panels (profile, orders, financial documents, wallet)
against the same customer record; legacy duplicated the whole page per
customer kind. The differing bits are parameterised:
- the wholesale screen adds an activity-log panel (legacy log/ago/{id})
- the retail screen adds a wallet panel (legacy single/wallet/{id})
-->
<a-spin :spinning="loading">
<a-card :title="title">
<a-descriptions bordered size="small" :column="3">
<a-descriptions-item label="نام">{{ customer?.name ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="موبایل">{{ customer?.mobile ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کد ملی">
{{ customer?.national_code ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="شهر">{{ customer?.city?.title ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="کارشناس فروش">
{{ customer?.reagent?.name ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="وضعیت">
<a-tag :color="customer?.active === 1 ? 'green' : 'red'">
{{ customer?.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="سطح اعتبار">
{{ customer?.gram_level ?? '-' }} گرم
</a-descriptions-item>
<a-descriptions-item label="بدهی گرمی">{{ customer?.remain ?? '-' }}</a-descriptions-item>
<a-descriptions-item label="بدهی ریالی">
{{ customer?.rial_remain ?? '-' }}
</a-descriptions-item>
</a-descriptions>
<div v-if="dashboard" class="stats mt-4">
<a-tag color="blue">کل سفارش‌ها: {{ dashboard.total_order }}</a-tag>
<a-tag color="orange">سفارش باز: {{ dashboard.open_order }}</a-tag>
<a-tag color="green">اسناد: {{ dashboard.ledger_entries }}</a-tag>
</div>
</a-card>
<a-tabs class="mt-4" :default-active-key="defaultTab">
<a-tab-pane key="orders" tab="سفارش ها">
<a-table
size="small"
:data-source="orders"
:columns="orderColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="documents" tab="سند های مالی">
<a-table
size="small"
:data-source="documents"
:columns="documentColumns"
:pagination="{ pageSize: 10 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'confirmed'">
<a-tag :color="record.confirmed === 1 ? 'green' : ''">
{{ record.confirmed === 1 ? 'تایید شده' : 'تایید نشده' }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showWallet" key="wallet" tab="کیف پول">
<a-table
size="small"
:data-source="wallet"
:columns="walletColumns"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdate(record.caj) }}</template>
<template v-else-if="column.dataIndex === 'expires_at'">
{{ jdate(record.expires_at) }}
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane v-if="showActivity" key="activity" tab="فعالیت ها">
<a-table
size="small"
:data-source="activity"
:columns="activityColumns"
:pagination="{ pageSize: 15 }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'caj'">{{ jdateTime(record.caj) }}</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-spin>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { jdate, jdateTime } from '../../utils/filters';
import api from '../../api/client';
const props = withDefaults(
defineProps<{
title?: string;
/** The retail screen shows the wallet panel. */
showWallet?: boolean;
/** The wholesale screen shows the activity log. */
showActivity?: boolean;
defaultTab?: string;
}>(),
{ title: 'مشاهده اطلاعات مشتری', showWallet: false, showActivity: false, defaultTab: 'orders' },
);
const route = useRoute();
const id = route.params.id as string;
const loading = ref(false);
const customer = ref<Record<string, any> | undefined>(undefined);
const dashboard = ref<Record<string, any> | undefined>(undefined);
const orders = ref<Record<string, any>[]>([]);
const documents = ref<Record<string, any>[]>([]);
const wallet = ref<Record<string, any>[]>([]);
const activity = ref<Record<string, any>[]>([]);
const orderColumns = [
{ title: 'شماره سفارش', dataIndex: 'name' },
{ title: 'وزن', dataIndex: 'w' },
{ title: 'تعداد آیتم', dataIndex: 'items_count' },
{ title: 'شعبه', dataIndex: 'branch' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const documentColumns = [
{ title: 'نوع', dataIndex: 'type' },
{ title: 'مقدار', dataIndex: 'value' },
{ title: 'وضعیت', dataIndex: 'confirmed' },
{ title: 'توضیحات', dataIndex: 'note' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
const walletColumns = [
{ title: 'مقدار (گرم)', dataIndex: 'value' },
{ title: 'تاریخ اعطا', dataIndex: 'caj' },
{ title: 'انقضا', dataIndex: 'expires_at' },
];
const activityColumns = [
{ title: 'عملیات', dataIndex: 'action' },
{ title: 'جزئیات', dataIndex: 'detail' },
{ title: 'تاریخ', dataIndex: 'caj' },
];
onMounted(async () => {
try {
loading.value = true;
const [c, d, o, docs] = await Promise.all([
api.get(`customer/${id}`),
api.get(`major/dashboard/${id}`).catch(() => ({ data: undefined })),
api.get(`admin/customers-omde/${id}/orders`).catch(() => ({ data: [] })),
api.get(`admin/customers-omde/${id}/documents`).catch(() => ({ data: [] })),
]);
customer.value = c.data;
dashboard.value = d.data;
orders.value = o.data;
documents.value = docs.data;
if (props.showWallet) {
const w = await api
.get(`admin/customers-omde/${id}/wallet`)
.catch(() => ({ data: [] }));
wallet.value = w.data;
}
if (props.showActivity) {
const a = await api.get(`log/ago/${id}`).catch(() => ({ data: [] }));
activity.value = a.data;
}
} catch (e) {
console.log(e);
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.stats {
display: flex;
gap: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
</style>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/BankerList.vue — differs from OmdeList by title + model url only (verified: 18 diff lines, all whitespace besides those two). -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.banker.title;
const model = buildOmdeListModel('banker');
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeArchivedList.vue. -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.archived.title;
const model = buildOmdeListModel('archived');
</script>
@@ -0,0 +1,414 @@
<template>
<!--
Shared implementation of the five legacy wholesale-customer lists
(OmdeList, BankerList, ShopkeeperList, OmdeNonVerifiedList, OmdeArchivedList).
Legacy shipped these as five ~615-line files. Verified by diff, they differ by:
- ShopkeeperList vs OmdeList: 4 lines (card title + model import ONLY)
- BankerList: 18 lines, all whitespace/formatting except the same two
- OmdeArchived: 24 lines, same plus method ordering
- OmdeNonVerified: 34 lines, same plus two genuine quirks (see props below)
Per the agreed decision, behaviour and appearance are preserved exactly while
the implementation lives once, parameterised by props.
-->
<div id="page-user-list">
<a-card :title="title">
<div v-if="query && query.count && query.total">
{{ query.total }} رکورد یافت شد -
<span class="text-danger">{{ query.count }} رکورد غیر فعال</span>
</div>
<XTable ref="tbl" :model="model" @query="setCount">
<template #city="{ record }">
<div v-if="record.city && record.city.title">{{ record.city.title }}</div>
<div v-else>-</div>
</template>
<template #sts="{ record }">
{{ record.sts_str }}
</template>
<template #active="{ record }">
<a-tag :color="record.active === 1 ? 'green' : 'red'">
{{ record.active === 1 ? 'فعال' : 'غیر فعال' }}
</a-tag>
</template>
<template #opt="{ record }">
<div class="flex">
<a-dropdown>
<div class="cursor-pointer flex items-center">
<DownOutlined />
<SettingOutlined class="cursor-pointer w-5 ml-1" />
</div>
<template #overlay>
<a-menu @click="handleMenuClick($event, record.id)">
<a-menu-item
v-if="hasPermission('persons.list.client.omde.edit')"
key="1"
@click="toEdit(record.id)"
>
<div class="flex gap-2">
<EditOutlined class="cursor-pointer w-5 ml-1" />
<div>ویرایش</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view')"
key="2"
@click="toView(record.id)"
>
<div class="flex gap-2">
<EyeOutlined class="cursor-pointer w-5 ml-1" />
<div>مشاهده اطلاعات</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view.orders')"
key="3"
@click="viewOrder(record)"
>
<div class="flex gap-2">
<UnorderedListOutlined class="cursor-pointer w-5 ml-1" />
<div>مشاهده سفارش</div>
</div>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.view.documents')"
key="4"
@click="viewCash(record)"
>
<div class="flex gap-2">
<FileTextOutlined class="cursor-pointer w-5 ml-1" />
<div>سند های مالی</div>
</div>
</a-menu-item>
<a-menu-item v-if="hasPermission('persons.list.client.omde.able')" key="7">
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleArchive(record.id)"
>
<div class="flex gap-2">
<CloudDownloadOutlined class="cursor-pointer w-5 ml-1" />
<div>بایگانی کردن</div>
</div>
</a-popconfirm>
</a-menu-item>
<!-- step one -->
<a-menu-item key="8">
<a-popconfirm
:ok-text="record.sts_verify_step_one === 1 ? 'تایید' : 'بله'"
:cancel-text="record.sts_verify_step_one === 1 ? 'رد' : 'خیر'"
:title="
record.sts_verify_step_one === 1
? 'وضعیت تایید اولیه مشتری به چه نحوی تغییر یابد؟'
: 'آیا از تغییر وضعیت مشتری اطمینان دارید؟'
"
@confirm="onConfirmStepOne(record)"
@cancel="onCancelStepOne(record)"
>
<div class="flex gap-2">
<component
:is="stepIconComponent(getStepOneIcon(record))"
class="cursor-pointer w-5 ml-1"
:class="stepIconClass(getStepOneIcon(record))"
/>
<div>{{ getStepOneText(record) }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<!-- step two: unlocked only once step one is ACCEPTED (=== 2) -->
<a-menu-item :disabled="record.sts_verify_step_one !== 2" key="9">
<a-popconfirm
:ok-text="record.sts_verify_step_two === 1 ? 'تایید' : 'بله'"
:cancel-text="record.sts_verify_step_two === 1 ? 'رد' : 'خیر'"
:title="
record.sts_verify_step_two === 1
? 'وضعیت تایید سفارش مشتری به چه نحوی تغییر یابد؟'
: 'آیا از تغییر وضعیت سفارش مشتری اطمینان دارید؟'
"
@confirm="onConfirmStepTwo(record)"
@cancel="onCancelStepTwo(record)"
>
<div class="flex gap-2">
<component
:is="stepIconComponent(getStepTwoIcon(record))"
class="cursor-pointer w-5 ml-1"
:class="stepIconClass(getStepTwoIcon(record))"
/>
<div>{{ getStepTwoText(record) }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.able.order')"
:disabled="record.sts_verify_step_one !== 2"
key="5"
>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleSts(record)"
>
<div class="flex gap-2">
<component
:is="record.sts === 1 ? CheckOutlined : StopOutlined"
class="cursor-pointer w-5 ml-1"
:class="record.sts === 1 ? 'text-success' : 'text-danger'"
/>
<div>{{ record.sts === 1 ? 'معلق کردن سفارش' : 'فعال کردن سفارش' }}</div>
</div>
</a-popconfirm>
</a-menu-item>
<a-menu-item
v-if="hasPermission('persons.list.client.omde.able')"
:disabled="record.sts_verify_step_one !== 2"
key="6"
>
<a-popconfirm
ok-text="بله"
cancel-text="خیر"
title="آیا مطمئن هستید؟"
@confirm="toggleActive(record)"
>
<div class="flex gap-2">
<!--
NOTE legacy binds :icon="getStsIcon(record)" here while
colouring by getActiveIcon(record) — it shows the ORDER
status icon next to the ACCOUNT label. Preserved.
-->
<component
:is="record.sts === 1 ? CheckOutlined : StopOutlined"
class="cursor-pointer w-5 ml-1"
:class="record.active === 1 ? 'text-success' : 'text-danger'"
/>
<div>
{{ record.active === 1 ? 'معلق کردن حساب کاربری' : ' فعال کردن حساب کاربری' }}
</div>
</div>
</a-popconfirm>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</XTable>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import {
CheckOutlined,
CloudDownloadOutlined,
DownOutlined,
EditOutlined,
EyeOutlined,
FileTextOutlined,
QuestionCircleOutlined,
SettingOutlined,
StopOutlined,
UnorderedListOutlined,
} from '@ant-design/icons-vue';
import XTable from '../../../components/x-table/XTable.vue';
import { useUsersStore } from '../../../stores/users';
import api from '../../../api/client';
import type { XTbl } from '../../../components/x-table/model';
const props = withDefaults(
defineProps<{
/** Card heading — the only visible difference between four of the five lists. */
title: string;
/** The table definition (differs only in its url). */
model: XTbl;
/**
* OmdeNonVerifiedList's quirk: its popconfirm calls rejectStepOne() in BOTH
* the accept and reject branches, so the first step can never be approved
* from that screen. Faithfully reproduced behind this flag rather than
* silently "fixed" — see docs/ADMIN-PORT-PLAN.md.
*/
rejectOnlyStepOne?: boolean;
}>(),
{ rejectOnlyStepOne: false },
);
const router = useRouter();
const users = useUsersStore();
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
const query = ref<{ count?: number; total?: number } | undefined>(undefined);
/** legacy setCount(): the "N رکورد غیر فعال" banner above the grid */
function setCount(raw: unknown) {
const value = raw as { count?: number; total?: number } | undefined;
query.value = {
count: value && value.count ? value.count : undefined,
total: value && value.total ? value.total : undefined,
};
}
/**
* legacy handleMenuClick(): closes the dropdown for the keys that open a
* popconfirm, so the menu does not stay open behind the confirmation.
*/
function handleMenuClick(e: { key: string | number }, _id: string) {
const key = String(e.key);
return key === '5' || key === '6' || key === '7' || key === '8' || key === '9';
}
// ── verification step display, ported verbatim (1 pending / 2 accepted / 3 rejected)
function getStepOneText(row: Record<string, any>) {
switch (row.sts_verify_step_one) {
case 1:
return 'در انتظار تایید ورود مشتری';
case 2:
return 'رد ورود مشتری';
case 3:
return 'تایید ورود مشتری';
}
}
function getStepOneIcon(row: Record<string, any>) {
switch (row.sts_verify_step_one) {
case 1:
return 'HelpCircleIcon';
case 2:
return 'SlashIcon';
case 3:
return 'CheckIcon';
}
}
function getStepTwoText(row: Record<string, any>) {
switch (row.sts_verify_step_two) {
case 1:
return 'در انتظار فعال سازی';
case 2:
return 'غیر فعال سازی';
case 3:
return 'فعال سازی';
}
}
function getStepTwoIcon(row: Record<string, any>) {
switch (row.sts_verify_step_two) {
case 1:
return 'HelpCircleIcon';
case 2:
return 'SlashIcon';
case 3:
return 'CheckIcon';
}
}
/** feather name -> ant component, matching legacy's three icons */
function stepIconComponent(name?: string) {
if (name === 'HelpCircleIcon') return QuestionCircleOutlined;
if (name === 'CheckIcon') return CheckOutlined;
return StopOutlined; // SlashIcon
}
function stepIconClass(name?: string) {
if (name === 'HelpCircleIcon') return 'text-primary';
if (name === 'CheckIcon') return 'text-success';
return 'text-danger';
}
// ── actions
async function postVerify(payload: Record<string, unknown>, errorMsg: string) {
try {
await api.post('major/verify', payload);
message.info('تغییرات ثبت شد ');
tbl.value?.fetch();
} catch (e) {
message.error(errorMsg);
}
}
const acceptStepOne = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_one: 2 }, 'خطا در تغییر وضعیت اولیه مشتری');
const rejectStepOne = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_one: 3 }, 'خطا در تغییر وضعیت اولیه مشتری');
const acceptStepTwo = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_two: 2 }, 'خطا در تغییر وضعیت سفارش مشتری');
const rejectStepTwo = (id: string) =>
postVerify({ customer_id: id, sts_verify_step_two: 3 }, 'خطا در تغییر وضعیت سفارش مشتری');
/** legacy inline ternary on @confirm, preserved including the NonVerified quirk */
function onConfirmStepOne(record: Record<string, any>) {
if (record.sts_verify_step_one === 1) {
return props.rejectOnlyStepOne ? rejectStepOne(record.id) : acceptStepOne(record.id);
}
if (record.sts_verify_step_one === 2) return rejectStepOne(record.id);
return acceptStepOne(record.id);
}
function onCancelStepOne(record: Record<string, any>) {
if (record.sts_verify_step_one === 1) return rejectStepOne(record.id);
}
function onConfirmStepTwo(record: Record<string, any>) {
if (record.sts_verify_step_two === 1) return acceptStepTwo(record.id);
if (record.sts_verify_step_two === 2) return rejectStepTwo(record.id);
return acceptStepTwo(record.id);
}
function onCancelStepTwo(record: Record<string, any>) {
if (record.sts_verify_step_two === 1) return rejectStepTwo(record.id);
}
async function toggleActive(record: Record<string, any>) {
try {
await api.post('customer/toggle-active', { id: record.id });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
async function toggleSts(record: Record<string, any>) {
try {
// BankerList sent an explicit {id, sts} object while the others posted the
// whole row; the server reads only `id`, so one shape is used here.
await api.post('customer/toggle-sts', { id: record.id, sts: record.sts });
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در تغییر وضعیت');
}
}
async function toggleArchive(id: string) {
try {
await api.get(`major/archive/${id}`);
message.info('تغییرات ثبت شد ');
await tbl.value?.fetch();
} catch (e) {
message.error('خطا در بایگانی');
}
}
function toEdit(id: string) {
router.push(`/bulkCustomer/edit/${id}`);
}
function toView(id: string) {
router.push(`/bulkCustomer/view/${id}`);
}
function viewOrder(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=orders`);
}
function viewCash(record: Record<string, any>) {
router.push(`/bulkCustomer/view/${record.id}?tab=documents`);
}
</script>
@@ -0,0 +1,12 @@
<template>
<!-- Ported from legacy views/customer/omde/OmdeList.vue (615 lines). -->
<OmdeCustomerList :title="title" :model="model" />
</template>
<script setup lang="ts">
import OmdeCustomerList from './OmdeCustomerList.vue';
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
const title = OMDE_SOURCES.omde.title;
const model = buildOmdeListModel('omde');
</script>

Some files were not shown because too many files have changed in this diff Show More