feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -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>
|
||||
<html lang="en">
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gold Bank — Admin Panel</title>
|
||||
<title>هنزاگلد — پنل مدیریت</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="app">
|
||||
<!-- Removed by router.afterEach, ported from legacy henza_admin/src/router.js:147-153 -->
|
||||
<div id="loading-bg"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,17 +4,30 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:branch": "vite --mode branch",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:branch": "vue-tsc -b && vite build --mode branch",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint \"src/**/*.{ts,vue}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^6.1.0",
|
||||
"@gold-bank/shared-types": "workspace:*",
|
||||
"@vueuse/core": "^9.13.0",
|
||||
"ant-design-vue": "^3.2.20",
|
||||
"axios": "^1.7.7",
|
||||
"lodash": "^4.17.21",
|
||||
"moment-jalaali": "^0.10.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"pinia": "^2.2.0"
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"less": "^4.1.2",
|
||||
"postcss": "^8.4.5",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
|
||||
@@ -1,23 +1,98 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<h2>Gold Bank — Admin</h2>
|
||||
<nav>
|
||||
<router-link to="/">Dashboard</router-link>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
<!--
|
||||
ConfigProvider supplies ant-design-vue's OWN strings and text direction.
|
||||
Without it ant rendered its built-in copy — pagination ("items/page"), table
|
||||
filter menus ("Reset"/"OK"), Select's "No Data", Popconfirm's default
|
||||
buttons — in ENGLISH inside an otherwise fully Farsi RTL panel.
|
||||
|
||||
NOTE this covers ant's own chrome only. Jalali dates still come from
|
||||
moment-jalaali via components/input/DateInput.vue, which is unaffected.
|
||||
-->
|
||||
<a-config-provider :locale="faIR" direction="rtl">
|
||||
<div id="app" :class="vueAppClasses">
|
||||
<router-view @setAppClasses="setAppClasses" />
|
||||
</div>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
<script setup lang="ts">
|
||||
// Ported from legacy henza_admin/src/App.vue.
|
||||
// Dropped (Vue-2-only or dead in legacy): the VeeValidate Farsi dictionary (`$validator`
|
||||
// has no Vue 3 equivalent and validation is re-done per-form with ant-design-vue rules),
|
||||
// the commented-out Firebase/service-worker block, and the `$vs.rtl` watcher (Vuesax).
|
||||
// RTL now comes from themeConfig.rtl directly, which is the same value legacy fed Vuesax.
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { notification } from 'ant-design-vue';
|
||||
import faIR from 'ant-design-vue/es/locale/fa_IR';
|
||||
import themeConfig from './themeConfig';
|
||||
import { useLayoutStore } from './stores/layout';
|
||||
import { userName } from './utils';
|
||||
|
||||
const layout = useLayoutStore();
|
||||
const vueAppClasses = ref<string[]>([]);
|
||||
|
||||
function toggleClassInBody(className: string) {
|
||||
if (className === 'dark') {
|
||||
if (document.body.className.match('theme-semi-dark')) document.body.classList.remove('theme-semi-dark');
|
||||
document.body.classList.add('theme-dark');
|
||||
} else if (className === 'semi-dark') {
|
||||
if (document.body.className.match('theme-dark')) document.body.classList.remove('theme-dark');
|
||||
document.body.classList.add('theme-semi-dark');
|
||||
} else {
|
||||
if (document.body.className.match('theme-dark')) document.body.classList.remove('theme-dark');
|
||||
if (document.body.className.match('theme-semi-dark')) document.body.classList.remove('theme-semi-dark');
|
||||
}
|
||||
}
|
||||
main {
|
||||
padding: 1rem;
|
||||
|
||||
function setAppClasses(classesStr: string) {
|
||||
vueAppClasses.value.push(classesStr);
|
||||
}
|
||||
</style>
|
||||
|
||||
function handleWindowResize() {
|
||||
layout.updateWindowWidth(window.innerWidth);
|
||||
// Set --vh property
|
||||
document.documentElement.style.setProperty('--vh', `${window.innerHeight * 0.01}px`);
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
layout.updateWindowScrollY(window.scrollY);
|
||||
}
|
||||
|
||||
watch(() => layout.theme, (val) => toggleClassInBody(val));
|
||||
|
||||
// legacy created(): set dir before first paint
|
||||
document.documentElement.setAttribute('dir', themeConfig.rtl ? 'rtl' : 'ltr');
|
||||
|
||||
onMounted(() => {
|
||||
// legacy showed a one-shot welcome toast when a `login` flag was left in localStorage
|
||||
const rawUser = localStorage.getItem(userName);
|
||||
const user = rawUser ? JSON.parse(rawUser) : undefined;
|
||||
const login = localStorage.getItem('login') || undefined;
|
||||
if (user && login) {
|
||||
notification.open({
|
||||
message: `${user && user.name ? user.name : ' کاربر '} عزیز `,
|
||||
placement: 'topLeft',
|
||||
description: 'به هنزاگلد خوش آمدید',
|
||||
style: {
|
||||
color: 'var(--primary-color)',
|
||||
marginLeft: `20px`,
|
||||
},
|
||||
});
|
||||
localStorage.removeItem('login');
|
||||
}
|
||||
|
||||
toggleClassInBody(themeConfig.theme);
|
||||
layout.updateWindowWidth(window.innerWidth);
|
||||
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
});
|
||||
</script>
|
||||
|
||||
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 res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`API ${path} failed (${res.status}): ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface CustomerSummary {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
goldDebtGrams: string;
|
||||
fiatDebtRial: string;
|
||||
goldCreditLimitGrams: string;
|
||||
}
|
||||
|
||||
export interface ProductVariantSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
rashWeightGrams: string;
|
||||
defaultWagePercent: string;
|
||||
}
|
||||
|
||||
export interface ProductSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
variants: ProductVariantSummary[];
|
||||
}
|
||||
|
||||
export interface OrderDetail {
|
||||
id: string;
|
||||
orderNumber: string;
|
||||
status: string;
|
||||
totalGoldWeightGrams: string;
|
||||
wageWeightGrams: string;
|
||||
flatRialCharge: string;
|
||||
isSuspendedOverCreditLimit: boolean;
|
||||
deliveredAt: string | null;
|
||||
items: Array<{ id: string; quantity: number; computedWeightGrams: string }>;
|
||||
statusEvents: Array<{ toStatus: string; note: string | null; createdAt: string }>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listCustomers: () => request<CustomerSummary[]>('/customers'),
|
||||
getCustomerBalance: (id: string) => request<CustomerBalanceDto>(`/accounting/customers/${id}/balance`),
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
createOrder: (dto: CreateOrderDto) =>
|
||||
request<OrderDetail>('/orders', { method: 'POST', body: JSON.stringify(dto) }),
|
||||
getOrder: (id: string) => request<OrderDetail>(`/orders/${id}`),
|
||||
updateOrderStatus: (id: string, status: string, note?: string) =>
|
||||
request<OrderDetail>(`/orders/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status, note }) }),
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
const initialToken = localStorage.getItem(tokenName);
|
||||
if (initialToken) {
|
||||
headers['Authorization'] = 'Bearer ' + initialToken;
|
||||
}
|
||||
|
||||
const ax = axios.create({
|
||||
baseURL,
|
||||
headers,
|
||||
});
|
||||
|
||||
/**
|
||||
* Legacy re-set the auth header on every request so a token acquired after boot
|
||||
* was picked up without recreating the instance. Kept — plus the endpoint
|
||||
* rewrite, and without legacy's habit of sending a literal "Bearer null" when
|
||||
* no token existed.
|
||||
*/
|
||||
ax.interceptors.request.use(
|
||||
(config) => {
|
||||
if (config.url) {
|
||||
config.url = mapEndpoint(config.url);
|
||||
}
|
||||
const token = localStorage.getItem(tokenName);
|
||||
if (token) {
|
||||
config.headers['Authorization'] = 'Bearer ' + token;
|
||||
} else {
|
||||
delete config.headers['Authorization'];
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
/**
|
||||
* NOT IN LEGACY: legacy had no response interceptor at all, so an expired token
|
||||
* produced a silent failure on every subsequent call with no path back to login.
|
||||
* A 401 now clears the stale session and redirects once.
|
||||
*
|
||||
* The redirect is a hard location assignment rather than a router push, to avoid
|
||||
* importing the router here (circular: router -> stores -> client -> router).
|
||||
*/
|
||||
let redirecting = false;
|
||||
ax.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error?.response?.status === 401 && !redirecting) {
|
||||
const onLoginPage = window.location.pathname.endsWith('/auth/login');
|
||||
if (!onLoginPage) {
|
||||
redirecting = true;
|
||||
localStorage.removeItem(tokenName);
|
||||
window.location.assign(`${import.meta.env.BASE_URL}auth/login`);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default ax;
|
||||
|
||||
@@ -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 { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import Antd from 'ant-design-vue';
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app');
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/styles/main.less';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(Antd);
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 DashboardView from '../views/DashboardView.vue';
|
||||
import OrderNewView from '../views/OrderNewView.vue';
|
||||
import OrderDetailView from '../views/OrderDetailView.vue';
|
||||
import { adminRoute, fullPageAdminRoute } from './admin';
|
||||
import { branchRoute, fullPageBranchRoute } from './branch';
|
||||
import { isBranch, tokenName, userName } from '../utils';
|
||||
import { useUsersStore } from '../stores/users';
|
||||
import api from '../api/client';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
// legacy router.js:17-19
|
||||
scrollBehavior() {
|
||||
return { left: 0, top: 0 };
|
||||
},
|
||||
routes: [
|
||||
{ path: '/', name: 'dashboard', component: DashboardView },
|
||||
{ path: '/orders/new', name: 'order-new', component: OrderNewView },
|
||||
{ path: '/orders/:id', name: 'order-detail', component: OrderDetailView },
|
||||
// ── MAIN LAYOUT ROUTES ──────────────────────────────────────────
|
||||
{
|
||||
path: '',
|
||||
component: () => import('../layouts/main/Main.vue'),
|
||||
children: isBranch() ? branchRoute : adminRoute,
|
||||
},
|
||||
// ── FULL PAGE LAYOUTS ───────────────────────────────────────────
|
||||
{
|
||||
path: '',
|
||||
component: () => import('../layouts/full-page/FullPage.vue'),
|
||||
children: [
|
||||
...(isBranch() ? fullPageBranchRoute : fullPageAdminRoute),
|
||||
{
|
||||
path: '/pages/error-404',
|
||||
name: 'page-error-404',
|
||||
component: () => import('../views/pages/Error404.vue'),
|
||||
meta: { rule: 'editor' },
|
||||
},
|
||||
{
|
||||
path: '/pages/error-500',
|
||||
name: 'page-error-500',
|
||||
component: () => import('../views/pages/Error500.vue'),
|
||||
meta: { rule: 'editor' },
|
||||
},
|
||||
{
|
||||
path: '/pages/not-authorized',
|
||||
name: 'page-not-authorized',
|
||||
component: () => import('../views/pages/NotAuthorized.vue'),
|
||||
meta: { rule: 'editor' },
|
||||
},
|
||||
{
|
||||
path: '/pages/maintenance',
|
||||
name: 'page-maintenance',
|
||||
component: () => import('../views/pages/Maintenance.vue'),
|
||||
meta: { rule: 'editor' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// Redirect to 404 page, if no match found
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/pages/error-404',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// legacy router.js:147-153
|
||||
router.afterEach(() => {
|
||||
const appLoading = document.getElementById('loading-bg');
|
||||
if (appLoading) {
|
||||
appLoading.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// legacy router.js:156-161
|
||||
const noneAuthUrl = ['/auth/login', '/auth/forgot-password', '/auth/register', '/reg'];
|
||||
|
||||
/**
|
||||
* Port of legacy getPermission() (router.js:163-182).
|
||||
*
|
||||
* Legacy cached the result in a MODULE-SCOPED `let roles`, which was never reset
|
||||
* on logout — so the next user to sign in on the same tab inherited the previous
|
||||
* user's permissions (bug #2 in docs/ADMIN-PORT-PLAN.md). Here the cache is the
|
||||
* Pinia users store, which auth.logout() clears; `roles === undefined` still
|
||||
* means "not fetched", exactly as legacy's check did.
|
||||
*/
|
||||
async function getPermission(): Promise<string[] | undefined> {
|
||||
const users = useUsersStore();
|
||||
if (users.roles) return users.roles;
|
||||
|
||||
try {
|
||||
const { data } = await api.get('users/show');
|
||||
users.getRoles(data.permission);
|
||||
// Legacy stripped these before caching the user blob.
|
||||
delete data.permission;
|
||||
delete data.ut;
|
||||
if (data.api_token) {
|
||||
localStorage.setItem(tokenName, data.api_token);
|
||||
}
|
||||
localStorage.setItem(userName, JSON.stringify(data));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
return users.roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of legacy router.beforeEach (router.js:184-199).
|
||||
*
|
||||
* One deliberate change: legacy's final line was a bare
|
||||
* router.push({path: '/auth/login'})
|
||||
* with no next() call, which in Vue Router 4 leaves the navigation permanently
|
||||
* pending and hangs the app (bug #1). Returning the redirect is the Vue Router 4
|
||||
* equivalent and produces the same user-visible result.
|
||||
*/
|
||||
router.beforeEach(async (to) => {
|
||||
if (noneAuthUrl.indexOf(to.path) > -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem(tokenName);
|
||||
if (token) {
|
||||
const roles = await getPermission();
|
||||
if (roles && to.meta && to.meta.role && !roles.includes(to.meta.role as string)) {
|
||||
return { name: 'page-not-authorized' };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return { path: '/auth/login' };
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<!-- Ported from legacy views/customer/omde/OmdeNonVerifiedList.vue. Carries the legacy quirk where step-one confirm rejects instead of accepting. -->
|
||||
<OmdeCustomerList :title="title" :model="model" reject-only-step-one />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import OmdeCustomerList from './OmdeCustomerList.vue';
|
||||
import { buildOmdeListModel, OMDE_SOURCES } from './omdeListModel';
|
||||
|
||||
const title = OMDE_SOURCES.nonVerified.title;
|
||||
const model = buildOmdeListModel('nonVerified');
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user