feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -7,3 +7,5 @@ dist/
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
apps/api/generated/
|
apps/api/generated/
|
||||||
|
apps/api/storage/uploads/*
|
||||||
|
!apps/api/storage/uploads/.gitkeep
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fa" dir="rtl">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gold Bank — Admin Panel</title>
|
<title>هنزاگلد — پنل مدیریت</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,17 +4,30 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:branch": "vite --mode branch",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"build:branch": "vue-tsc -b && vite build --mode branch",
|
||||||
|
"preview": "vite preview",
|
||||||
"lint": "eslint \"src/**/*.{ts,vue}\""
|
"lint": "eslint \"src/**/*.{ts,vue}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons-vue": "^6.1.0",
|
||||||
"@gold-bank/shared-types": "workspace:*",
|
"@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": "^3.5.0",
|
||||||
"vue-router": "^4.4.0",
|
"vue-router": "^4.4.0"
|
||||||
"pinia": "^2.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "^4.17.0",
|
||||||
"@vitejs/plugin-vue": "^5.1.0",
|
"@vitejs/plugin-vue": "^5.1.0",
|
||||||
|
"autoprefixer": "^10.4.2",
|
||||||
|
"less": "^4.1.2",
|
||||||
|
"postcss": "^8.4.5",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.4.0",
|
"vite": "^5.4.0",
|
||||||
"vue-tsc": "^2.1.0"
|
"vue-tsc": "^2.1.0"
|
||||||
|
|||||||
@@ -1,23 +1,98 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<!--
|
||||||
<h2>Gold Bank — Admin</h2>
|
ConfigProvider supplies ant-design-vue's OWN strings and text direction.
|
||||||
<nav>
|
Without it ant rendered its built-in copy — pagination ("items/page"), table
|
||||||
<router-link to="/">Dashboard</router-link>
|
filter menus ("Reset"/"OK"), Select's "No Data", Popconfirm's default
|
||||||
</nav>
|
buttons — in ENGLISH inside an otherwise fully Farsi RTL panel.
|
||||||
</header>
|
|
||||||
<main>
|
NOTE this covers ant's own chrome only. Jalali dates still come from
|
||||||
<router-view />
|
moment-jalaali via components/input/DateInput.vue, which is unaffected.
|
||||||
</main>
|
-->
|
||||||
|
<a-config-provider :locale="faIR" direction="rtl">
|
||||||
|
<div id="app" :class="vueAppClasses">
|
||||||
|
<router-view @setAppClasses="setAppClasses" />
|
||||||
|
</div>
|
||||||
|
</a-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup lang="ts">
|
||||||
header {
|
// Ported from legacy henza_admin/src/App.vue.
|
||||||
padding: 1rem;
|
// Dropped (Vue-2-only or dead in legacy): the VeeValidate Farsi dictionary (`$validator`
|
||||||
border-bottom: 1px solid #ddd;
|
// 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>
|
||||||
|
|||||||
Vendored
-2
@@ -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,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 */
|
|
||||||
Vendored
-48
@@ -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>;
|
|
||||||
};
|
|
||||||
@@ -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 }) }),
|
|
||||||
};
|
|
||||||
@@ -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 headers: Record<string, string> = {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
'Content-Type': 'application/json',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
Accept: '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 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;
|
||||||
|
|||||||
@@ -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.
Binary file not shown.
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 |
@@ -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;
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -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');
|
|
||||||
@@ -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 { createApp } from 'vue';
|
||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
import App from './App.vue';
|
import Antd from 'ant-design-vue';
|
||||||
import { router } from './router';
|
|
||||||
|
|
||||||
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
@@ -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
@@ -1 +0,0 @@
|
|||||||
export declare const router: import("vue-router").Router;
|
|
||||||
@@ -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 },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,13 +1,128 @@
|
|||||||
|
// Ported from legacy henza_admin/src/router.js
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import DashboardView from '../views/DashboardView.vue';
|
import { adminRoute, fullPageAdminRoute } from './admin';
|
||||||
import OrderNewView from '../views/OrderNewView.vue';
|
import { branchRoute, fullPageBranchRoute } from './branch';
|
||||||
import OrderDetailView from '../views/OrderDetailView.vue';
|
import { isBranch, tokenName, userName } from '../utils';
|
||||||
|
import { useUsersStore } from '../stores/users';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
export const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
// legacy router.js:17-19
|
||||||
|
scrollBehavior() {
|
||||||
|
return { left: 0, top: 0 };
|
||||||
|
},
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', name: 'dashboard', component: DashboardView },
|
// ── MAIN LAYOUT ROUTES ──────────────────────────────────────────
|
||||||
{ path: '/orders/new', name: 'order-new', component: OrderNewView },
|
{
|
||||||
{ path: '/orders/:id', name: 'order-detail', component: OrderDetailView },
|
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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
@@ -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;
|
|
||||||
}>;
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// moment-jalaali ships no type declarations of its own.
|
||||||
|
declare module 'moment-jalaali' {
|
||||||
|
import moment from 'moment';
|
||||||
|
export default moment;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 */
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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
Reference in New Issue
Block a user