Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<!--
|
||||
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.
|
||||
|
||||
`:theme` replaces the old vite.config.ts LESS `modifyVars` (ant-design-vue 4
|
||||
dropped LESS theming for CSS-in-JS design tokens). Only the three legacy
|
||||
variables that ant itself ever consumed are carried over — `secondary-color`
|
||||
and `third-color` were never real ant variables (confirmed dead in the old
|
||||
LESS build) and stay only as the CSS custom properties in utilities.less.
|
||||
|
||||
ant derives its light "primary background" steps from colorPrimary; for a
|
||||
navy this dark the generated steps are mid-greys (#8d9296, #81858a), which
|
||||
painted every selected option / tree node / picker cell grey. Those steps
|
||||
are pinned to light navy tints instead (see `themeTokens`).
|
||||
-->
|
||||
<a-config-provider :locale="faIR" direction="rtl" :theme="{ token: themeTokens }">
|
||||
<div id="app" :class="vueAppClasses">
|
||||
<router-view @setAppClasses="setAppClasses" />
|
||||
<AppUpdateGate />
|
||||
</div>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<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 { brand } from './brand';
|
||||
import { useLayoutStore } from './stores/layout';
|
||||
import AppUpdateGate from './components/common/AppUpdateGate.vue';
|
||||
import { userName } from './utils';
|
||||
|
||||
const themeTokens = {
|
||||
colorPrimary: '#1c2d56',
|
||||
colorLink: '#1DA57A',
|
||||
borderRadius: 6,
|
||||
controlHeight: 36,
|
||||
colorPrimaryBg: '#eef1f8',
|
||||
colorPrimaryBgHover: '#e1e6f2',
|
||||
colorPrimaryBorder: '#c5cde0',
|
||||
colorPrimaryBorderHover: '#9ea9c6',
|
||||
};
|
||||
|
||||
const layout = useLayoutStore();
|
||||
const vueAppClasses = ref<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');
|
||||
}
|
||||
}
|
||||
|
||||
function setAppClasses(classesStr: string) {
|
||||
vueAppClasses.value.push(classesStr);
|
||||
}
|
||||
|
||||
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: `به ${brand.name} خوش آمدید`,
|
||||
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>
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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';
|
||||
|
||||
export const baseURL = import.meta.env.VITE_API_BASE_URL ?? '/api/';
|
||||
|
||||
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'];
|
||||
}
|
||||
// The instance-level default Content-Type is 'application/json', and axios
|
||||
// only auto-sets multipart/form-data + boundary when no Content-Type is
|
||||
// already present — an existing default is NOT replaced. Posting a
|
||||
// FormData body (file uploads) with the JSON default left in place sends
|
||||
// the raw FormData object as a JSON-labelled body, so multer/NestJS's
|
||||
// FileInterceptor sees no file at all and 400s. Let axios/the browser set
|
||||
// the correct multipart boundary for FormData bodies instead.
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type'];
|
||||
}
|
||||
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,276 @@
|
||||
/**
|
||||
* 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.
|
||||
// FIXED: this used to alias to the same admin/customers-omde/create as the
|
||||
// wholesale form, which has no notion of "retail" and defaults kind to
|
||||
// APP_SHOPKEEPER — every "تک" signup was silently created as a wholesale
|
||||
// app-shopkeeper row, which is why "لیست مشتریان تک" never showed genuinely
|
||||
// retail data even after that list gained a kind filter. Routed at its own
|
||||
// endpoint now, which sets kind=RETAIL explicitly.
|
||||
single: 'admin/customers-omde/single',
|
||||
'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',
|
||||
// The two legacy dashboards are merged into one screen; both old names
|
||||
// keep working (aliased server-side) but the new dashboard calls this.
|
||||
'get-analytics': 'admin/dashboard/omde',
|
||||
'get-analytics-tak': 'admin/dashboard/tak',
|
||||
'dashboard/overview': 'admin/dashboard/overview',
|
||||
'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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,105 @@
|
||||
// Ported from legacy henza_admin/src/assets/scss/ara/farsi.scss
|
||||
// Converted .scss -> .less (no Sass features were used; this file is plain CSS).
|
||||
//
|
||||
// CSS bugs fixed here (approved departure from byte-faithful porting — see
|
||||
// docs/ADMIN-PORT-PLAN.md rule 2; listed rather than silently applied):
|
||||
// 1. FIXED: the bold/700 @font-face blocks pointed at the SAME
|
||||
// iran-yekan-400.woff2 file even though iran-yekan-700.woff2 exists, so
|
||||
// bold text rendered in the regular weight. They now load the 700 file.
|
||||
// There is no source file for weight 300 (only 400/700 exist on disk),
|
||||
// so that face is dropped rather than mapped to another weight.
|
||||
// 2. FIXED: `format("woff")` was declared for a .woff2 file (wrong format
|
||||
// hint). Corrected to format("woff2").
|
||||
// 3. FIXED: the `body, *` reset set `font-weight: inherit`, which flattened
|
||||
// every heading's weight (h1..h6 rendered at their parent's weight
|
||||
// instead of bold). Removed; browsers' default heading weights apply.
|
||||
|
||||
@font-face {
|
||||
font-family: 'p-yek';
|
||||
src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'p-yek';
|
||||
src: url("../fonts/yekan/iran-yekan-700.woff2") format("woff2");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'p-yek';
|
||||
src: url("../fonts/yekan/iran-yekan-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'p-yek';
|
||||
src: url("../fonts/yekan/iran-yekan-400.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
// IRANSans — shared with the customer app (assets/fonts/iransans/*).
|
||||
// Installed as the panel's primary typeface; 'p-yek' (above) stays loaded as
|
||||
// the fallback in the body rule below rather than being removed, in case
|
||||
// anything still depends on its metrics.
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_UltraLight.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_UltraLight.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Light.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Light.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Medium.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Medium.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Bold.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Bold.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'iransans';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
src: url('../fonts/iransans/IRANSansWeb_FaNum_Black.woff2') format('woff2'),
|
||||
url('../fonts/iransans/IRANSansWeb_FaNum_Black.woff') format('woff');
|
||||
}
|
||||
|
||||
body, * {
|
||||
font-family: iransans, p-yek, sans-serif, tahoma;
|
||||
letter-spacing: 0px !important;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.ara-icon-button {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// ── Add/edit page layout ─────────────────────────────────────────────
|
||||
// Shared by the redesigned create/edit screens (AddProduct, AddCustomer and
|
||||
// its forms): page header, main/side columns, section cards and a sticky
|
||||
// action bar. Layout only — ant components keep their own design-system look.
|
||||
|
||||
@pf-primary: #1c2d56;
|
||||
@pf-ink: #101828;
|
||||
@pf-muted: #8a94a6;
|
||||
@pf-line: #eef0f5;
|
||||
|
||||
.pf-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* header */
|
||||
.pf-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.pf-head__back {
|
||||
flex-shrink: 0;
|
||||
border-color: @pf-line;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
.pf-head__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: @pf-ink;
|
||||
}
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: @pf-muted;
|
||||
}
|
||||
}
|
||||
.pf-head__status {
|
||||
margin: 0;
|
||||
padding: 2px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* layout */
|
||||
.pf-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.pf-layout--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 860px;
|
||||
}
|
||||
.pf-main,
|
||||
.pf-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.pf-side {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* section cards */
|
||||
.pf-section {
|
||||
background: #fff;
|
||||
border: 1px solid @pf-line;
|
||||
border-radius: 18px;
|
||||
padding: 22px;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 8px 24px -14px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
.pf-section__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
> div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: @pf-ink;
|
||||
}
|
||||
p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: @pf-muted;
|
||||
}
|
||||
}
|
||||
.pf-section__icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
font-size: 16px;
|
||||
color: @pf-primary;
|
||||
background: rgba(28, 45, 86, 0.07);
|
||||
}
|
||||
.pf-section__action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pf-alert {
|
||||
margin-bottom: 18px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.pf-divider {
|
||||
height: 1px;
|
||||
margin: 4px 0 20px;
|
||||
background: @pf-line;
|
||||
}
|
||||
.pf-hint {
|
||||
margin: -10px 0 0;
|
||||
font-size: 12px;
|
||||
color: @pf-muted;
|
||||
}
|
||||
|
||||
/* form controls */
|
||||
.pf-form .ant-form-item.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
// numbers/phones typed left-to-right but kept right-aligned like their labels
|
||||
.pf-form .ltr-input,
|
||||
.pf-form .ltr-input input {
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
.pf-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
.pf-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
.pf-span-all {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* upload tiles inside sections */
|
||||
.pf-uploads {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* in-section actions (send forms that share the page with a list) */
|
||||
.pf-inline-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* sticky action bar (route pages; sits on #content-area's 16px padding) */
|
||||
.pf-actions {
|
||||
position: sticky;
|
||||
bottom: -16px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin: 20px -16px -16px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid @pf-line;
|
||||
box-shadow: 0 -8px 24px -16px rgba(16, 24, 40, 0.18);
|
||||
.ant-btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
.pf-actions__hint {
|
||||
margin-inline-end: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: @pf-muted;
|
||||
}
|
||||
|
||||
/* responsive */
|
||||
@media (max-width: 1199px) {
|
||||
.pf-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.pf-side {
|
||||
position: static;
|
||||
order: -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
> .pf-section:first-child:last-child,
|
||||
> .pf-section.pf-span-all {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.pf-side {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.pf-actions__hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 575px) {
|
||||
.pf-head {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.pf-head__text {
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
p {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.pf-main,
|
||||
.pf-side,
|
||||
.pf-layout {
|
||||
gap: 12px;
|
||||
}
|
||||
.pf-section {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.pf-section__head {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pf-section__action {
|
||||
width: 100%;
|
||||
}
|
||||
.pf-grid-2,
|
||||
.pf-grid-3 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
// stacked: a last-row item is no longer last
|
||||
> .ant-form-item.mb-0:not(:last-child) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
.pf-actions {
|
||||
gap: 8px;
|
||||
.ant-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-submit {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Style entry for admin-panel.
|
||||
//
|
||||
// ant-design-vue 4 removed its LESS theme entirely (CSS-in-JS design tokens
|
||||
// replace modifyVars) — `ant-design-vue/dist/antd.less` no longer exists.
|
||||
// Each COMPONENT's styles are injected at runtime by the library, but the
|
||||
// global BASELINE is not: ADV4 ships it separately as dist/reset.css and
|
||||
// requires you to import it yourself. It carries box-sizing, the body margin
|
||||
// reset and — critically — the list-style reset the menu depends on. Without
|
||||
// it the sidebar renders as raw <ul> bullets and the whole panel stacks
|
||||
// vertically, so this import MUST come first.
|
||||
// Primary/link/radius tokens live in App.vue's <a-config-provider :theme>.
|
||||
//
|
||||
// `ara/ant.less` (426 lines of hand-written RTL flips for float/margin/
|
||||
// transform) is gone: ADV4 renders RTL natively via CSS logical properties
|
||||
// when direction="rtl" is set, which App.vue already does. The small residue
|
||||
// ADV4 does not cover lives in ./rtl-overrides.css.
|
||||
//
|
||||
// Order:
|
||||
// 1. ant-design-vue reset -> the global baseline ADV4 no longer auto-injects
|
||||
// 2. farsi.less -> font faces + global reset
|
||||
// 3. rtl-overrides.css -> the few RTL rules ADV4 doesn't provide natively
|
||||
// 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 'ant-design-vue/dist/reset.css';
|
||||
@import './farsi.less';
|
||||
@import './rtl-overrides.css';
|
||||
@import './utilities.less';
|
||||
@import './form-page.less';
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Residue from the legacy ant.less (426 lines, deleted when ant-design-vue was
|
||||
* upgraded to v4). ADV4 renders RTL natively via CSS logical properties given
|
||||
* App.vue's direction="rtl", which covers nearly everything ant.less used to
|
||||
* hand-fix (pagination arrows, table alignment, column floats, menu padding,
|
||||
* steps, tags, radio margins, input-group radii, etc).
|
||||
*
|
||||
* The handful of rules below are NOT direction-driven ant behaviour — they are
|
||||
* app-specific fixes that stay regardless of LTR/RTL — so they still apply.
|
||||
*/
|
||||
|
||||
/* Uploaded file names are Latin filenames; keep them LTR even in an RTL panel. */
|
||||
.ant-upload-list-item-name {
|
||||
font-family: Arial, sans-serif !important;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* App-specific stacking fix: dropdowns must sit above other floating layers
|
||||
used elsewhere in the panel (image previews, modals). */
|
||||
.ant-select-dropdown {
|
||||
z-index: 2500000;
|
||||
}
|
||||
|
||||
/*
|
||||
* Global light selected/active states — soft primary tint, never grey/dark.
|
||||
* Wins over ant CSS-in-JS when specificity fails on nested menus / portals.
|
||||
* Keep in sync with App.vue `antTheme` tokens.
|
||||
*/
|
||||
:root {
|
||||
--selected-bg: rgba(28, 45, 86, 0.08);
|
||||
--selected-bg-hover: rgba(28, 45, 86, 0.12);
|
||||
--hover-bg: rgba(28, 45, 86, 0.04);
|
||||
}
|
||||
|
||||
.ant-menu-light .ant-menu-item-selected,
|
||||
.ant-menu-light .ant-menu-item-selected:hover,
|
||||
.ant-menu-inline .ant-menu-item-selected,
|
||||
.ant-menu-inline .ant-menu-item-selected:hover,
|
||||
.ant-menu-submenu .ant-menu-item-selected,
|
||||
.ant-menu-submenu .ant-menu-item-selected:hover,
|
||||
.vertical-nav-menu .ant-menu-item-selected,
|
||||
.vertical-nav-menu .ant-menu-item-selected:hover,
|
||||
.vertical-nav-menu .ant-menu-sub .ant-menu-item-selected,
|
||||
.vertical-nav-menu .ant-menu-sub .ant-menu-item-selected:hover {
|
||||
color: var(--primary-color, #1c2d56) !important;
|
||||
background: var(--selected-bg) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-menu-light .ant-menu-item-selected .anticon,
|
||||
.ant-menu-inline .ant-menu-item-selected .anticon,
|
||||
.vertical-nav-menu .ant-menu-item-selected .anticon {
|
||||
color: var(--primary-color, #1c2d56) !important;
|
||||
}
|
||||
|
||||
.ant-menu-light .ant-menu-item-selected::after,
|
||||
.ant-menu-inline .ant-menu-item-selected::after,
|
||||
.vertical-nav-menu .ant-menu-item-selected::after {
|
||||
display: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Select / Cascader / TreeSelect / Transfer / Mentions / Dropdown */
|
||||
.ant-select-item-option-selected:not(.ant-select-item-option-disabled),
|
||||
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
|
||||
color: var(--primary-color, #1c2d56) !important;
|
||||
}
|
||||
|
||||
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
|
||||
background: var(--selected-bg) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-select-item-option-active:not(.ant-select-item-option-disabled):not(
|
||||
.ant-select-item-option-selected
|
||||
) {
|
||||
background: var(--hover-bg) !important;
|
||||
}
|
||||
|
||||
.ant-cascader-menu-item-active:not(.ant-cascader-menu-item-disabled),
|
||||
.ant-tree-treenode-selected .ant-tree-node-content-wrapper,
|
||||
.ant-tree-treenode-selected .ant-tree-node-content-wrapper:hover,
|
||||
.ant-dropdown-menu-item-selected,
|
||||
.ant-dropdown-menu-submenu-title-selected,
|
||||
.ant-transfer-list-content-item-checked,
|
||||
.ant-mentions-dropdown-menu-item-active {
|
||||
color: var(--primary-color, #1c2d56) !important;
|
||||
background: var(--selected-bg) !important;
|
||||
}
|
||||
|
||||
/* REMOVED: an `.ant-tabs-nav { direction: ltr }` override used to live here,
|
||||
forcing the tab strip itself to lay out left-to-right while each tab's
|
||||
content stayed RTL. That fought ADV4's own RTL handling (ConfigProvider's
|
||||
`direction="rtl"` in App.vue) instead of matching it: the first-defined tab
|
||||
pane (e.g. "برند ها" in manage/Const.vue) rendered at the visual LEFT edge
|
||||
instead of the RIGHT edge a Farsi reader expects tab order to start from.
|
||||
ADV4 already reorders tabs correctly for RTL on its own — this override was
|
||||
silently undoing that, so it is simply deleted rather than fixed. */
|
||||
@@ -0,0 +1,213 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 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. `--primary-color` is also
|
||||
// fed to ant itself via App.vue's <a-config-provider :theme> (ant-design-vue 4
|
||||
// replaced LESS modifyVars with CSS-in-JS design tokens); declaring it here as
|
||||
// a custom property keeps it reachable from plain CSS too (App.vue reads it).
|
||||
//
|
||||
// `--secondary-color` / `--third-color` were never real ant LESS variables
|
||||
// (confirmed dead in the old build — ant never consumed them), so they exist
|
||||
// only here for application code to use directly; they are not passed to
|
||||
// ConfigProvider.
|
||||
//
|
||||
// The old ant.less also declared `--primary: #41b883` / `--accent: #35495e` —
|
||||
// ant-design-pro boilerplate in Vue's own palette, inherited verbatim from
|
||||
// legacy and used as `@border-color-base`. That file is deleted (ADV4 has no
|
||||
// LESS build to feed); nothing in the app should reference `--primary`/
|
||||
// `--accent` any more — use the brand tokens below instead.
|
||||
: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;
|
||||
// Softened twice per request: the original 25px/10% shadow read as heavy
|
||||
// on every card in the panel (54 sites); this is now barely-there —
|
||||
// a faint depth cue rather than a visible shadow.
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
// ── Full-height list pages ──────────────────────────────────────────
|
||||
// Main.vue stretches #content-area/.content-area__content to fill the
|
||||
// viewport below the navbar; the routed page's own root needs to pick that
|
||||
// up so a short table doesn't leave a gray gap under its card. Most list
|
||||
// pages render <div id="page-user-list"><a-card>...</a-card></div>, a few
|
||||
// (e.g. FacilityList.vue) render <a-card> directly — both are covered here.
|
||||
.content-area__content > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
#page-user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#page-user-list > .ant-card,
|
||||
.content-area__content > .ant-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// ── Phone page chrome ────────────────────────────────────────────────
|
||||
// Opt-in per page (class="page-card" on the page's root card) as each page is
|
||||
// made responsive. Base-form modals get the same treatment for their body.
|
||||
@media (max-width: 575px) {
|
||||
.page-card,
|
||||
.base-form-modal .ant-modal-body {
|
||||
.ant-card-head {
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.ant-card-head-title {
|
||||
font-size: 14px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.ant-card-extra {
|
||||
padding: 6px 0;
|
||||
}
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
.mb-base {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.grid-2,
|
||||
.grid-3,
|
||||
.grid-4 {
|
||||
row-gap: 0;
|
||||
column-gap: 12px;
|
||||
}
|
||||
.ant-tabs-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-submit {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
// date-range filter rows on report pages: the two dates side by side, the
|
||||
// action buttons on their own row without the blank spacer label
|
||||
.filter-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
.ant-form-item:last-child {
|
||||
grid-column: 1 / -1;
|
||||
.ant-form-item-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
// a nested section card inside the page card: drop the double frame
|
||||
.page-card .ant-card .ant-card,
|
||||
.page-card > .ant-card-body .ant-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
// a horizontally scrollable plain <a-table> (class="table-scroll" on it or
|
||||
// on any ancestor)
|
||||
.table-scroll .ant-table-content,
|
||||
.detail-modal .ant-table-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table-scroll .ant-table-content > table,
|
||||
.detail-modal .ant-table-content > table {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
// View/status modals (wrap-class-name="detail-modal"): near full width on
|
||||
// phones instead of a percentage of an already narrow screen.
|
||||
.detail-modal .ant-modal {
|
||||
width: calc(100vw - 16px) !important;
|
||||
max-width: none;
|
||||
top: 8px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.detail-modal .ant-modal-content {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
.detail-modal .ant-card-head {
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.detail-modal .ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
.detail-modal .ant-descriptions-item {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// The brand lives in the repo-root brand.ts, shared by both frontends.
|
||||
export { brand } from '../../../brand';
|
||||
@@ -0,0 +1,742 @@
|
||||
<template>
|
||||
<!--
|
||||
«اتصال» tab of the accounting screen (D27), redesigned 2026-10-06.
|
||||
|
||||
One question per step, only what the chosen software needs:
|
||||
status → 1. which software → 2. how to reach it → 3. its certificate
|
||||
(only for https, shown as a fact, never as raw PEM) → test and save.
|
||||
The form object is the page's own (save sends the whole configuration);
|
||||
this component only edits the connection part of it.
|
||||
-->
|
||||
<div class="conn">
|
||||
<!-- ── status ─────────────────────────────────────────────── -->
|
||||
<div class="status" :class="`status--${status.tone}`">
|
||||
<component :is="status.icon" class="status__icon" />
|
||||
<div class="status__body">
|
||||
<div class="status__title">
|
||||
{{ status.title }}
|
||||
<a-tag v-if="dirty" color="orange" class="status__tag">تغییرات ذخیره نشده</a-tag>
|
||||
</div>
|
||||
<div v-if="status.text" class="status__text">{{ status.text }}</div>
|
||||
<dl v-if="status.rows?.length" class="kv status__rows">
|
||||
<template v-for="row in status.rows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd :dir="row.ltr ? 'ltr' : undefined" :class="{ latin: row.ltr }">{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 1. software ────────────────────────────────────────── -->
|
||||
<section class="step">
|
||||
<h3 class="step__title"><span class="step__no">۱</span>نرمافزار حسابداری</h3>
|
||||
<div class="choices" role="radiogroup">
|
||||
<button
|
||||
v-for="c in CHOICES"
|
||||
:key="c.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="form.provider === c.value"
|
||||
class="choice"
|
||||
:class="{ 'choice--on': form.provider === c.value }"
|
||||
@click="form.provider = c.value"
|
||||
>
|
||||
<span class="choice__dot" />
|
||||
<span class="choice__name">
|
||||
{{ c.label }}
|
||||
</span>
|
||||
<span class="choice__desc">{{ c.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="providerChanged"
|
||||
class="mt"
|
||||
type="info"
|
||||
show-icon
|
||||
message="با ذخیره، نرمافزار عوض میشود"
|
||||
description="کارهای در صف نرمافزار قبلی متوقف میمانند (پاک نمیشوند) و لینکهای هر نرمافزار جدا نگه داشته میشوند؛ برگشت بیخطر است."
|
||||
/>
|
||||
</section>
|
||||
|
||||
<template v-if="p">
|
||||
<!-- ── 2. connection details ──────────────────────────────── -->
|
||||
<section class="step">
|
||||
<h3 class="step__title"><span class="step__no">۲</span>اطلاعات اتصال به {{ LABEL[p] }}</h3>
|
||||
<a-form layout="vertical" class="grid">
|
||||
<a-form-item label="آدرس وبسرویس" required>
|
||||
<a-input v-model:value="form[p].baseUrl" dir="ltr" class="ltr" :placeholder="p === 'tahesab' ? 'https://192.168.1.10:8081' : 'http://192.168.1.10:8000/ZarrinService/accounting'" />
|
||||
<div class="help">آدرسی که وبسرویس {{ LABEL[p] }} روی آن در دسترس است، با پورت.</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="p === 'tahesab'" label="نام دیتابیس (DBname)" required>
|
||||
<a-input v-model:value="form.tahesab.dbName" dir="ltr" class="ltr" placeholder="TahesabDB" />
|
||||
<div class="help">نام دقیق دیتابیس همین شرکت. یک سرور ممکن است چند دیتابیس داشته باشد؛ بعد از تست، نام را در نتیجه چک کنید.</div>
|
||||
</a-form-item>
|
||||
<a-form-item v-else label="علامت ماندهی بدهکار">
|
||||
<a-select v-model:value="form.zarrin.balanceOwesWhen">
|
||||
<a-select-option value="negative">منفی یعنی بدهکار</a-select-option>
|
||||
<a-select-option value="positive">مثبت یعنی بدهکار</a-select-option>
|
||||
</a-select>
|
||||
<div class="help">در زرین منفی یعنی بدهکار (روی نصب واقعی بررسی شد)؛ تغییرش ندهید مگر نصب شما خلاف این باشد.</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="توکن وبسرویس" required class="grid__full">
|
||||
<div v-if="tokenSaved && !editingToken[p]" class="token">
|
||||
<LockOutlined class="token__icon" />
|
||||
<span v-if="pub(p)?.tokenReadable">توکن ذخیره شده است و نمایش داده نمیشود.</span>
|
||||
<span v-else class="text-danger">توکن ذخیرهشده قابلخواندن نیست؛ دوباره وارد کنید.</span>
|
||||
<a-space class="token__actions">
|
||||
<a-button size="small" @click="editingToken[p] = true">تغییر توکن</a-button>
|
||||
<a-popconfirm title="توکن حذف شود؟ تا توکن جدید وارد نشود اتصال کار نمیکند." ok-text="حذف" cancel-text="انصراف" @confirm="emit('clearToken', p)">
|
||||
<a-button size="small" danger>حذف</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</div>
|
||||
<template v-else>
|
||||
<a-input-password v-model:value="tokens[p]" dir="ltr" class="ltr" autocomplete="new-password" placeholder="توکن را اینجا وارد کنید" />
|
||||
<div class="help">
|
||||
بعد از ذخیره، توکن رمز میشود و دیگر نمایش داده نمیشود.
|
||||
<a v-if="tokenSaved" class="link" @click="cancelTokenEdit">انصراف و نگهداشتن توکن فعلی</a>
|
||||
</div>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<a-collapse ghost class="advanced">
|
||||
<a-collapse-panel key="adv" header="تنظیمات پیشرفته">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="حداکثر زمان انتظار برای پاسخ">
|
||||
<a-input-number v-model:value="timeoutSec" :min="3" :max="120" addon-after="ثانیه" />
|
||||
<div class="help">اگر سرور حسابداری کند است بیشترش کنید. پیشفرض ۲۰ ثانیه.</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. certificate (https only) ────────────────────────── -->
|
||||
<section v-if="isHttps" class="step">
|
||||
<h3 class="step__title"><span class="step__no">۳</span>گواهی امنیتی سرور</h3>
|
||||
<p class="step__lead">
|
||||
اتصال همیشه رمزنگاریشده و بررسیشده است. نصبهای معمول {{ LABEL[p] }} گواهی خودساخته دارند؛ در این حالت یک بار گواهی سرور را بررسی و تأیید کنید.
|
||||
</p>
|
||||
|
||||
<!-- trusted (pinned) -->
|
||||
<div v-if="form[p].caCert" class="cert cert--ok">
|
||||
<SafetyCertificateOutlined class="cert__icon" />
|
||||
<div class="cert__body">
|
||||
<div class="cert__title">
|
||||
گواهی سرور تأیید شده است
|
||||
<a-tag v-if="certUnsaved" color="orange">ذخیره نشده</a-tag>
|
||||
<a-tag v-if="certInfo?.expired" color="red">منقضی شده</a-tag>
|
||||
</div>
|
||||
<dl v-if="certInfo" class="kv">
|
||||
<dt>صادرشده برای</dt>
|
||||
<dd dir="ltr" class="latin">{{ cn(certInfo.subject) }}</dd>
|
||||
<dt>معتبر تا</dt>
|
||||
<dd>{{ jdate(certInfo.validTo) }}</dd>
|
||||
<dt>اثرانگشت</dt>
|
||||
<dd dir="ltr" class="mono">{{ certInfo.fingerprint256 }}</dd>
|
||||
</dl>
|
||||
<div v-else class="help">مشخصات گواهی بعد از ذخیره نمایش داده میشود.</div>
|
||||
</div>
|
||||
<a-space direction="vertical" class="cert__actions">
|
||||
<a-button size="small" :loading="checking" @click="checkCert">بررسی دوباره</a-button>
|
||||
<a-button size="small" danger @click="form[p].caCert = ''">برداشتن</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- just checked, waiting for a decision -->
|
||||
<div v-else-if="checked && !checked.trustedBySystem" class="cert cert--ask">
|
||||
<ExclamationCircleOutlined class="cert__icon" />
|
||||
<div class="cert__body">
|
||||
<div class="cert__title">این گواهی را تأیید میکنید؟</div>
|
||||
<div class="help">اثرانگشت زیر را با گواهی نصبشده روی سرور حسابداری مقایسه کنید. اگر یکی است تأیید کنید.</div>
|
||||
<dl class="kv">
|
||||
<dt>صادرشده برای</dt>
|
||||
<dd dir="ltr" class="latin">{{ cn(checked.subject) }}</dd>
|
||||
<dt>معتبر تا</dt>
|
||||
<dd>{{ jdate(checked.validTo) }}</dd>
|
||||
<dt>اثرانگشت</dt>
|
||||
<dd dir="ltr" class="mono">{{ checked.fingerprint256 }}</dd>
|
||||
</dl>
|
||||
<a-alert v-if="checked.expired" type="error" show-icon message="این گواهی منقضی شده است." class="mt" />
|
||||
</div>
|
||||
<a-space direction="vertical" class="cert__actions">
|
||||
<a-button type="primary" size="small" @click="trustChecked">تأیید گواهی</a-button>
|
||||
<a-button size="small" @click="checked = null">انصراف</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- public certificate: nothing to do -->
|
||||
<div v-else-if="checked?.trustedBySystem" class="cert cert--ok">
|
||||
<SafetyCertificateOutlined class="cert__icon" />
|
||||
<div class="cert__body">
|
||||
<div class="cert__title">گواهی سرور معتبر است</div>
|
||||
<div class="help">این سرور گواهی معتبر عمومی دارد؛ کاری لازم نیست.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- nothing yet -->
|
||||
<div v-else class="cert">
|
||||
<SafetyCertificateOutlined class="cert__icon cert__icon--muted" />
|
||||
<div class="cert__body">
|
||||
<div class="cert__title">گواهیای تأیید نشده</div>
|
||||
<div class="help">اگر «تست اتصال» خطای گواهی داد، اینجا گواهی سرور را بررسی کنید.</div>
|
||||
</div>
|
||||
<a-button :loading="checking" :disabled="!form[p].baseUrl" @click="checkCert">بررسی گواهی سرور</a-button>
|
||||
</div>
|
||||
|
||||
<a-collapse ghost class="advanced">
|
||||
<a-collapse-panel key="pem" header="وارد کردن دستی گواهی (PEM)">
|
||||
<a-textarea v-model:value="form[p].caCert" dir="ltr" class="ltr mono" :rows="5" placeholder="-----BEGIN CERTIFICATE-----" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ── test result ────────────────────────────────────────── -->
|
||||
<section v-if="p && result" class="step">
|
||||
<a-alert v-if="result.ok" type="success" show-icon :message="`اتصال به ${LABEL[p]} برقرار است`">
|
||||
<template #description>
|
||||
<dl class="kv kv--inline">
|
||||
<template v-for="row in resultRows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd :dir="row.ltr ? 'ltr' : undefined" :class="{ latin: row.ltr }">{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<div v-if="dbMismatch" class="text-danger mt">
|
||||
دیتابیسی که وصل شد <bdi class="latin">{{ result.info.dbName }}</bdi> است، نه <bdi class="latin">{{ form.tahesab.dbName }}</bdi>. نام دیتابیس را چک کنید.
|
||||
</div>
|
||||
<div v-if="resultStale" class="help">این نتیجه برای مقادیر قبلی است؛ دوباره تست کنید.</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
<a-alert v-else type="error" show-icon message="اتصال برقرار نشد">
|
||||
<template #description>
|
||||
<div>{{ result.error }}</div>
|
||||
<div v-if="resultStale" class="help">این نتیجه برای مقادیر قبلی است؛ دوباره تست کنید.</div>
|
||||
</template>
|
||||
</a-alert>
|
||||
</section>
|
||||
|
||||
<!-- ── actions ────────────────────────────────────────────── -->
|
||||
<div class="bar">
|
||||
<span class="bar__note">
|
||||
<template v-if="dirty">تغییرات هنوز ذخیره نشدهاند.</template>
|
||||
<template v-else-if="p">«تست اتصال» مقادیر همین فرم را امتحان میکند، حتی پیش از ذخیره.</template>
|
||||
</span>
|
||||
<a-space>
|
||||
<a-button v-if="p" :loading="testing" :disabled="!canTest" @click="test">تست اتصال</a-button>
|
||||
<a-button type="primary" :loading="saving" :disabled="!dirty" @click="emit('save')">ذخیره</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
ExclamationCircleOutlined,
|
||||
LockOutlined,
|
||||
MinusCircleFilled,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import moment from 'moment-jalaali';
|
||||
import api from '../../api/client';
|
||||
|
||||
type ProviderName = 'tahesab' | 'zarrin';
|
||||
|
||||
const props = defineProps<{
|
||||
overview: any;
|
||||
form: any;
|
||||
tokens: Record<ProviderName, string>;
|
||||
saving: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: 'save'): void; (e: 'clearToken', p: ProviderName): void }>();
|
||||
|
||||
const LABEL: Record<ProviderName, string> = { tahesab: 'تهحساب', zarrin: 'زرین' };
|
||||
const CHOICES: { value: 'none' | ProviderName; label: string; desc: string }[] = [
|
||||
{ value: 'none', label: 'بدون اتصال', desc: 'هیچ اطلاعاتی با حسابداری رد و بدل نمیشود.' },
|
||||
{ value: 'tahesab', label: 'تهحساب', desc: 'اتصال به وبسرویس نرمافزار تهحساب.' },
|
||||
{ value: 'zarrin', label: 'زرین', desc: 'اتصال به وبسرویس نرمافزار زرین.' },
|
||||
];
|
||||
|
||||
const p = computed<ProviderName | null>(() => (props.form.provider === 'none' ? null : props.form.provider));
|
||||
const saved = computed(() => props.overview?.config ?? null);
|
||||
const pub = (name: ProviderName) => saved.value?.[name];
|
||||
const tokenSaved = computed(() => !!(p.value && pub(p.value)?.tokenSet));
|
||||
const isHttps = computed(() => !!p.value && /^https:\/\//i.test(props.form[p.value].baseUrl || ''));
|
||||
|
||||
// ── dirty: the connection part of the form against what is saved ──
|
||||
const snapshot = (src: any, provider: string) =>
|
||||
JSON.stringify(
|
||||
provider === 'none'
|
||||
? { provider }
|
||||
: {
|
||||
provider,
|
||||
baseUrl: src[provider]?.baseUrl ?? '',
|
||||
caCert: src[provider]?.caCert ?? '',
|
||||
timeoutMs: src[provider]?.timeoutMs,
|
||||
dbName: provider === 'tahesab' ? src.tahesab?.dbName : undefined,
|
||||
balanceOwesWhen: provider === 'zarrin' ? src.zarrin?.balanceOwesWhen : undefined,
|
||||
},
|
||||
);
|
||||
const dirty = computed(() => {
|
||||
if (!saved.value) return false;
|
||||
if (props.form.provider !== saved.value.provider) return true;
|
||||
if (!p.value) return false;
|
||||
return snapshot(props.form, p.value) !== snapshot(saved.value, p.value) || !!props.tokens[p.value]?.trim();
|
||||
});
|
||||
const providerChanged = computed(() => !!saved.value && saved.value.provider !== 'none' && saved.value.provider !== props.form.provider);
|
||||
|
||||
// ── status card ──
|
||||
interface StatusRow {
|
||||
label: string;
|
||||
value: string;
|
||||
ltr?: boolean;
|
||||
}
|
||||
const jdt = (v?: string | null) => (v ? moment(v).format('jYYYY/jMM/jDD ساعت HH:mm') : '');
|
||||
const status = computed(() => {
|
||||
const ov = props.overview;
|
||||
if (!ov) return { tone: 'off', icon: MinusCircleFilled, title: '…', text: '', rows: [] as StatusRow[] };
|
||||
const c = ov.config;
|
||||
if (c.provider === 'none') {
|
||||
return { tone: 'off', icon: MinusCircleFilled, title: 'اتصال خاموش است', text: 'هیچ اطلاعاتی با نرمافزار حسابداری رد و بدل نمیشود.' };
|
||||
}
|
||||
const label = LABEL[c.provider as ProviderName];
|
||||
if (ov.inactiveReason) {
|
||||
return { tone: 'bad', icon: CloseCircleFilled, title: `اتصال به ${label} کار نمیکند`, text: ov.inactiveReason };
|
||||
}
|
||||
const rt = ov.realtime;
|
||||
if (rt?.lastError) {
|
||||
return { tone: 'bad', icon: CloseCircleFilled, title: `${label}: ارتباط برقرار نیست`, text: rt.lastError };
|
||||
}
|
||||
const conn = c[c.provider];
|
||||
const rows: StatusRow[] = [{ label: 'آدرس', value: conn.baseUrl, ltr: true }];
|
||||
if (c.provider === 'tahesab') rows.push({ label: 'دیتابیس', value: c.tahesab.dbName, ltr: true });
|
||||
if (rt?.lastOkAt) rows.push({ label: 'آخرین ارتباط موفق', value: jdt(rt.lastOkAt) });
|
||||
return { tone: 'ok', icon: CheckCircleFilled, title: `متصل به ${label}`, text: '', rows };
|
||||
});
|
||||
|
||||
// ── token ──
|
||||
const editingToken = reactive<Record<ProviderName, boolean>>({ tahesab: false, zarrin: false });
|
||||
function cancelTokenEdit() {
|
||||
if (!p.value) return;
|
||||
props.tokens[p.value] = '';
|
||||
editingToken[p.value] = false;
|
||||
}
|
||||
watch(
|
||||
() => props.overview,
|
||||
() => {
|
||||
editingToken.tahesab = false;
|
||||
editingToken.zarrin = false;
|
||||
checked.value = null;
|
||||
},
|
||||
);
|
||||
|
||||
// ── timeout shown in seconds ──
|
||||
const timeoutSec = computed({
|
||||
get: () => (p.value ? Math.round((props.form[p.value].timeoutMs ?? 20000) / 1000) : 20),
|
||||
set: (v: number) => {
|
||||
if (p.value) props.form[p.value].timeoutMs = Math.max(3, Math.min(120, Number(v) || 20)) * 1000;
|
||||
},
|
||||
});
|
||||
|
||||
// ── certificate ──
|
||||
const checking = ref(false);
|
||||
const checked = ref<any>(null);
|
||||
const cn = (subject: string) => (subject.match(/CN=([^\n,]+)/)?.[1] ?? subject.replace(/\n/g, ', '));
|
||||
const jdate = (v: string) => (v ? moment(new Date(v)).format('jYYYY/jMM/jDD') : '—');
|
||||
const certUnsaved = computed(() => !!p.value && (props.form[p.value].caCert || '') !== (pub(p.value)?.caCert || ''));
|
||||
const certInfo = computed(() => {
|
||||
if (!p.value) return null;
|
||||
if (!certUnsaved.value) return pub(p.value)?.caInfo ?? null;
|
||||
return checked.value && checked.value.pem === props.form[p.value].caCert ? checked.value : null;
|
||||
});
|
||||
|
||||
async function checkCert() {
|
||||
if (!p.value) return;
|
||||
checking.value = true;
|
||||
try {
|
||||
const { data } = await api.post('admin/accounting/certificate', { baseUrl: props.form[p.value].baseUrl });
|
||||
checked.value = data;
|
||||
if (props.form[p.value].caCert && data.pem.trim() === props.form[p.value].caCert.trim()) {
|
||||
message.success('گواهی سرور همان گواهی تأییدشده است');
|
||||
} else if (props.form[p.value].caCert) {
|
||||
// A different certificate than the trusted one: ask again.
|
||||
props.form[p.value].caCert = '';
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'گواهی دریافت نشد');
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
}
|
||||
function trustChecked() {
|
||||
if (!p.value || !checked.value) return;
|
||||
props.form[p.value].caCert = checked.value.pem;
|
||||
}
|
||||
|
||||
// ── test ──
|
||||
const testing = ref(false);
|
||||
const result = ref<any>(null);
|
||||
const testedWith = ref('');
|
||||
const fingerprint = () => (p.value ? JSON.stringify([props.form.provider, props.form[p.value], props.tokens[p.value]]) : '');
|
||||
const resultStale = computed(() => !!result.value && testedWith.value !== fingerprint());
|
||||
const canTest = computed(() => !!p.value && !!props.form[p.value].baseUrl && (tokenSaved.value || !!props.tokens[p.value]?.trim()));
|
||||
watch(p, () => (result.value = null));
|
||||
|
||||
async function test() {
|
||||
if (!p.value) return;
|
||||
const name = p.value;
|
||||
testing.value = true;
|
||||
try {
|
||||
const connection: Record<string, unknown> = {
|
||||
baseUrl: props.form[name].baseUrl,
|
||||
timeoutMs: props.form[name].timeoutMs,
|
||||
caCert: props.form[name].caCert,
|
||||
token: props.tokens[name] || undefined,
|
||||
};
|
||||
if (name === 'tahesab') connection.dbName = props.form.tahesab.dbName;
|
||||
else connection.balanceOwesWhen = props.form.zarrin.balanceOwesWhen;
|
||||
testedWith.value = fingerprint();
|
||||
const { data } = await api.post('admin/accounting/test', { provider: name, connection });
|
||||
result.value = data;
|
||||
} catch (e: any) {
|
||||
const m = e?.response?.data?.message;
|
||||
result.value = { ok: false, error: Array.isArray(m) ? m.join('، ') : (m ?? 'خطا در تست اتصال') };
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const INFO_LABELS: Record<string, { label: string; ltr?: boolean }> = {
|
||||
version: { label: 'نسخه', ltr: true },
|
||||
dbType: { label: 'نوع دیتابیس', ltr: true },
|
||||
dbName: { label: 'دیتابیس', ltr: true },
|
||||
customerGroups: { label: 'گروههای حساب' },
|
||||
message: { label: 'پیام سرور' },
|
||||
};
|
||||
const resultRows = computed(() => {
|
||||
const info = result.value?.info ?? {};
|
||||
const rows = Object.entries(INFO_LABELS)
|
||||
.filter(([k]) => info[k] !== undefined && info[k] !== null && info[k] !== '')
|
||||
.map(([k, v]) => ({ label: v.label, value: String(info[k]), ltr: v.ltr }));
|
||||
rows.push({ label: 'زمان پاسخ', value: `${(result.value?.ms ?? 0).toLocaleString('fa-IR')} میلیثانیه`, ltr: false });
|
||||
return rows;
|
||||
});
|
||||
const dbMismatch = computed(
|
||||
() =>
|
||||
p.value === 'tahesab' &&
|
||||
!!result.value?.info?.dbName &&
|
||||
String(result.value.info.dbName).toLowerCase() !== String(props.form.tahesab.dbName || '').toLowerCase(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conn {
|
||||
max-width: 920px;
|
||||
}
|
||||
.mt {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.help {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-top: 4px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.link {
|
||||
margin-inline-start: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.text-danger {
|
||||
color: #cf1322;
|
||||
}
|
||||
.ltr {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
.ltr :deep(input),
|
||||
.ltr :deep(textarea) {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* status */
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 16px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.status__icon {
|
||||
font-size: 22px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.status__title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.status__tag {
|
||||
margin-inline-start: 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.status__text {
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.status__rows {
|
||||
margin-top: 6px;
|
||||
}
|
||||
/* URLs, database names, fingerprints: Latin digits, left-to-right */
|
||||
.latin,
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
.status--ok {
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
.status--ok .status__icon {
|
||||
color: #52c41a;
|
||||
}
|
||||
.status--bad {
|
||||
background: #fff2f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
.status--bad .status__icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.status--off {
|
||||
background: #fafafa;
|
||||
border-color: #f0f0f0;
|
||||
}
|
||||
.status--off .status__icon {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
/* steps */
|
||||
.step {
|
||||
padding: 4px 0 24px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.step__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.step__no {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #1f2a5c;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.step__lead {
|
||||
margin: -4px 0 14px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
/* provider choice */
|
||||
.choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.choice {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
text-align: start;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.choice:hover {
|
||||
border-color: #8c9bd1;
|
||||
}
|
||||
.choice:focus-visible {
|
||||
outline: 2px solid #1f2a5c;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.choice--on {
|
||||
border-color: #1f2a5c;
|
||||
box-shadow: 0 0 0 1px #1f2a5c;
|
||||
background: #f5f7ff;
|
||||
}
|
||||
.choice__dot {
|
||||
grid-row: span 2;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 3px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #bfbfbf;
|
||||
}
|
||||
.choice--on .choice__dot {
|
||||
border: 5px solid #1f2a5c;
|
||||
}
|
||||
.choice__name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.choice__tag {
|
||||
margin-inline-start: 6px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.choice__desc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* form grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 16px;
|
||||
}
|
||||
.grid__full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.advanced {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.advanced :deep(.ant-collapse-header) {
|
||||
padding-inline: 0 !important;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* token */
|
||||
.token {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.token__icon {
|
||||
color: #52c41a;
|
||||
}
|
||||
.token__actions {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* certificate */
|
||||
.cert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.cert--ok {
|
||||
background: #f6ffed;
|
||||
border-color: #d9f7be;
|
||||
}
|
||||
.cert--ask {
|
||||
background: #fffbe6;
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
.cert__icon {
|
||||
font-size: 22px;
|
||||
color: #52c41a;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cert--ask .cert__icon {
|
||||
color: #faad14;
|
||||
}
|
||||
.cert__icon--muted {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
.cert__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cert__title {
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cert__actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* key/value lists */
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 14px;
|
||||
margin: 8px 0 0;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.kv dt {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
text-align: start;
|
||||
}
|
||||
.kv dd[dir='ltr'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* action bar */
|
||||
.bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.bar__note {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.choices,
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cert {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<!--
|
||||
D27, plan §12 — «سینک با حسابداری» on a list page (wholesale customers,
|
||||
products). Shown only while an accounting software is connected; runs the
|
||||
sync on the server, waits for it and reports what happened.
|
||||
-->
|
||||
<a-button v-if="connected" :loading="busy" @click="run">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
سینک با {{ provider || 'حسابداری' }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, onMounted, ref } from 'vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
|
||||
const props = defineProps<{ endpoint: string }>();
|
||||
const emit = defineEmits<{ (e: 'done'): void }>();
|
||||
|
||||
const connected = ref(false);
|
||||
const provider = ref<string | null>(null);
|
||||
const busy = ref(false);
|
||||
|
||||
const JOB_LABELS: Record<string, string> = { customers: 'مشتریان', balances: 'ماندهها', products: 'کارساختهها و موجودی' };
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
scanned: 'بررسیشده',
|
||||
inScope: 'در گروههای انتخابی',
|
||||
refreshed: 'بهروز',
|
||||
linked: 'لینکشده',
|
||||
imported: 'وارد شده',
|
||||
issues: 'مغایرت',
|
||||
updated: 'بهروز',
|
||||
items: 'کارساخته',
|
||||
stockErrors: 'خطای موجودی',
|
||||
};
|
||||
|
||||
const describe = (summary: unknown) =>
|
||||
summary && typeof summary === 'object'
|
||||
? Object.entries(summary as Record<string, unknown>)
|
||||
.map(([k, v]) => `${KEY_LABELS[k] ?? k}: ${v}`)
|
||||
.join('، ')
|
||||
: '';
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.get('admin/accounting-status');
|
||||
connected.value = !!data?.connected;
|
||||
provider.value = data?.provider ?? null;
|
||||
} catch {
|
||||
connected.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function run() {
|
||||
busy.value = true;
|
||||
try {
|
||||
const { data } = await api.post(props.endpoint);
|
||||
const rows = Object.entries(data.results as Record<string, { ok: boolean; summary?: unknown; error?: string }>).map(
|
||||
([job, r]) =>
|
||||
h('p', { style: r.ok ? '' : 'color:#d4380d' }, `${JOB_LABELS[job] ?? job}: ${r.ok ? describe(r.summary) || 'انجام شد' : `خطا — ${r.error}`}`),
|
||||
);
|
||||
(data.ok ? Modal.success : Modal.warning)({
|
||||
title: data.ok ? 'سینک با حسابداری انجام شد' : 'سینک با حسابداری کامل انجام نشد',
|
||||
content: h('div', rows),
|
||||
okText: 'باشه',
|
||||
});
|
||||
emit('done');
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message || 'سینک با حسابداری انجام نشد');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<!--
|
||||
D27 — the customer's side in the accounting software: which account it is
|
||||
linked to, and both balances side by side (the customer sees the
|
||||
accounting one; the credit limit is checked against the app's own).
|
||||
-->
|
||||
<section v-if="visible" class="acc-card" :style="themeVars">
|
||||
<header class="acc-head">
|
||||
<h2>حسابداری</h2>
|
||||
<a-tag v-if="info?.link" color="success">لینک شده</a-tag>
|
||||
<a-tag v-else color="warning">لینک نشده</a-tag>
|
||||
</header>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="acc-body">
|
||||
<div class="acc-code">
|
||||
<span class="label">کد حساب</span>
|
||||
<template v-if="info?.link">
|
||||
<span class="code" dir="ltr">{{ info.link.externalCode }}</span>
|
||||
<span class="origin">{{ ORIGIN[info.link.origin] ?? info.link.origin }}</span>
|
||||
</template>
|
||||
<span v-else class="muted">—</span>
|
||||
</div>
|
||||
|
||||
<div class="balances">
|
||||
<div class="balance">
|
||||
<div class="label">ماندهی حسابداری</div>
|
||||
<template v-if="info?.accounting">
|
||||
<div class="line">
|
||||
<span :class="sign(info.accounting.gold)">{{ num(info.accounting.gold, 3) }}</span>
|
||||
<small>گرم</small>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span :class="sign(info.accounting.rial)">{{ num(info.accounting.rial, 0) }}</span>
|
||||
<small>ریال</small>
|
||||
</div>
|
||||
<div class="hint">{{ jdt(info.accounting.at) }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="line muted">—</div>
|
||||
<div class="hint">هنوز دریافت نشده</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="balance">
|
||||
<div class="label">ماندهی دفتر اپ</div>
|
||||
<div class="line">
|
||||
<span :class="sign(info?.internal?.gold)">{{ num(info?.internal?.gold, 3) }}</span>
|
||||
<small>گرم</small>
|
||||
</div>
|
||||
<div class="line">
|
||||
<span :class="sign(info?.internal?.rial)">{{ num(info?.internal?.rial, 0) }}</span>
|
||||
<small>ریال</small>
|
||||
</div>
|
||||
<div class="hint">مبنای سقف اعتبار</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="lastJob" class="last-job">
|
||||
<a-badge :status="JOB_BADGE[lastJob.status] ?? 'default'" />
|
||||
آخرین ارسال: {{ STATUS[lastJob.status] ?? lastJob.status }} — {{ jdt(lastJob.updatedAt) }}
|
||||
<div v-if="lastJob.lastError" class="warn">{{ lastJob.lastError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="link-form">
|
||||
<div class="label">{{ info?.link ? 'تغییر لینک' : 'لینک دستی' }}</div>
|
||||
<div class="link-row">
|
||||
<a-input
|
||||
v-model:value="code"
|
||||
dir="ltr"
|
||||
placeholder="کد حساب در حسابداری"
|
||||
@press-enter="code.trim() && link()"
|
||||
/>
|
||||
<a-button type="primary" ghost :disabled="!code.trim()" @click="link">لینک</a-button>
|
||||
</div>
|
||||
<a-popconfirm v-if="info?.link" title="لینک حذف شود؟" @confirm="unlink">
|
||||
<a-button type="link" danger size="small" class="unlink">حذف لینک فعلی</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { message, theme } from 'ant-design-vue';
|
||||
import moment from 'moment-jalaali';
|
||||
import api from '../../api/client';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
|
||||
const props = defineProps<{ customerId: string }>();
|
||||
|
||||
const ORIGIN: Record<string, string> = {
|
||||
LINKED: 'جفت با موبایل',
|
||||
IMPORTED: 'وارد شده از حسابداری',
|
||||
PUSHED: 'ساخته شده از اپ',
|
||||
MANUAL: 'دستی',
|
||||
};
|
||||
const STATUS: Record<string, string> = {
|
||||
PENDING: 'در صف',
|
||||
SENDING: 'در حال ارسال',
|
||||
SENT: 'ارسال شد',
|
||||
FAILED: 'ناموفق',
|
||||
NEEDS_REVIEW: 'نیاز به بررسی',
|
||||
RESOLVED: 'حلشده',
|
||||
};
|
||||
const JOB_BADGE: Record<string, 'success' | 'processing' | 'error' | 'warning' | 'default'> = {
|
||||
PENDING: 'default',
|
||||
SENDING: 'processing',
|
||||
SENT: 'success',
|
||||
FAILED: 'error',
|
||||
NEEDS_REVIEW: 'warning',
|
||||
RESOLVED: 'success',
|
||||
};
|
||||
|
||||
const users = useUsersStore();
|
||||
const { token } = theme.useToken();
|
||||
const info = ref<any>(null);
|
||||
const loading = ref(false);
|
||||
const code = ref('');
|
||||
|
||||
const themeVars = computed(() => ({
|
||||
'--acc-bg': token.value.colorBgContainer,
|
||||
'--acc-border': token.value.colorBorderSecondary,
|
||||
'--acc-fill': token.value.colorFillQuaternary,
|
||||
'--acc-text': token.value.colorText,
|
||||
'--acc-text-2': token.value.colorTextSecondary,
|
||||
'--acc-text-3': token.value.colorTextTertiary,
|
||||
'--acc-error': token.value.colorError,
|
||||
'--acc-success': token.value.colorSuccess,
|
||||
}));
|
||||
|
||||
const visible = computed(() => Boolean(users.hasPermission('config.accounting')) && info.value && info.value.provider !== 'none');
|
||||
const lastJob = computed(() => info.value?.jobs?.[0]);
|
||||
|
||||
const num = (v: unknown, places: number) => (v === null || v === undefined ? '—' : Number(v).toLocaleString('fa-IR', { maximumFractionDigits: places }));
|
||||
const sign = (v: unknown) => (Number(v) > 0 ? 'debt' : Number(v) < 0 ? 'credit' : '');
|
||||
const jdt = (v?: string | null) => (v ? moment(v).format('jYYYY/jMM/jDD HH:mm') : '');
|
||||
|
||||
async function load() {
|
||||
if (!users.hasPermission('config.accounting')) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`admin/accounting/customers/${props.customerId}`);
|
||||
info.value = data;
|
||||
} catch {
|
||||
info.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function link() {
|
||||
try {
|
||||
await api.post('admin/accounting/links', { entityType: 'CUSTOMER', entityId: props.customerId, externalCode: code.value.trim() });
|
||||
message.success('لینک شد');
|
||||
code.value = '';
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
if (!info.value?.link) return;
|
||||
await api.delete(`admin/accounting/links/${info.value.link.id}`);
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.acc-card {
|
||||
background: var(--acc-bg);
|
||||
border: 1px solid var(--acc-border);
|
||||
border-radius: 12px;
|
||||
color: var(--acc-text);
|
||||
}
|
||||
.acc-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--acc-border);
|
||||
}
|
||||
.acc-head h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.acc-head :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
.acc-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 14px 18px 16px;
|
||||
}
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: var(--acc-text-2);
|
||||
}
|
||||
.acc-code {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
.acc-code .label {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
.code {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.origin {
|
||||
font-size: 12px;
|
||||
color: var(--acc-text-3);
|
||||
}
|
||||
.balances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.balance {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--acc-fill);
|
||||
}
|
||||
.balance .label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.line {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.line small {
|
||||
margin-inline-start: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--acc-text-3);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--acc-text-3);
|
||||
}
|
||||
.last-job {
|
||||
font-size: 12px;
|
||||
color: var(--acc-text-2);
|
||||
}
|
||||
.link-form {
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--acc-border);
|
||||
}
|
||||
.link-form .label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.link-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.unlink {
|
||||
margin-top: 4px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
.muted {
|
||||
color: var(--acc-text-3);
|
||||
}
|
||||
.warn {
|
||||
margin-top: 2px;
|
||||
color: var(--acc-error);
|
||||
}
|
||||
.debt {
|
||||
color: var(--acc-error);
|
||||
}
|
||||
.credit {
|
||||
color: var(--acc-success);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<!--
|
||||
The update modal (composables/useAppUpdate.ts). It cannot be dismissed:
|
||||
the only way out is the update, so every operator runs the live release
|
||||
(owner ruling 2026-10-10).
|
||||
-->
|
||||
<a-modal
|
||||
:open="Boolean(available)"
|
||||
:closable="false"
|
||||
:mask-closable="false"
|
||||
:keyboard="false"
|
||||
:z-index="2000"
|
||||
width="420px"
|
||||
centered
|
||||
>
|
||||
<template #title>
|
||||
نسخهی تازهی پنل آماده است
|
||||
<a-tag v-if="available" class="ms-2" color="blue">نسخهی {{ available.version }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="changes.length">
|
||||
<p class="lead">در این نسخه:</p>
|
||||
<ul class="list">
|
||||
<template v-for="c in changes" :key="c.build">
|
||||
<li v-for="(n, i) in c.notes" :key="c.build + '-' + i">{{ n }}</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-else class="lead">بهبودها و رفع اشکالها.</p>
|
||||
<p class="hint">کار ذخیرهنشدهای اگر در صفحه دارید، پیش از بهروزرسانی ثبتش کنید.</p>
|
||||
|
||||
<template #footer>
|
||||
<a-button type="primary" block :loading="updating" @click="applyUpdate">بهروزرسانی</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppUpdate } from '../../composables/useAppUpdate';
|
||||
|
||||
const { available, changes, updating, applyUpdate } = useAppUpdate();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lead {
|
||||
margin: 0 0 6px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.list {
|
||||
margin: 0 0 10px;
|
||||
padding-inline-start: 18px;
|
||||
line-height: 1.9;
|
||||
max-height: 50vh;
|
||||
list-style: disc;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<!--
|
||||
Standard shell for every "add/edit" modal in the panel: header = title,
|
||||
footer = action buttons, body = the form (passed in via the default slot).
|
||||
The 16 forms converted from full pages to modals all use this instead of
|
||||
hand-rolling <a-modal> + <a-card> + a trailing button, which used to be
|
||||
duplicated 16 times with inconsistent widths (60%/70%/80%/default).
|
||||
-->
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="id ? editTitle : addTitle"
|
||||
:width="WIDTH[width]"
|
||||
:confirm-loading="submitting"
|
||||
:ok-text="okText"
|
||||
cancel-text="انصراف"
|
||||
:mask-closable="false"
|
||||
wrap-class-name="base-form-modal"
|
||||
@update:open="onUpdateOpen"
|
||||
@ok="$emit('submit')"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical">
|
||||
<slot />
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
/** Presence of `id` switches the modal into edit mode (title only — the
|
||||
* form body decides its own create/edit behaviour from the same prop). */
|
||||
id?: string;
|
||||
addTitle: string;
|
||||
editTitle: string;
|
||||
/** Drives the inner a-spin, e.g. while loading the record being edited. */
|
||||
loading?: boolean;
|
||||
/** Drives the OK button's loading state while a submit is in flight. */
|
||||
submitting?: boolean;
|
||||
/** 520 / 720 / 900 / 1140 — replaces the old ad-hoc 60%/70%/80% widths. */
|
||||
width?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
okText?: string;
|
||||
}>(),
|
||||
{
|
||||
id: undefined,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
width: 'md',
|
||||
okText: 'ثبت اطلاعات',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'submit'): void;
|
||||
(e: 'cancel'): void;
|
||||
}>();
|
||||
|
||||
const WIDTH: Record<'sm' | 'md' | 'lg' | 'xl', number> = {
|
||||
sm: 520,
|
||||
md: 720,
|
||||
lg: 900,
|
||||
xl: 1140,
|
||||
};
|
||||
|
||||
function onUpdateOpen(value: boolean) {
|
||||
emit('update:open', value);
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('update:open', false);
|
||||
emit('cancel');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Unscoped: <a-modal> teleports to <body>; contained by wrap-class-name. -->
|
||||
<style>
|
||||
@media (max-width: 575px) {
|
||||
.base-form-modal .ant-modal {
|
||||
/* ant sets the width inline from :width */
|
||||
width: 100vw !important;
|
||||
max-width: 100vw;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.base-form-modal .ant-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(var(--vh, 1vh) * 100);
|
||||
border-radius: 0;
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
.base-form-modal .ant-modal-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.base-form-modal .ant-modal-body {
|
||||
flex: 1;
|
||||
}
|
||||
.base-form-modal .ant-modal-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 -16px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.base-form-modal .ant-modal-footer > .ant-btn {
|
||||
flex: 1;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<!--
|
||||
Legacy's city picker (henza_admin mixins/citiesModel.js): the cities as a
|
||||
tree — country > province > city > district — with the top level a heading
|
||||
that cannot be picked. A top-level row with nothing under it stays pickable,
|
||||
so a city entered without a parent is never unreachable.
|
||||
`parentPicker` is the «والد» field of the city form: every row is pickable
|
||||
there except the city being edited and its own descendants.
|
||||
-->
|
||||
<a-tree-select
|
||||
:value="value ?? undefined"
|
||||
:tree-data="treeData"
|
||||
show-search
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
tree-node-filter-prop="title"
|
||||
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
|
||||
:placeholder="placeholder ?? 'شهر را انتخاب کنید'"
|
||||
class="w-full"
|
||||
@update:value="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface CityRow {
|
||||
id: string;
|
||||
title: string;
|
||||
parent?: string | null;
|
||||
}
|
||||
|
||||
interface Node {
|
||||
value: string;
|
||||
key: string;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value?: string | null;
|
||||
cities: CityRow[];
|
||||
parentPicker?: boolean;
|
||||
/** parentPicker: the city being edited — it and its subtree are not offered. */
|
||||
excludeId?: string;
|
||||
placeholder?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:value', v: string | undefined): void;
|
||||
(e: 'change', v: string | undefined): void;
|
||||
}>();
|
||||
|
||||
const treeData = computed<Node[]>(() => {
|
||||
const ids = new Set(props.cities.map((c) => c.id));
|
||||
const kids = new Map<string | null, CityRow[]>();
|
||||
for (const c of props.cities) {
|
||||
const p = c.parent && ids.has(c.parent) && c.parent !== c.id ? c.parent : null;
|
||||
if (!kids.has(p)) kids.set(p, []);
|
||||
kids.get(p)!.push(c);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const build = (parent: string | null, depth: number): Node[] =>
|
||||
(kids.get(parent) ?? [])
|
||||
.filter((c) => !seen.has(c.id) && !(props.parentPicker && c.id === props.excludeId))
|
||||
.map((c) => {
|
||||
seen.add(c.id);
|
||||
const children = build(c.id, depth + 1);
|
||||
const node: Node = { value: c.id, key: c.id, title: c.title };
|
||||
if (children.length) node.children = children;
|
||||
if (!props.parentPicker && depth === 0 && children.length) node.disabled = true;
|
||||
return node;
|
||||
});
|
||||
return build(null, 0);
|
||||
});
|
||||
|
||||
function onChange(v: string | undefined) {
|
||||
emit('update:value', v ?? undefined);
|
||||
emit('change', v ?? undefined);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<!--
|
||||
Standard "add" button for every list page — same rid the create route
|
||||
used to gate on, so permissions are not re-derived by hand. Rendered
|
||||
inline inside XTable's toolbar (via its #add slot), aligned with the
|
||||
search bar, rather than in a row of its own above the table.
|
||||
-->
|
||||
<a-button v-if="!rid || hasPermission(rid)" type="primary" @click="$emit('add')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ addLabel }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** Permission id gating the button; omit if the action has no rid of its own. */
|
||||
rid?: string;
|
||||
addLabel?: string;
|
||||
}>(),
|
||||
{ rid: undefined, addLabel: 'افزودن' },
|
||||
);
|
||||
|
||||
defineEmits<{ (e: 'add'): void }>();
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (id: string) => Boolean(users.hasPermission(id));
|
||||
</script>
|
||||
@@ -0,0 +1,81 @@
|
||||
// Shared legacy-Feather-name -> ant-design-vue icon map.
|
||||
//
|
||||
// Originally lived only inside TooltipBtn.vue (row-action icons). Extracted
|
||||
// here so the sidebar (navMenuItems.ts icon: values) can resolve the SAME
|
||||
// names through the SAME map instead of duplicating it — one source of
|
||||
// truth for "legacy Feather icon name" -> "ant component" across the app.
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BarChartOutlined,
|
||||
BellOutlined,
|
||||
BlockOutlined,
|
||||
BookOutlined,
|
||||
CameraOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
CreditCardOutlined,
|
||||
DeleteOutlined,
|
||||
DollarOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
GiftOutlined,
|
||||
HomeOutlined,
|
||||
InboxOutlined,
|
||||
MailOutlined,
|
||||
MessageOutlined,
|
||||
PercentageOutlined,
|
||||
PlusOutlined,
|
||||
SettingOutlined,
|
||||
ShoppingOutlined,
|
||||
StopOutlined,
|
||||
TeamOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserAddOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
/** legacy Feather name -> ant component. */
|
||||
export const ICONS: Record<string, unknown> = {
|
||||
// row-action icons (TooltipBtn.vue's original set)
|
||||
ListIcon: UnorderedListOutlined,
|
||||
FileIcon: FileOutlined,
|
||||
TrashIcon: DeleteOutlined,
|
||||
EditIcon: EditOutlined,
|
||||
CheckIcon: CheckOutlined,
|
||||
CameraIcon: CameraOutlined,
|
||||
EyeIcon: EyeOutlined,
|
||||
PlusIcon: PlusOutlined,
|
||||
XIcon: CloseOutlined,
|
||||
FileTextIcon: FileTextOutlined,
|
||||
|
||||
// sidebar icons (navMenuItems.ts), added when the sidebar started
|
||||
// rendering them for the first time
|
||||
HomeIcon: HomeOutlined,
|
||||
PackageIcon: InboxOutlined,
|
||||
UserIcon: UserOutlined,
|
||||
UsersIcon: TeamOutlined,
|
||||
UserPlusIcon: UserAddOutlined,
|
||||
ShoppingBagIcon: ShoppingOutlined,
|
||||
BoxIcon: InboxOutlined,
|
||||
GridIcon: AppstoreOutlined,
|
||||
LayersIcon: BlockOutlined,
|
||||
CreditCardIcon: CreditCardOutlined,
|
||||
DollarSignIcon: DollarOutlined,
|
||||
PercentIcon: PercentageOutlined,
|
||||
GiftIcon: GiftOutlined,
|
||||
SlashIcon: StopOutlined,
|
||||
BookIcon: BookOutlined,
|
||||
MessageSquareIcon: MessageOutlined,
|
||||
MailIcon: MailOutlined,
|
||||
BellIcon: BellOutlined,
|
||||
BarChartIcon: BarChartOutlined,
|
||||
SettingsIcon: SettingOutlined,
|
||||
InboxIcon: InboxOutlined,
|
||||
};
|
||||
|
||||
export function resolveIcon(name: string | undefined): unknown | undefined {
|
||||
if (!name) return undefined;
|
||||
return ICONS[name];
|
||||
}
|
||||
@@ -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,123 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/components/statistics-cards/StatisticsCardLine.vue.
|
||||
Visual chrome unified with admin panel cards (2026-10-14). Props and call-sites
|
||||
unchanged. Sparkline branch still omitted (every live caller used hideChart).
|
||||
-->
|
||||
<a-card class="stat-card" :bordered="true">
|
||||
<div class="stat-row">
|
||||
<span class="stat-icon">
|
||||
<component :is="iconComponent" />
|
||||
</span>
|
||||
<div class="stat-copy">
|
||||
<span class="stat-title">{{ statisticTitle }}</span>
|
||||
<h2 class="stat-value">{{ statistic }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
ShoppingOutlined,
|
||||
TeamOutlined,
|
||||
DollarOutlined,
|
||||
BarChartOutlined,
|
||||
CreditCardOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
statistic?: string | number;
|
||||
statisticTitle?: string;
|
||||
/** Kept for call-site compatibility; chrome no longer varies by colour. */
|
||||
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,
|
||||
CreditCardIcon: CreditCardOutlined,
|
||||
};
|
||||
|
||||
const iconComponent = computed(() => ICONS[props.icon ?? ''] ?? BarChartOutlined);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.stat-card :deep(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.stat-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(28, 45, 86, 0.06);
|
||||
color: #1c2d56;
|
||||
}
|
||||
|
||||
.stat-copy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #8c8c8c;
|
||||
font-weight: 400;
|
||||
}
|
||||
.stat-value {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.stat-card :deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
.stat-row {
|
||||
gap: 10px;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<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 via the shared
|
||||
map in components/icons.ts (also used by the sidebar). 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 { UnorderedListOutlined } from '@ant-design/icons-vue';
|
||||
import { resolveIcon } from '../icons';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
popTitle?: string;
|
||||
icon?: string;
|
||||
iconClass?: string;
|
||||
}>(),
|
||||
{ iconClass: 'w-5 h-5' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: 'click'): void }>();
|
||||
|
||||
const iconComponent = computed(() => resolveIcon(props.icon) ?? UnorderedListOutlined);
|
||||
|
||||
function onClick() {
|
||||
emit('click');
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<!--
|
||||
Multi-file gallery uploader. Same `file/create` endpoint as
|
||||
ImageUploadInput (one file per request), but files can be picked or dropped
|
||||
in bulk; each uploads on its own with progress, and the bound value stays
|
||||
the ordered list of stored URLs.
|
||||
-->
|
||||
<div class="gallery-upload">
|
||||
<a-upload-dragger
|
||||
accept="image/*"
|
||||
multiple
|
||||
:show-upload-list="false"
|
||||
:custom-request="doUpload"
|
||||
:before-upload="beforeUpload"
|
||||
>
|
||||
<div class="gallery-upload__zone">
|
||||
<CloudUploadOutlined class="gallery-upload__icon" />
|
||||
<p class="gallery-upload__title">{{ placeholder }}</p>
|
||||
<p class="gallery-upload__hint">
|
||||
تصاویر را اینجا بکشید و رها کنید یا کلیک کنید — انتخاب چند تصویر بهصورت همزمان امکانپذیر است
|
||||
</p>
|
||||
</div>
|
||||
</a-upload-dragger>
|
||||
|
||||
<div v-if="items.length" class="gallery-upload__grid">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.uid"
|
||||
class="tile"
|
||||
:class="{ 'is-uploading': item.status === 'uploading' }"
|
||||
>
|
||||
<img :src="item.url ?? item.preview" alt="" />
|
||||
|
||||
<div v-if="item.status === 'uploading'" class="tile__progress">
|
||||
<a-progress type="circle" :percent="item.percent" :size="40" :stroke-width="8" stroke-color="#fff" trail-color="rgba(255,255,255,.3)" :show-info="false" />
|
||||
</div>
|
||||
|
||||
<div v-else class="tile__overlay">
|
||||
<button type="button" class="tile__btn" title="نمایش" @click="preview(item)">
|
||||
<EyeOutlined />
|
||||
</button>
|
||||
<button type="button" class="tile__btn tile__btn--danger" title="حذف" @click="remove(item.uid)">
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" class="gallery-upload__meta">
|
||||
<span>{{ doneCount }} تصویر</span>
|
||||
<span v-if="pendingCount" class="gallery-upload__pending">
|
||||
<LoadingOutlined /> در حال بارگذاری {{ pendingCount }} تصویر…
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { CloudUploadOutlined, DeleteOutlined, EyeOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Ordered list of stored file URLs. */
|
||||
modelValue?: string[];
|
||||
/** Free-form tag recorded on each upload, e.g. 'product.gallery'. */
|
||||
purpose?: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{ modelValue: () => [], purpose: undefined, placeholder: 'افزودن تصاویر گالری' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>();
|
||||
|
||||
const MAX_PARALLEL = 3;
|
||||
|
||||
interface Item {
|
||||
uid: string;
|
||||
status: 'uploading' | 'done';
|
||||
url?: string;
|
||||
/** Local object URL shown while the file is still uploading. */
|
||||
preview?: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
const items = ref<Item[]>([]);
|
||||
const previewVisible = ref(false);
|
||||
const previewSrc = ref('');
|
||||
|
||||
const doneUrls = () => items.value.filter((i) => i.status === 'done' && i.url).map((i) => i.url!);
|
||||
const doneCount = computed(() => items.value.filter((i) => i.status === 'done').length);
|
||||
const pendingCount = computed(() => items.value.filter((i) => i.status === 'uploading').length);
|
||||
|
||||
function sync() {
|
||||
emit('update:modelValue', doneUrls());
|
||||
}
|
||||
|
||||
/** Keeps the tiles in step when the parent loads an existing record. */
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(urls) => {
|
||||
if (JSON.stringify(doneUrls()) === JSON.stringify(urls ?? [])) return;
|
||||
const pending = items.value.filter((i) => i.status === 'uploading');
|
||||
items.value = [
|
||||
...(urls ?? []).map((url) => ({ uid: url, status: 'done' as const, url, percent: 100 })),
|
||||
...pending,
|
||||
];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error(`«${file.name}» تصویر نیست`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A small queue so a 30-file drop doesn't open 30 requests at once.
|
||||
let active = 0;
|
||||
const queue: Array<() => void> = [];
|
||||
function runQueued<T>(job: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const start = () => {
|
||||
active++;
|
||||
job()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
active--;
|
||||
queue.shift()?.();
|
||||
});
|
||||
};
|
||||
if (active < MAX_PARALLEL) start();
|
||||
else queue.push(start);
|
||||
});
|
||||
}
|
||||
|
||||
const find = (uid: string) => items.value.find((i) => i.uid === uid);
|
||||
|
||||
function doUpload({ file }: { file: File & { uid?: string } }) {
|
||||
const uid = file.uid ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
items.value.push({ uid, status: 'uploading', preview: URL.createObjectURL(file), percent: 0 });
|
||||
|
||||
runQueued(async () => {
|
||||
// removed by the user while still waiting in the queue
|
||||
if (!find(uid)) return null;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (props.purpose) form.append('purpose', props.purpose);
|
||||
// Let the browser set the multipart boundary.
|
||||
const { data } = await api.post('file/create', form, {
|
||||
onUploadProgress: (e) => {
|
||||
const item = find(uid);
|
||||
if (item && e.total) item.percent = Math.round((e.loaded / e.total) * 100);
|
||||
},
|
||||
});
|
||||
return data as { url?: string };
|
||||
})
|
||||
.then((data) => {
|
||||
const item = find(uid);
|
||||
if (!item || !data?.url) return;
|
||||
if (item.preview) URL.revokeObjectURL(item.preview);
|
||||
Object.assign(item, { status: 'done', url: data.url, preview: undefined, percent: 100 });
|
||||
sync();
|
||||
})
|
||||
.catch((e) => {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
`بارگذاری «${file.name}» ناموفق بود`;
|
||||
message.error(msg);
|
||||
dropItem(uid);
|
||||
});
|
||||
}
|
||||
|
||||
function dropItem(uid: string) {
|
||||
const idx = items.value.findIndex((i) => i.uid === uid);
|
||||
if (idx === -1) return;
|
||||
const [gone] = items.value.splice(idx, 1);
|
||||
if (gone.preview) URL.revokeObjectURL(gone.preview);
|
||||
}
|
||||
|
||||
function remove(uid: string) {
|
||||
dropItem(uid);
|
||||
sync();
|
||||
}
|
||||
|
||||
function preview(item: Item) {
|
||||
previewSrc.value = item.url ?? item.preview ?? '';
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
items.value.forEach((i) => i.preview && URL.revokeObjectURL(i.preview));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gallery-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.gallery-upload__zone {
|
||||
padding: 18px 12px;
|
||||
}
|
||||
.gallery-upload__icon {
|
||||
font-size: 16px;
|
||||
color: #1c2d56;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.gallery-upload__title {
|
||||
margin: 8px 0 2px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #101828;
|
||||
}
|
||||
.gallery-upload__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8a94a6;
|
||||
}
|
||||
|
||||
/* ── square thumbnails ── */
|
||||
.gallery-upload__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.tile {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #eef0f5;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
.tile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.tile.is-uploading img {
|
||||
filter: brightness(0.6);
|
||||
}
|
||||
.tile__progress {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.tile__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: rgba(16, 24, 40, 0.5);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.tile:hover .tile__overlay,
|
||||
.tile:focus-within .tile__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
.tile__btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #1c2d56;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.tile__btn:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.tile__btn--danger {
|
||||
color: #ea5455;
|
||||
}
|
||||
/* touch screens have no hover: keep the actions visible */
|
||||
@media (hover: none) {
|
||||
.tile__overlay {
|
||||
opacity: 1;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
background: linear-gradient(to top, rgba(16, 24, 40, 0.55), transparent 55%);
|
||||
}
|
||||
.tile__btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-upload__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: #8a94a6;
|
||||
}
|
||||
.gallery-upload__pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #1c2d56;
|
||||
}
|
||||
</style>
|
||||
@@ -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,757 @@
|
||||
<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" :class="{ 'x-table--cards': cardMode }">
|
||||
<div class="x-table__toolbar">
|
||||
<div class="x-table__search-group">
|
||||
<a-input-search
|
||||
v-if="!model._hideSearch"
|
||||
v-model:value="query.search"
|
||||
class="x-table__search"
|
||||
placeholder="جستجو"
|
||||
allow-clear
|
||||
@search="onSearch"
|
||||
/>
|
||||
|
||||
<div class="x-table__actions">
|
||||
<a-badge v-if="!model._noFilters" :count="query.filters.length">
|
||||
<a-button type="text" :class="{ 'x-table__action--active': activeFilter }" @click="toggleVisibleFilter">
|
||||
فیلتر
|
||||
<template #icon><FilterOutlined /></template>
|
||||
</a-button>
|
||||
</a-badge>
|
||||
|
||||
<a-tooltip title="چاپ">
|
||||
<a-button type="text" shape="circle">
|
||||
<template #icon><PrinterOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-tooltip title="ستونها">
|
||||
<a-button type="text" shape="circle">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<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-tooltip title="بارگذاری مجدد">
|
||||
<a-button class="refresh-btn" type="text" shape="circle" :loading="loading" @click="fetch()">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- card view has no column headers to click, so sorting moves here -->
|
||||
<a-select
|
||||
v-if="cardMode && sortOptions.length"
|
||||
:value="sortValue"
|
||||
class="x-table__sort"
|
||||
size="small"
|
||||
placeholder="مرتبسازی"
|
||||
allow-clear
|
||||
:options="sortOptions"
|
||||
@change="onSortSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.add" class="x-table__add">
|
||||
<slot name="add" />
|
||||
</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>
|
||||
|
||||
<div v-if="cardMode" class="x-table__cards">
|
||||
<a-spin :spinning="loading">
|
||||
<a-empty v-if="!data.length && !loading" :description="locale.emptyText" />
|
||||
<a-checkbox
|
||||
v-if="hasSelection && data.length"
|
||||
class="x-table__select-all"
|
||||
:checked="allOnPageSelected"
|
||||
:indeterminate="someOnPageSelected && !allOnPageSelected"
|
||||
@change="(e: any) => selectPage(e.target.checked)"
|
||||
>
|
||||
انتخاب همه در این صفحه
|
||||
</a-checkbox>
|
||||
<div
|
||||
v-for="record in data"
|
||||
:key="String(record.id)"
|
||||
class="x-card"
|
||||
:class="{ 'x-card--selected': hasSelection && isSelected(record) }"
|
||||
>
|
||||
<div class="x-card__head">
|
||||
<div class="x-card__title">
|
||||
<a-checkbox
|
||||
v-if="hasSelection"
|
||||
:checked="isSelected(record)"
|
||||
@change="(e: any) => toggleRow(record, e.target.checked)"
|
||||
/>
|
||||
<span v-if="cardMediaCol" class="x-card__media">
|
||||
<slot :name="cardMediaCol.slot" :text="cellValue(record, cardMediaCol)" :record="record" />
|
||||
</span>
|
||||
<span v-if="cardIdCol" class="x-card__id">#{{ cellValue(record, cardIdCol) }}</span>
|
||||
<span class="x-card__name">
|
||||
<template v-for="col in cardTitleCols" :key="col.key">
|
||||
<slot v-if="col.slot" :name="col.slot" :text="cellValue(record, col)" :record="record" />
|
||||
<template v-else>{{ displayValue(record, col) }} </template>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="cardActionCol" class="x-card__actions">
|
||||
<slot :name="cardActionCol.slot" :text="cellValue(record, cardActionCol)" :record="record" />
|
||||
</div>
|
||||
</div>
|
||||
<dl v-if="cardBodyCols.length" class="x-card__body">
|
||||
<div v-for="col in cardBodyCols" :key="col.key" class="x-card__row">
|
||||
<dt>{{ col.title }}</dt>
|
||||
<dd>
|
||||
<slot v-if="col.slot" :name="col.slot" :text="cellValue(record, col)" :record="record" />
|
||||
<template v-else>{{ displayValue(record, col) }}</template>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<div v-if="!model._hidePagination && query.total > 0" class="x-table__card-pager">
|
||||
<span class="x-table__card-total">{{ query.total }} مورد</span>
|
||||
<a-pagination
|
||||
size="small"
|
||||
show-less-items
|
||||
:current="query.page"
|
||||
:page-size="query.limit"
|
||||
:total="query.total"
|
||||
:show-size-changer="false"
|
||||
@change="onCardPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
v-else
|
||||
:columns="antColumns"
|
||||
:data-source="data"
|
||||
:loading="loading"
|
||||
:pagination="paginationConfig"
|
||||
:locale="locale"
|
||||
:row-selection="rowSelection"
|
||||
row-key="id"
|
||||
:size="layout.isMobile ? 'small' : 'middle'"
|
||||
:scroll="layout.isSmallScreen ? { x: 'max-content' } : undefined"
|
||||
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 v-else-if="column.__date">{{ gridDate(text) }}</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { FilterOutlined, PrinterOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
import type { XTbl, Xtc } from './model';
|
||||
import { gridDate } from '../../utils/filters';
|
||||
import { useLayoutStore } from '../../stores/layout';
|
||||
|
||||
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>[];
|
||||
/** On phones, render each row as a card instead of a wide table. Opt-in per page. */
|
||||
mobileCards?: boolean;
|
||||
}>(),
|
||||
{ hasSelection: false, modelValue: () => [], mobileCards: false },
|
||||
);
|
||||
|
||||
const layout = useLayoutStore();
|
||||
const cardMode = computed(() => props.mobileCards && layout.isMobile);
|
||||
|
||||
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: 'بین' },
|
||||
];
|
||||
|
||||
// The model is a plain class instance (see model.ts), so its columns are not
|
||||
// reactive on their own. Wrapping it here lets the column picker's `col.show`
|
||||
// checkbox toggles re-run visibleCols / antColumns.
|
||||
const cols = computed<Xtc[]>(() => (props.model ? (reactive(props.model) as XTbl).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,
|
||||
__date: c.filterType === 'date',
|
||||
})),
|
||||
);
|
||||
|
||||
function slotNameFor(column: { __slot?: string }) {
|
||||
return column.__slot;
|
||||
}
|
||||
|
||||
// ── phone card view ──────────────────────────────────────────────────
|
||||
interface CardCol {
|
||||
key: string;
|
||||
title: string;
|
||||
path: string[];
|
||||
slot?: string;
|
||||
date: boolean;
|
||||
}
|
||||
|
||||
const ACTION_KEYS = ['opt', 'op', 'action'];
|
||||
|
||||
const cardCols = computed<CardCol[]>(() =>
|
||||
visibleCols.value.map((c: Xtc) => ({
|
||||
key: c.key,
|
||||
title: c.title,
|
||||
path: c.dataIndex.split('.'),
|
||||
slot: c.scopedSlots?.customRender,
|
||||
date: c.filterType === 'date',
|
||||
})),
|
||||
);
|
||||
const cardActionCol = computed(() => cardCols.value.find((c) => ACTION_KEYS.includes(c.key) && c.slot));
|
||||
const cardIdCol = computed(() => cardCols.value.find((c) => c.key === 'id'));
|
||||
const cardMediaCol = computed(() => cardCols.value.find((c) => c.slot === 'image'));
|
||||
const cardTitleCols = computed<CardCol[]>(() => {
|
||||
const rest = cardCols.value.filter(
|
||||
(c) => c !== cardActionCol.value && c !== cardIdCol.value && c !== cardMediaCol.value,
|
||||
);
|
||||
const named = rest.find((c) => c.key === 'name' || c.key === 'title');
|
||||
if (named) return [named];
|
||||
const fname = rest.find((c) => c.key === 'fname');
|
||||
if (fname) return [fname, ...rest.filter((c) => c.key === 'lname')];
|
||||
return rest.slice(0, 1);
|
||||
});
|
||||
const cardBodyCols = computed(() =>
|
||||
cardCols.value.filter(
|
||||
(c) =>
|
||||
c !== cardActionCol.value &&
|
||||
c !== cardIdCol.value &&
|
||||
c !== cardMediaCol.value &&
|
||||
!cardTitleCols.value.includes(c),
|
||||
),
|
||||
);
|
||||
|
||||
// card-mode selection, same v-model contract as the table's rowSelection
|
||||
function isSelected(record: Record<string, unknown>) {
|
||||
return props.modelValue.some((r) => r.id === record.id);
|
||||
}
|
||||
function toggleRow(record: Record<string, unknown>, checked: boolean) {
|
||||
const rest = props.modelValue.filter((r) => r.id !== record.id);
|
||||
emit('update:modelValue', checked ? [...rest, record] : rest);
|
||||
}
|
||||
const allOnPageSelected = computed(() => data.value.length > 0 && data.value.every(isSelected));
|
||||
const someOnPageSelected = computed(() => data.value.some(isSelected));
|
||||
function selectPage(checked: boolean) {
|
||||
const pageIds = new Set(data.value.map((r) => r.id));
|
||||
const rest = props.modelValue.filter((r) => !pageIds.has(r.id));
|
||||
emit('update:modelValue', checked ? [...rest, ...data.value] : rest);
|
||||
}
|
||||
|
||||
function cellValue(record: Record<string, unknown>, col: CardCol): unknown {
|
||||
return col.path.reduce<unknown>(
|
||||
(v, k) => (v && typeof v === 'object' ? (v as Record<string, unknown>)[k] : undefined),
|
||||
record,
|
||||
);
|
||||
}
|
||||
|
||||
function displayValue(record: Record<string, unknown>, col: CardCol) {
|
||||
const v = cellValue(record, col);
|
||||
if (v === null || v === undefined || v === '') return '-';
|
||||
return col.date ? gridDate(v) : v;
|
||||
}
|
||||
|
||||
const sortOptions = computed(() =>
|
||||
visibleCols.value
|
||||
.filter((c) => c.__sorter)
|
||||
.flatMap((c) => [
|
||||
{ value: `${c.key}|asc`, label: `${c.title} ↑` },
|
||||
{ value: `${c.key}|desc`, label: `${c.title} ↓` },
|
||||
]),
|
||||
);
|
||||
const sortValue = computed(() => (sort.value ? `${sort.value}|${sortDirection.value}` : undefined));
|
||||
|
||||
function onSortSelect(value?: string) {
|
||||
if (value) {
|
||||
const [key, dir] = value.split('|');
|
||||
sort.value = key;
|
||||
sortDirection.value = dir as 'asc' | 'desc';
|
||||
} else {
|
||||
sort.value = undefined;
|
||||
}
|
||||
query.page = 1;
|
||||
fetch();
|
||||
}
|
||||
|
||||
function onCardPage(page: number, pageSize: number) {
|
||||
query.page = page;
|
||||
query.limit = pageSize;
|
||||
fetch();
|
||||
document.getElementById('content-area')?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
.x-table__search-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.x-table__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.x-table__action--active {
|
||||
color: #1677ff;
|
||||
}
|
||||
.x-table__add {
|
||||
display: flex;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.x-table__search {
|
||||
max-width: 260px;
|
||||
}
|
||||
.x-table__sort {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
/* ── phone card view ── */
|
||||
.x-table__cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.x-table__cards :deep(.ant-spin-container) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 80px;
|
||||
}
|
||||
.x-card {
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.x-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #eef0f3;
|
||||
}
|
||||
.x-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.x-card__id {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #8a909c;
|
||||
background: #eef0f3;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
direction: ltr;
|
||||
}
|
||||
.x-card__name {
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.x-card__actions {
|
||||
flex-shrink: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.x-card--selected {
|
||||
border-color: #91caff;
|
||||
}
|
||||
.x-card--selected .x-card__head {
|
||||
background: #f0f7ff;
|
||||
}
|
||||
.x-card__media {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
}
|
||||
.x-card__media :deep(img) {
|
||||
width: 44px !important;
|
||||
height: 44px !important;
|
||||
object-fit: cover !important;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.x-table__select-all {
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
.x-card__body {
|
||||
margin: 0;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.x-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.x-card__row + .x-card__row {
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
.x-card__row dt {
|
||||
color: #8a909c;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.x-card__row dd {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.x-card__row dd :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
.x-table__card-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.x-table__card-total {
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.x-table__toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/* flatten so search takes row one and actions + add share row two */
|
||||
.x-table__search-group {
|
||||
display: contents;
|
||||
}
|
||||
.x-table__search {
|
||||
max-width: none;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.x-table__actions {
|
||||
gap: 2px;
|
||||
}
|
||||
.x-table__sort {
|
||||
width: 130px;
|
||||
}
|
||||
.x-table__add {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.x-table__filters {
|
||||
padding: 10px;
|
||||
}
|
||||
.x-table__filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #e8e8e8;
|
||||
}
|
||||
/* the filter inputs carry fixed inline widths for desktop */
|
||||
.x-table__filter-row > * {
|
||||
width: 100% !important;
|
||||
}
|
||||
.x-table__filter-row > :nth-child(n + 3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.x-table__filter-actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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;
|
||||
// The record id ('کد') is kept out of every table by default. It can still be
|
||||
// turned on from the column picker.
|
||||
if (key === 'id') this.show = false;
|
||||
}
|
||||
|
||||
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,84 @@
|
||||
// The update gate (scripts/app-version.mjs builds the other half).
|
||||
//
|
||||
// The bundle knows its own build number (__APP_BUILD__). Every few minutes,
|
||||
// and whenever the app comes back to the foreground, it reads version.json
|
||||
// from the server, bypassing every cache. A newer build there means a release
|
||||
// went out: the modal lists what changed since the build this tab is running,
|
||||
// and «بهروزرسانی» drops the service worker and every Cache Storage entry,
|
||||
// then reloads — so the next load is the new release, not a precached copy.
|
||||
// localStorage is left alone: the sign-in and settings survive the update.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
declare const __APP_BUILD__: number
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
export interface VersionInfo {
|
||||
build: number
|
||||
version: string
|
||||
history: { build: number; date: string; notes: string[] }[]
|
||||
}
|
||||
|
||||
const CHECK_EVERY_MS = 5 * 60 * 1000
|
||||
|
||||
export const runningBuild: number = typeof __APP_BUILD__ === 'number' ? __APP_BUILD__ : 0
|
||||
export const runningVersion: string = typeof __APP_VERSION__ === 'string' ? __APP_VERSION__ : ''
|
||||
|
||||
export function useAppUpdate() {
|
||||
const available = ref<VersionInfo | null>(null)
|
||||
const updating = ref(false)
|
||||
|
||||
/** What changed between the running build and the server's, newest first. */
|
||||
const changes = computed(() => {
|
||||
const info = available.value
|
||||
if (!info) return []
|
||||
return (info.history ?? []).filter((h) => h.build > runningBuild && h.build <= info.build)
|
||||
})
|
||||
|
||||
async function check() {
|
||||
if (import.meta.env.DEV || !runningBuild || available.value) return
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.BASE_URL}version.json?t=${Date.now()}`, { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const info = (await res.json()) as VersionInfo
|
||||
if (typeof info.build === 'number' && info.build > runningBuild) available.value = info
|
||||
} catch {
|
||||
// Offline or a deploy in progress: try again on the next tick.
|
||||
}
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
updating.value = true
|
||||
try {
|
||||
const regs = await navigator.serviceWorker?.getRegistrations?.()
|
||||
if (regs) await Promise.all(regs.map((r) => r.unregister()))
|
||||
} catch {
|
||||
/* no service worker */
|
||||
}
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map((k) => caches.delete(k)))
|
||||
}
|
||||
} catch {
|
||||
/* Cache Storage unavailable */
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') check()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
check()
|
||||
timer = setInterval(check, CHECK_EVERY_MS)
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
})
|
||||
|
||||
return { available, changes, updating, applyUpdate, check }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Shared "delete a row, refresh the list, show the server's refusal reason"
|
||||
// handler. This exact try/catch body — api.delete, then either a success
|
||||
// toast + refetch, or Modal.error with the server's message — was copy-pasted
|
||||
// verbatim in 6 list views (city/cityList.vue, user/ListUser.vue,
|
||||
// apps/category/List.vue, finical/list.vue, apps/product/ProductList.vue,
|
||||
// manage/Const.vue). Centralised here as a composable rather than a component
|
||||
// since it is pure behaviour with no template.
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import api from '../api/client';
|
||||
|
||||
export function useDeleteHandler(options: {
|
||||
/** Called after a confirmed, successful delete — typically `table.value?.fetch()`. */
|
||||
onSuccess: () => void | Promise<void>;
|
||||
successMessage?: string;
|
||||
/** Some call sites used message.info rather than message.success; preserved
|
||||
* per-site rather than silently unified. */
|
||||
successType?: 'success' | 'info';
|
||||
/** Shown when the server didn't return its own message. */
|
||||
fallbackErrorMessage?: string;
|
||||
}) {
|
||||
const {
|
||||
onSuccess,
|
||||
successMessage = 'با موفقیت حذف شد',
|
||||
successType = 'success',
|
||||
fallbackErrorMessage = 'خطا در حذف',
|
||||
} = options;
|
||||
|
||||
async function remove(url: string, opts?: { method?: 'delete' | 'post' }) {
|
||||
try {
|
||||
// ProductList's delete is a legacy irregular endpoint (POST, not
|
||||
// DELETE) — see docs/ADMIN-PORT-PLAN.md's endpoint-mapping notes.
|
||||
// `method` lets that call site keep using this composable too.
|
||||
if (opts?.method === 'post') {
|
||||
await api.post(url);
|
||||
} else {
|
||||
await api.delete(url);
|
||||
}
|
||||
message[successType](successMessage);
|
||||
await onSuccess();
|
||||
} catch (e: any) {
|
||||
// The backend refuses deletion while other records still reference the
|
||||
// row (e.g. a city with customers/branches, a brand with products).
|
||||
Modal.error({
|
||||
title: e?.response?.data?.message ?? fallbackErrorMessage,
|
||||
okText: 'بستن',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { remove };
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
// vue3-apexcharts registers <apexchart> globally via app.use() (main.ts); this
|
||||
// lets template type-checking (vue-tsc) resolve the tag instead of erroring
|
||||
// on an unknown component.
|
||||
import type { VueApexChartsComponentProps } from 'vue3-apexcharts';
|
||||
declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
apexchart: import('vue').DefineComponent<VueApexChartsComponentProps>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<!--
|
||||
Recursive renderer for one navMenuItems.ts node, used by VerticalNavMenu.vue.
|
||||
Extracted so a submenu can itself contain a submenu (e.g. "پیکربندی" nesting
|
||||
"کیف پول" nesting "لیست کیف پولهای شارژ شده") — ant-design-vue's a-sub-menu
|
||||
nests natively, the fixed two-level template just didn't recurse into it.
|
||||
-->
|
||||
<a-sub-menu v-if="node.submenu" :key="menuKey" popup-class-name="nav-popup">
|
||||
<template #icon><component :is="resolveIcon(node.icon)" v-if="node.icon" /></template>
|
||||
<template #title>{{ node.i18n || node.name }}</template>
|
||||
<NavMenuNode
|
||||
v-for="(child, k) in visibleChildren(node.submenu)"
|
||||
:key="child.url || `${menuKey}-${k}`"
|
||||
:node="child"
|
||||
:menu-key="child.url || `${menuKey}-${k}`"
|
||||
/>
|
||||
</a-sub-menu>
|
||||
|
||||
<a-menu-item v-else :key="menuKey">
|
||||
<template #icon><component :is="resolveIcon(node.icon)" v-if="node.icon" /></template>
|
||||
{{ node.i18n || node.name }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import { resolveIcon } from '../../components/icons';
|
||||
import type { NavMenuNode as NavMenuNodeType } from '../navMenuItems';
|
||||
|
||||
defineProps<{ node: NavMenuNodeType; menuKey: string }>();
|
||||
|
||||
const users = useUsersStore();
|
||||
|
||||
// Same visibility rule as VerticalNavMenu.vue: no permissionId = always visible.
|
||||
function canSee(node: NavMenuNodeType): boolean {
|
||||
if (node.superAdminOnly && !users.isSuperAdmin) return false;
|
||||
if (!node.permissionId) return true;
|
||||
return Boolean(users.hasPermission(node.permissionId));
|
||||
}
|
||||
|
||||
function visibleChildren(nodes?: NavMenuNodeType[]): NavMenuNodeType[] {
|
||||
return (nodes ?? []).filter(canSee);
|
||||
}
|
||||
|
||||
defineOptions({ name: 'NavMenuNode' });
|
||||
</script>
|
||||
@@ -0,0 +1,256 @@
|
||||
<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>
|
||||
<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="nav-right">
|
||||
<a-dropdown trigger="click" placement="bottomRight" :overlay-class-name="'user-dropdown-overlay'">
|
||||
<span class="user-chip">
|
||||
<a-avatar size="small" style="background-color: #1c2d56">
|
||||
{{ initial }}
|
||||
</a-avatar>
|
||||
<span class="user-name">{{ displayName }}</span>
|
||||
</span>
|
||||
<template #overlay>
|
||||
<div class="user-card">
|
||||
<div class="user-card__head">
|
||||
<a-avatar size="large" style="background-color: #1c2d56">
|
||||
{{ initial }}
|
||||
</a-avatar>
|
||||
<div class="user-card__id">
|
||||
<span class="user-card__name">{{ displayName }}</span>
|
||||
<span class="user-card__mobile">{{ auth.user?.mobile }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider class="user-card__divider" />
|
||||
|
||||
<a-menu class="user-card__menu">
|
||||
<a-menu-item key="my-data" @click="router.push('/my-data')">
|
||||
<UserOutlined />
|
||||
اطلاعات من
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="canSeePermissions" key="permission" @click="router.push('/permission')">
|
||||
<SafetyCertificateOutlined />
|
||||
دسترسی ها
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
|
||||
<a-divider class="user-card__divider" />
|
||||
|
||||
<a-menu class="user-card__menu">
|
||||
<a-menu-item key="logout" @click="onLogout">
|
||||
<LogoutOutlined />
|
||||
خروج
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</div>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
|
||||
interface Crumb {
|
||||
title: string;
|
||||
url?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
defineProps<{ collapsed?: boolean }>();
|
||||
defineEmits<{ (e: 'toggle-sidebar'): void }>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const users = useUsersStore();
|
||||
|
||||
const pageTitle = computed(() => (route.meta?.pageTitle as string) ?? '');
|
||||
const breadcrumb = computed(() => route.meta?.breadcrumb as Crumb[] | undefined);
|
||||
const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'کاربر');
|
||||
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
|
||||
const canSeePermissions = computed(() => Boolean(users.hasPermission('user.permission')));
|
||||
|
||||
async function onLogout() {
|
||||
const left = await auth.logout();
|
||||
if (!left) 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;
|
||||
}
|
||||
.nav-left {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.nav-right {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
color: #1c2d56;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 13px;
|
||||
}
|
||||
.user-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.the-navbar {
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
padding: 0 8px 0 16px;
|
||||
}
|
||||
.nav-left {
|
||||
gap: 4px;
|
||||
}
|
||||
.breadcrumb {
|
||||
display: none;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@media (max-width: 575px) {
|
||||
.user-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!--
|
||||
Unscoped: ant-design-vue's <a-dropdown> teleports its #overlay to <body>, so
|
||||
a `scoped` block (which relies on a component-instance data attribute) never
|
||||
matches it. Reach is contained instead by :overlay-class-name="'user-dropdown-overlay'"
|
||||
above, so every selector below is namespaced under that one class.
|
||||
-->
|
||||
<style>
|
||||
.user-dropdown-overlay .ant-dropdown-menu {
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card {
|
||||
width: 220px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 28px -8px rgba(28, 45, 86, 0.28);
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 2px 4px 8px;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__id {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1c2d56;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__mobile {
|
||||
font-size: 12px;
|
||||
color: #8a909c;
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__divider {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__menu.ant-menu {
|
||||
border-inline-end: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
margin: 0;
|
||||
padding-inline: 6px !important;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__menu .ant-menu-item:hover {
|
||||
background: #f4f5f6;
|
||||
color: #1c2d56;
|
||||
}
|
||||
|
||||
.user-dropdown-overlay .user-card__menu .anticon {
|
||||
font-size: 15px;
|
||||
color: #8a909c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,430 @@
|
||||
<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" :class="{ 'is-collapsed': collapsed }">
|
||||
<span class="nav-logo">
|
||||
<img src="../../assets/images/logo/brand-icon.png" alt="" />
|
||||
</span>
|
||||
<span v-if="!collapsed" class="nav-brand">
|
||||
<span class="nav-title">{{ title }}</span>
|
||||
<span class="nav-subtitle">پنل مدیریت</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
class="nav-menu"
|
||||
mode="inline"
|
||||
theme="light"
|
||||
:inline-collapsed="collapsed"
|
||||
: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` (ant has no icon
|
||||
slot on a menu-item-group; it's a label-only divider, so its
|
||||
children still carry their own icons below). Each child renders
|
||||
through NavMenuNode, which recurses into however many submenu
|
||||
levels it has. -->
|
||||
<a-menu-item-group v-if="node.header" :title="node.i18n || node.header">
|
||||
<NavMenuNode
|
||||
v-for="(child, j) in visibleChildren(node.items)"
|
||||
:key="child.url || `${i}-${j}`"
|
||||
:node="child"
|
||||
:menu-key="child.url || subKey(child, j)"
|
||||
/>
|
||||
</a-menu-item-group>
|
||||
|
||||
<!-- group / leaf node -->
|
||||
<NavMenuNode v-else :node="node" :menu-key="node.url || subKey(node, i)" />
|
||||
</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 as NavMenuNodeData } from '../navMenuItems';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import { resolveIcon } from '../../components/icons';
|
||||
import NavMenuNode from './NavMenuNode.vue';
|
||||
import { brand } from '../../brand';
|
||||
|
||||
withDefaults(defineProps<{ title?: string; collapsed?: boolean }>(), {
|
||||
title: brand.name,
|
||||
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: NavMenuNodeData): boolean {
|
||||
if (node.superAdminOnly && !users.isSuperAdmin) return false;
|
||||
if (!node.permissionId) return true;
|
||||
return Boolean(users.hasPermission(node.permissionId));
|
||||
}
|
||||
|
||||
function visibleChildren(nodes?: NavMenuNodeData[]): NavMenuNodeData[] {
|
||||
return (nodes ?? []).filter(canSee);
|
||||
}
|
||||
|
||||
/** Hides a group whose children are all hidden, as legacy did. */
|
||||
function hasVisibleContent(node: NavMenuNodeData): 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: NavMenuNodeData, i: number) => `sub-${node.name ?? ''}-${i}`;
|
||||
|
||||
/**
|
||||
* Finds the chain of sub-menu keys that must be open so the current route's
|
||||
* menu item is visible, at any nesting depth. Mirrors the key each
|
||||
* NavMenuNode instance is actually given (node.url, or menuKey-derived for
|
||||
* url-less group nodes), so a match here really is a key ant's a-menu knows
|
||||
* about.
|
||||
*/
|
||||
function findOpenKeysIn(nodes: NavMenuNodeData[], path: string, parentKey: string): string[] | null {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const key = node.url || `${parentKey}-${i}`;
|
||||
if (!node.submenu) {
|
||||
if (node.url === path) return [];
|
||||
continue;
|
||||
}
|
||||
const nested = findOpenKeysIn(visibleChildren(node.submenu), path, key);
|
||||
if (nested) return [key, ...nested];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the sub-menu key(s) that must be open so the current route's menu
|
||||
* item is visible. Previously openKeys only ever changed via user clicks
|
||||
* (@update:openKeys), so a direct link or a page refresh landed with every
|
||||
* submenu collapsed even when the active item was nested inside one.
|
||||
*/
|
||||
function findOpenKeysFor(path: string): string[] {
|
||||
for (let i = 0; i < visibleMenu.value.length; i++) {
|
||||
const node = visibleMenu.value[i];
|
||||
|
||||
if (node.header) {
|
||||
const children = visibleChildren(node.items);
|
||||
for (let j = 0; j < children.length; j++) {
|
||||
const child = children[j];
|
||||
if (!child.submenu) continue;
|
||||
const childKey = child.url || subKey(child, j);
|
||||
const nested = findOpenKeysIn(visibleChildren(child.submenu), path, childKey);
|
||||
if (nested) return [childKey, ...nested];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.submenu) {
|
||||
const nodeKey = node.url || subKey(node, i);
|
||||
const nested = findOpenKeysIn(visibleChildren(node.submenu), path, nodeKey);
|
||||
if (nested) return [nodeKey, ...nested];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedKeys = ref<string[]>([route.path]);
|
||||
const openKeys = ref<string[]>(findOpenKeysFor(route.path));
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(p) => {
|
||||
selectedKeys.value = [p];
|
||||
const needed = findOpenKeysFor(p);
|
||||
for (const key of needed) {
|
||||
if (!openKeys.value.includes(key)) openKeys.value = [...openKeys.value, key];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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 {
|
||||
height: 100vh;
|
||||
border-left: 1px solid #eef0f5;
|
||||
box-shadow: -4px 0 24px -16px rgba(16, 24, 40, 0.12);
|
||||
}
|
||||
.vertical-nav-menu :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.nav-header {
|
||||
height: 61px; /* legacy ara/ant.less @layout-header-height */
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #eef0f5;
|
||||
}
|
||||
.nav-header.is-collapsed {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.nav-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef0f5;
|
||||
box-shadow: 0 4px 12px -6px rgba(28, 45, 86, 0.35);
|
||||
}
|
||||
.nav-logo img {
|
||||
width: 64%;
|
||||
}
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #1c2d56;
|
||||
}
|
||||
.nav-subtitle {
|
||||
font-size: 11px;
|
||||
color: #8a94a6;
|
||||
}
|
||||
/* pinned in place; only the menu scrolls if it's taller than the viewport */
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 0 24px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #e3e7ef transparent;
|
||||
}
|
||||
.nav-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.nav-menu::-webkit-scrollbar-thumb {
|
||||
border-radius: 6px;
|
||||
background: #e3e7ef;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!--
|
||||
Unscoped: the mobile drawer teleports outside #app and ant's collapsed popups
|
||||
teleport to <body>. :is(#app, ...) also out-ranks utilities.less's
|
||||
`body #app .ant-menu-item-selected`.
|
||||
-->
|
||||
<style>
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .nav-menu.ant-menu {
|
||||
border-inline-end: none !important;
|
||||
background: transparent;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
/* section titles */
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item-group-title {
|
||||
padding: 18px 24px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
color: #a0a8b8;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline-collapsed .ant-menu-item-group-title {
|
||||
padding: 12px 0 4px;
|
||||
text-align: center;
|
||||
font-size: 0;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline-collapsed .ant-menu-item-group-title::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: #e3e7ef;
|
||||
}
|
||||
|
||||
/* items + submenu titles */
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline .ant-menu-item,
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline .ant-menu-submenu-title {
|
||||
height: 42px;
|
||||
line-height: 42px;
|
||||
margin: 2px 12px;
|
||||
width: calc(100% - 24px);
|
||||
border-radius: 10px;
|
||||
color: #475467;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item .ant-menu-item-icon,
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-title .ant-menu-item-icon {
|
||||
font-size: 16px;
|
||||
color: #98a2b3;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item:not(.ant-menu-item-selected):hover,
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-title:hover {
|
||||
background: #f5f7fb !important;
|
||||
color: #1c2d56 !important;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item:hover .ant-menu-item-icon,
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-title:hover .ant-menu-item-icon {
|
||||
color: #1c2d56;
|
||||
}
|
||||
|
||||
/* active item */
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item-selected {
|
||||
position: relative;
|
||||
background: rgba(28, 45, 86, 0.08) !important;
|
||||
color: #1c2d56 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||
color: #1c2d56;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-inline .ant-menu-item-selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 3px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: #1c2d56;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-selected > .ant-menu-submenu-title,
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-submenu-selected > .ant-menu-submenu-title .ant-menu-item-icon {
|
||||
color: #1c2d56;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* nested lists: no grey slab, a soft guide line instead */
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-sub.ant-menu-inline {
|
||||
position: relative;
|
||||
background: transparent !important;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-sub.ant-menu-inline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 32px;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 1px;
|
||||
background: #e8ebf2;
|
||||
}
|
||||
:is(#app, .mobile-nav-drawer) .vertical-nav-menu .ant-menu-sub .ant-menu-item {
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* collapsed rail: submenus open as popups teleported to <body> */
|
||||
.nav-popup.ant-menu-submenu-popup > .ant-menu {
|
||||
padding: 6px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef0f5;
|
||||
box-shadow: 0 12px 32px -12px rgba(16, 24, 40, 0.25);
|
||||
}
|
||||
.nav-popup .ant-menu-item,
|
||||
.nav-popup .ant-menu-submenu-title {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
margin: 2px 0;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
.nav-popup .ant-menu-item .ant-menu-item-icon,
|
||||
.nav-popup .ant-menu-submenu-title .ant-menu-item-icon {
|
||||
color: #98a2b3;
|
||||
}
|
||||
.nav-popup .ant-menu-item:not(.ant-menu-item-selected):hover,
|
||||
.nav-popup .ant-menu-submenu-title:hover {
|
||||
background: #f5f7fb !important;
|
||||
color: #1c2d56 !important;
|
||||
}
|
||||
.nav-popup .ant-menu-item-selected {
|
||||
position: relative;
|
||||
background: rgba(28, 45, 86, 0.08) !important;
|
||||
color: #1c2d56 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-popup .ant-menu-item-selected .ant-menu-item-icon {
|
||||
color: #1c2d56;
|
||||
}
|
||||
.nav-popup .ant-menu-item-selected::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: auto;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
width: 3px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: #1c2d56;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.nav-popup .ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||
color: #1c2d56;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* collapsed rail: the icon of the group holding the active page */
|
||||
#app .vertical-nav-menu .ant-menu-inline-collapsed .ant-menu-submenu-selected > .ant-menu-submenu-title,
|
||||
#app .vertical-nav-menu .ant-menu-inline-collapsed > .ant-menu-item-selected {
|
||||
background: rgba(28, 45, 86, 0.08) !important;
|
||||
color: #1c2d56 !important;
|
||||
}
|
||||
</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,146 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/layouts/main/Main.vue (281 lines).
|
||||
Structure preserved: nav menu + navbar + routed content. Dropped as
|
||||
template-only or Vue-2-only: VxTour, TheCustomizer, vue-backtotop, and the
|
||||
Vuesax transition wrappers. The breadcrumb/pageTitle header row that used
|
||||
to sit above the content as its own row now lives inside TheNavbar, to
|
||||
give pages full-height room for their card.
|
||||
|
||||
Layout is pinned viewport-height: the sidebar and navbar never move, and
|
||||
only #content-area scrolls — instead of the whole page scrolling under a
|
||||
sidebar/navbar that used to scroll away with it.
|
||||
-->
|
||||
<a-layout class="layout--main" :class="{ 'no-scroll': isAppPage }">
|
||||
<VerticalNavMenu
|
||||
v-if="!layout.isSmallScreen"
|
||||
:collapsed="collapsed"
|
||||
show-toggle
|
||||
@toggle="onToggleSidebar"
|
||||
/>
|
||||
|
||||
<a-drawer
|
||||
v-else
|
||||
:open="layout.mobileNavOpen"
|
||||
placement="right"
|
||||
:width="260"
|
||||
:closable="false"
|
||||
root-class-name="mobile-nav-drawer"
|
||||
@close="layout.setMobileNavOpen(false)"
|
||||
>
|
||||
<VerticalNavMenu />
|
||||
</a-drawer>
|
||||
|
||||
<a-layout>
|
||||
<TheNavbar
|
||||
:collapsed="layout.isSmallScreen ? !layout.mobileNavOpen : collapsed"
|
||||
@toggle-sidebar="onToggleSidebar"
|
||||
/>
|
||||
|
||||
<a-layout-content id="content-area">
|
||||
<div class="content-wrapper">
|
||||
<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';
|
||||
import { useLayoutStore } from '../../stores/layout';
|
||||
|
||||
const route = useRoute();
|
||||
const layout = useLayoutStore();
|
||||
// Persisted via stores/layout.ts (localStorage) so the sidebar stays
|
||||
// collapsed/expanded across reloads instead of resetting every time.
|
||||
const collapsed = computed(() => layout.sidebarCollapsed);
|
||||
|
||||
function onToggleSidebar() {
|
||||
if (layout.isSmallScreen) layout.setMobileNavOpen(!layout.mobileNavOpen);
|
||||
else layout.setSidebarCollapsed(!collapsed.value);
|
||||
}
|
||||
|
||||
// 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. The
|
||||
// heading itself now lives in TheNavbar, so this only resets on navigation;
|
||||
// nothing currently emits changeRouteTitle, but the wiring is kept in case a
|
||||
// view starts overriding its own title again.
|
||||
const overriddenTitle = ref<string | undefined>(undefined);
|
||||
|
||||
function changeRouteTitle(title: string) {
|
||||
overriddenTitle.value = title;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
overriddenTitle.value = undefined;
|
||||
layout.setMobileNavOpen(false);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => layout.isSmallScreen,
|
||||
(small) => {
|
||||
if (!small) layout.setMobileNavOpen(false);
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout--main {
|
||||
/* --vh is set from window.innerHeight in App.vue, so mobile browser chrome
|
||||
(address bar) is excluded from the height */
|
||||
height: calc(var(--vh, 1vh) * 100);
|
||||
overflow: hidden; /* the sidebar and navbar are pinned; only #content-area scrolls */
|
||||
}
|
||||
.layout--main > :deep(.ant-layout) {
|
||||
height: 100%;
|
||||
}
|
||||
#content-area {
|
||||
padding: 16px;
|
||||
background: #f8f8f8;
|
||||
/* Stretch the routed page (and its card) to fill the viewport height below
|
||||
the navbar, instead of the card sizing to its content and leaving a gray
|
||||
gap underneath on short lists. This is also the ONLY element that
|
||||
scrolls — the navbar above it and the sidebar beside it stay fixed. */
|
||||
display: flex;
|
||||
height: calc(var(--vh, 1vh) * 100 - 61px); /* 61px matches TheNavbar's fixed height */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
#content-area {
|
||||
height: calc(var(--vh, 1vh) * 100 - 56px); /* TheNavbar is 56px below 992 */
|
||||
}
|
||||
}
|
||||
.content-wrapper,
|
||||
.content-area__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Unscoped: <a-drawer> teleports to <body>; contained by root-class-name. -->
|
||||
<style>
|
||||
.mobile-nav-drawer .ant-drawer-body {
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
.mobile-nav-drawer .vertical-nav-menu {
|
||||
height: 100%;
|
||||
border-left: none;
|
||||
background: #ffffff !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,666 @@
|
||||
// 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):
|
||||
// - several nodes repeat the same permissionId or reuse another view's `name`
|
||||
//
|
||||
// Two further legacy quirks were fixed rather than preserved, since both were
|
||||
// bugs (not intentional behaviour) and each has since caused real confusion:
|
||||
// - banker-list / shopkeeper-list had their permissionId commented out, so
|
||||
// the items showed for everyone even though the backing endpoints
|
||||
// (admin/customers-omde/banker|shopkeeper/list) require
|
||||
// persons.list.client.omde and 403'd on an unauthorized click. Filled in.
|
||||
// - "newsletters.newsletters" never matched any rid in ALL_RIDS (the real
|
||||
// one is "newsletters"), so the item was invisible to every user,
|
||||
// including super-admin. Corrected to "newsletters".
|
||||
// "wallet.list" is a real, seeded rid (see acl.constants.ts) and was never
|
||||
// actually broken — the old note above was itself mistaken.
|
||||
import { isBranch } from '../utils';
|
||||
|
||||
export interface NavMenuNode {
|
||||
url?: string | null;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
icon?: string;
|
||||
i18n?: string;
|
||||
tag?: string;
|
||||
tagColor?: string;
|
||||
permissionId?: string;
|
||||
/** Shown to the super admin only, whatever the rid (see utils/superAdmin.ts). */
|
||||
superAdminOnly?: boolean;
|
||||
header?: string;
|
||||
items?: NavMenuNode[];
|
||||
submenu?: NavMenuNode[];
|
||||
}
|
||||
|
||||
export const adminMenu: NavMenuNode[] = [
|
||||
{
|
||||
// "آمار عمده فروشی" and "آمار تک فروشی" were two submenu entries behind
|
||||
// two rids, pointing at two near-duplicate dashboards. Merged into one
|
||||
// link behind one rid (dashboard.view) — see Dashboard.vue.
|
||||
url: '/dashboard/ecommerce',
|
||||
name: 'پیشخوان',
|
||||
// tag: '2',
|
||||
tagColor: 'warning',
|
||||
icon: 'HomeIcon',
|
||||
i18n: 'پیشخوان',
|
||||
permissionId:"dashboard.view",
|
||||
},
|
||||
{
|
||||
header: 'Apps',
|
||||
icon: 'PackageIcon',
|
||||
i18n: 'کاربری',
|
||||
items: [
|
||||
{
|
||||
url: null,
|
||||
name: 'User',
|
||||
icon: 'UserIcon',
|
||||
i18n: 'اشخاص',
|
||||
permissionId:"persons",
|
||||
submenu: [
|
||||
// Restored per request: the add button on OmdeList only covers
|
||||
// wholesale customers, so removing this entry left no nav path to
|
||||
// the general "add person" form.
|
||||
{
|
||||
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",
|
||||
superAdminOnly: true
|
||||
},
|
||||
{
|
||||
url: '/customer/banker-list',
|
||||
name: 'List',
|
||||
slug: 'banker-list',
|
||||
i18n: 'لیست بنکداران',
|
||||
// Legacy left this rid commented out, so the item showed for
|
||||
// everyone regardless of permission — a bug, not a feature: the
|
||||
// API route it points at (admin/customers-omde/banker/list) is
|
||||
// gated on exactly this rid, so an unauthorized click 403'd.
|
||||
// Filled in to match what the server actually enforces.
|
||||
permissionId:"persons.list.client.omde",
|
||||
superAdminOnly: true
|
||||
},
|
||||
{
|
||||
// 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: 'لیست مغازه داران حضوری',
|
||||
// Same fix as banker-list above — the backing endpoint
|
||||
// (admin/customers-omde/shopkeeper/list) requires this rid.
|
||||
permissionId:"persons.list.client.omde",
|
||||
superAdminOnly: true
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
// Grouped per request: "شعب" and "کارگاه" used to be two separate
|
||||
// top-level entries; they are now one group with each nested inside
|
||||
// as its own submenu, same shape as the other grouped sections.
|
||||
url: null,
|
||||
name: 'branch and workshop',
|
||||
icon: 'GridIcon',
|
||||
i18n: 'شعب و کارگاه',
|
||||
superAdminOnly: true,
|
||||
submenu: [
|
||||
{
|
||||
url: null,
|
||||
name: 'Branch',
|
||||
icon: 'GridIcon',
|
||||
i18n: 'شعب',
|
||||
permissionId:"branch",
|
||||
submenu: [
|
||||
// 'افزودن شعب' removed: it is now the add button on the branch
|
||||
// list page. The /branch/add route still works.
|
||||
{
|
||||
url: '/branch/list',
|
||||
name: 'BranchList',
|
||||
slug: 'branch-list',
|
||||
i18n: 'لیست شعب',
|
||||
permissionId:"branch.list"
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
url: null,
|
||||
name: 'Workshop',
|
||||
icon: 'GridIcon',
|
||||
i18n: 'کارگاه',
|
||||
permissionId:"workshop",
|
||||
submenu: [
|
||||
// 'افزودن کارگاه' removed: it is now the add button on the
|
||||
// workshop list page. The /workshop/add route still works.
|
||||
{
|
||||
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",
|
||||
superAdminOnly: true
|
||||
},
|
||||
{
|
||||
url: '/order/list',
|
||||
name: 'orderList',
|
||||
slug: 'order-list',
|
||||
i18n: 'لیست سفارش های تک',
|
||||
permissionId:"order.list",
|
||||
superAdminOnly: true
|
||||
},
|
||||
{
|
||||
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"
|
||||
},
|
||||
// 'افزودن مدیر' removed: it is now the add button on the admin list
|
||||
// page (ListUser). The /user/create route still works.
|
||||
{
|
||||
url: '/user/list',
|
||||
name: 'UserList',
|
||||
slug: 'user-list',
|
||||
i18n: 'لیست مدیران',
|
||||
permissionId:"user.list"
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
url: null,
|
||||
name: 'report',
|
||||
icon: 'GiftIcon',
|
||||
i18n: 'تخفیفات',
|
||||
permissionId:"offers",
|
||||
submenu: [
|
||||
// 'تعریف تخفیف تک' removed: it is now one of the two add buttons on
|
||||
// the promotions list page. The /major/promotion/tak/add route
|
||||
// still works.
|
||||
{
|
||||
url: '/major/promotion/autopromotion',
|
||||
name: 'major_promotion_list',
|
||||
slug: 'major_promotion_list',
|
||||
i18n: 'تخفیف اتوماتیک ',
|
||||
permissionId:"offers.autopromotion"
|
||||
},
|
||||
// 'تعریف تخفیف عمده' removed: it is now the other add button on the
|
||||
// promotions list page. The /major/promotion/add route still works.
|
||||
{
|
||||
url: '/major/promotion/list',
|
||||
name: 'major_promotion_list',
|
||||
slug: 'major_promotion_list',
|
||||
i18n: 'لیست تخفیفات ',
|
||||
permissionId:"offers.list"
|
||||
},
|
||||
// Moved under "تخفیفات" per request — was a top-level entry.
|
||||
{
|
||||
name: 'report',
|
||||
icon: 'SlashIcon',
|
||||
i18n: 'استثناعات تخفیف',
|
||||
permissionId:"offersIgnore",
|
||||
url: '/major/offersIgnore',
|
||||
slug: 'offersIgnore',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
// Grouped per request: "اسناد مالی", "فاکتور انتقال" and "گزارشات"
|
||||
// used to be three separate top-level entries; they are now one
|
||||
// group ("گزارشات و اسناد مالی") with each of them nested inside it
|
||||
// as its own submenu, same 3-level shape as "پیکر بندی" below.
|
||||
url: null,
|
||||
name: 'reports and documents',
|
||||
icon: 'FileTextIcon',
|
||||
i18n: 'گزارشات و اسناد مالی',
|
||||
submenu: [
|
||||
{
|
||||
url: null,
|
||||
name: 'finical',
|
||||
icon: 'DollarSignIcon',
|
||||
i18n: 'اسناد مالی',
|
||||
permissionId:"document",
|
||||
submenu: [
|
||||
{
|
||||
url: '/finical/list',
|
||||
name: 'finical_list',
|
||||
slug: 'finical_list',
|
||||
i18n: 'لیست اسناد',
|
||||
permissionId:"document.list"
|
||||
},
|
||||
// 'وارد کردن سند مالی مشتری' removed: it is now the add button
|
||||
// on the financial documents list page. The /add/findoc route
|
||||
// still works.
|
||||
]
|
||||
},
|
||||
{
|
||||
url: null,
|
||||
name: 'Invoice',
|
||||
icon: 'BookIcon',
|
||||
i18n: 'فاکتور انتقال',
|
||||
permissionId:"invoice",
|
||||
submenu: [
|
||||
// 'افزودن فاکتور' removed: it is now the add button on the
|
||||
// invoice list page. The /invoice/add route still works.
|
||||
{
|
||||
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: 'sms',
|
||||
icon: 'MailIcon',
|
||||
i18n: 'پیامک',
|
||||
permissionId:"sms",
|
||||
submenu: [
|
||||
{
|
||||
url: '/sms/create',
|
||||
name: 'create sms',
|
||||
slug: 'create_sms',
|
||||
i18n: 'ارسال پیامک',
|
||||
permissionId:"sms.create",
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
url: '/statics',
|
||||
name: 'statics',
|
||||
icon: 'BarChartIcon',
|
||||
i18n: 'آمار',
|
||||
permissionId:"statics",
|
||||
},
|
||||
{
|
||||
url: '/newsletters',
|
||||
name: 'newsletters',
|
||||
icon: 'FileTextIcon',
|
||||
i18n: 'خبرنامه',
|
||||
// Legacy typo'd this as "newsletters.newsletters", a rid that never
|
||||
// exists in ALL_RIDS — so the item was permanently invisible to
|
||||
// everyone, including super-admin. The route's own guard
|
||||
// (POST newsletter/list, admin-misc.controller.ts) requires the
|
||||
// plain "newsletters" rid; matched here.
|
||||
permissionId:"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",
|
||||
superAdminOnly: true
|
||||
},
|
||||
// D27 — Tahesab / Zarrin integration.
|
||||
{
|
||||
url: '/config/manage/accounting',
|
||||
name: 'accounting Integration',
|
||||
slug: 'manage-accounting',
|
||||
i18n: 'اتصال حسابداری',
|
||||
permissionId:"config.accounting"
|
||||
},
|
||||
// MON-08 — the making-charge formula.
|
||||
{
|
||||
url: '/config/manage/wage',
|
||||
name: 'wage Formula',
|
||||
slug: 'manage-wage',
|
||||
i18n: 'اجرت',
|
||||
permissionId:"config.wage"
|
||||
},
|
||||
// Moved under "پیکر بندی" per request — same node shape as before
|
||||
// (own submenu / plain leaf), just relocated in the tree.
|
||||
{
|
||||
url: null,
|
||||
name: 'category',
|
||||
icon: 'ListIcon',
|
||||
i18n: 'دسته بندی ها',
|
||||
permissionId:"collection",
|
||||
submenu: [
|
||||
// 'افزودن دسته بندی' removed: it is now the add button on the
|
||||
// category list page. The /category/add route still works.
|
||||
{
|
||||
url: '/category/list',
|
||||
name: 'categoryList',
|
||||
slug: 'list-category',
|
||||
i18n: 'لیست دسته بندی ها',
|
||||
permissionId:"collection.list"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
url: null,
|
||||
name: 'wallet',
|
||||
icon: 'CreditCardIcon',
|
||||
i18n: 'کیف پول',
|
||||
permissionId:"wallet",
|
||||
submenu: [
|
||||
{
|
||||
url: '/wallet',
|
||||
name: 'wallet',
|
||||
slug: 'wallet',
|
||||
i18n: 'لیست کیف پول های شارژ شده',
|
||||
permissionId:"wallet.list"
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
url: null,
|
||||
name: 'cities offer percent',
|
||||
// tagColor: 'warning',
|
||||
icon: 'PercentIcon',
|
||||
i18n: 'شهر ها',
|
||||
permissionId:"cities",
|
||||
submenu: [
|
||||
// 'افزودن شهر' removed: it is now the add button on the city list
|
||||
// page. The /city/create route still works.
|
||||
{
|
||||
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: 'notify',
|
||||
icon: 'BellIcon',
|
||||
i18n: 'نوتیفیکیشن',
|
||||
permissionId:"notify",
|
||||
submenu: [
|
||||
{
|
||||
url: '/notify/create',
|
||||
name: 'create notify',
|
||||
slug: 'create_notify',
|
||||
i18n: 'ارسال نوتیفیکیشن',
|
||||
permissionId:"notify.create",
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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 Antd from 'ant-design-vue';
|
||||
import VueApexCharts from 'vue3-apexcharts';
|
||||
|
||||
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.use(VueApexCharts);
|
||||
|
||||
app.mount('#app');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
// 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',
|
||||
// was 'dashboard.omde' — merged into the single 'dashboard.view' rid
|
||||
// when the two admin dashboards became one screen.
|
||||
role: 'dashboard.view'
|
||||
}
|
||||
},
|
||||
{
|
||||
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'
|
||||
}
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,174 @@
|
||||
// Ported from legacy henza_admin/src/router.js
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { adminRoute, fullPageAdminRoute } from './admin';
|
||||
import { branchRoute, fullPageBranchRoute } from './branch';
|
||||
import { iamAppUrl, isBranch, tokenName, userName } from '../utils';
|
||||
import { useUsersStore } from '../stores/users';
|
||||
import api from '../api/client';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
// legacy router.js:17-19
|
||||
scrollBehavior() {
|
||||
return { left: 0, top: 0 };
|
||||
},
|
||||
routes: [
|
||||
// ── 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: '/auth/iam/callback',
|
||||
name: 'iam-callback',
|
||||
component: () => import('../views/auth/IamCallback.vue'),
|
||||
meta: { rule: 'editor' },
|
||||
},
|
||||
{
|
||||
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', '/auth/iam/callback'];
|
||||
|
||||
/**
|
||||
* 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);
|
||||
users.setSuperAdmin(data.is_super_admin);
|
||||
// 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.
|
||||
*/
|
||||
let silentSsoAttempted = false;
|
||||
|
||||
function skipSilentSso(): boolean {
|
||||
if (sessionStorage.getItem('gb_admin_iam_sso_skip') === '1') {
|
||||
sessionStorage.removeItem('gb_admin_iam_sso_skip');
|
||||
silentSsoAttempted = true;
|
||||
return true;
|
||||
}
|
||||
return silentSsoAttempted;
|
||||
}
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (!localStorage.getItem(tokenName) && to.path !== '/auth/iam/callback' && !skipSilentSso()) {
|
||||
silentSsoAttempted = true;
|
||||
sessionStorage.removeItem('gb_admin_iam_sso_checked');
|
||||
try {
|
||||
const { data } = await api.get('admin/auth/sso/start', {
|
||||
params: { redirect_uri: iamAppUrl('auth/iam/callback') },
|
||||
});
|
||||
if (data?.enabled && data.authorize_url) {
|
||||
window.location.assign(data.authorize_url);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// سامانه هویت برای این پنل تنظیم نشده
|
||||
}
|
||||
}
|
||||
|
||||
if (noneAuthUrl.indexOf(to.path) > -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem(tokenName);
|
||||
if (token) {
|
||||
const roles = await getPermission();
|
||||
// The stored token could not be confirmed (API down, still booting, or any
|
||||
// non-401 failure). Letting the route through here showed the whole panel to
|
||||
// whoever held a stale token, so fail closed and send them to sign in.
|
||||
if (!roles) {
|
||||
return { path: '/auth/login' };
|
||||
}
|
||||
if (to.meta?.superAdminOnly && !useUsersStore().isSuperAdmin) {
|
||||
return { name: 'page-not-authorized' };
|
||||
}
|
||||
if (roles && to.meta && to.meta.role && !roles.includes(to.meta.role as string)) {
|
||||
if (to.name === 'dashboard-ecommerce' || to.name === 'dashboard-analytics') {
|
||||
return { name: 'my-data' };
|
||||
}
|
||||
return { name: 'page-not-authorized' };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return { path: '/auth/login' };
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,114 @@
|
||||
// Ported from legacy henza_admin/src/store/auth/moduleAuth.js
|
||||
import { defineStore } from 'pinia';
|
||||
import api from '../api/client';
|
||||
import { iamAppUrl, 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));
|
||||
users.setSuperAdmin(data.is_super_admin);
|
||||
|
||||
// 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);
|
||||
if (data.iamBootstrapUrl) {
|
||||
window.location.assign(data.iamBootstrapUrl);
|
||||
}
|
||||
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.
|
||||
*/
|
||||
async logout(): Promise<boolean> {
|
||||
let central: string | null = null;
|
||||
try {
|
||||
const { data } = await api.get('admin/auth/sso/logout-url', {
|
||||
params: { return_url: iamAppUrl('auth/login') },
|
||||
});
|
||||
central = data?.logout_url ?? null;
|
||||
} catch {
|
||||
central = null;
|
||||
}
|
||||
localStorage.removeItem(tokenName);
|
||||
localStorage.removeItem(userName);
|
||||
localStorage.removeItem('login');
|
||||
sessionStorage.removeItem('gb_admin_iam_sso_checked');
|
||||
const users = useUsersStore();
|
||||
users.deleteRoles();
|
||||
users.deleteUserInfo();
|
||||
if (central) {
|
||||
window.location.assign(central);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,64 @@
|
||||
// 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';
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'admin-panel:sidebarCollapsed';
|
||||
|
||||
/** Reads the persisted collapse state, falling back to themeConfig's default
|
||||
* when localStorage is unavailable or has never been set. */
|
||||
function readPersistedCollapsed(): boolean {
|
||||
if (typeof window === 'undefined') return themeConfig.sidebarCollapsed;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
|
||||
return raw === null ? themeConfig.sidebarCollapsed : raw === '1';
|
||||
} catch {
|
||||
return themeConfig.sidebarCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
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: readPersistedCollapsed(),
|
||||
// below the 992 breakpoint the sidebar is an off-canvas drawer; never persisted
|
||||
mobileNavOpen: false,
|
||||
}),
|
||||
getters: {
|
||||
// legacy used breakpoints of 1200 (theme tour) and 992 (nav menu collapse)
|
||||
isSmallScreen: (s) => s.windowWidth < 992,
|
||||
isMobile: (s) => s.windowWidth < 576,
|
||||
},
|
||||
actions: {
|
||||
// legacy mutation: UPDATE_WINDOW_WIDTH
|
||||
updateWindowWidth(width: number) {
|
||||
this.windowWidth = width;
|
||||
},
|
||||
// legacy mutation: UPDATE_WINDOW_SCROLL_Y
|
||||
updateWindowScrollY(y: number) {
|
||||
this.scrollY = y;
|
||||
},
|
||||
setMobileNavOpen(value: boolean) {
|
||||
this.mobileNavOpen = value;
|
||||
},
|
||||
toggleSidebar(value?: boolean) {
|
||||
this.isSidebarActive = value ?? !this.isSidebarActive;
|
||||
},
|
||||
setSidebarCollapsed(value: boolean) {
|
||||
this.sidebarCollapsed = value;
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? '1' : '0');
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, disabled site data, ...) —
|
||||
// collapse state just won't persist across reloads.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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,
|
||||
// The super-admin-only features (see utils/superAdmin.ts) show for this
|
||||
// account alone, whatever rids other admins hold. Set by the server.
|
||||
isSuperAdmin: false,
|
||||
}),
|
||||
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);
|
||||
},
|
||||
setSuperAdmin(value: unknown) {
|
||||
this.isSuperAdmin = value === true;
|
||||
},
|
||||
deleteRoles() {
|
||||
this.roles = undefined;
|
||||
this.isSuperAdmin = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,85 @@
|
||||
// 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. */
|
||||
/**
|
||||
* A date column in an XTable grid. The legacy API sent `caj`/`uaj`/… already
|
||||
* formatted (Selector::convertDate, Jalalian) and the grid printed the string;
|
||||
* the new API sends an ISO timestamp, which the grid printed raw
|
||||
* ("2026-07-25T12:32:29.000Z"). Same shape the legacy screen shows:
|
||||
* 1405/5/3-16:02, in the viewer's local (Tehran) time. Anything that is not
|
||||
* an ISO timestamp is returned untouched.
|
||||
*/
|
||||
export function gridDate(val: unknown) {
|
||||
if (typeof val !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(val)) return val;
|
||||
return moment(val).format('jYYYY/jM/jD-HH:mm');
|
||||
}
|
||||
|
||||
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,26 @@
|
||||
// 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.
|
||||
/** آدرس همین پنل، با پیشوند /branch در حالت شعبه. */
|
||||
export function iamAppUrl(path: string) {
|
||||
const base = import.meta.env.BASE_URL || '/';
|
||||
const prefix = base.endsWith('/') ? base : `${base}/`;
|
||||
return `${window.location.origin}${prefix}${path.replace(/^\//, '')}`;
|
||||
}
|
||||
|
||||
export const tokenName = isBranch() ? 'branch_token' : 'token';
|
||||
export const userName = isBranch() ? 'branch_user' : 'user';
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Rids of the features only the super admin (09128081392) may use: retail
|
||||
* customers and orders, branches and workshops, retail promotions and wallet
|
||||
* management. The permission screen hides them from everyone else. Bankers,
|
||||
* walk-in shopkeepers and trays ride on rids other screens share, so they are
|
||||
* gated by `users.isSuperAdmin` in place instead.
|
||||
*
|
||||
* Keep in step with SUPER_ADMIN_RIDS in apps/api/src/modules/auth/super-admin.ts.
|
||||
*/
|
||||
export const SUPER_ADMIN_RIDS: ReadonlySet<string> = new Set([
|
||||
'persons.create.single',
|
||||
'persons.list.client.single',
|
||||
'persons.list.client.single.view',
|
||||
'persons.list.client.single.edit',
|
||||
'branch',
|
||||
'branch.create',
|
||||
'branch.list',
|
||||
'workshop',
|
||||
'workshop.create',
|
||||
'workshop.list',
|
||||
'order.create',
|
||||
'order.list',
|
||||
'order.list.view',
|
||||
'order.list.sts',
|
||||
'order.list.sts.view',
|
||||
'order.list.sts.remove',
|
||||
'order.list.sts.submit',
|
||||
'order.list.view.edit',
|
||||
'order.list.pdf',
|
||||
'offers.create.tak',
|
||||
'offers.list.tak.edit',
|
||||
'offers.list.tak.remove',
|
||||
'config.wallet',
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
<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.
|
||||
Converted to a modal (BaseFormModal): the /category/add/:id? route still
|
||||
exists and still renders this component directly as a route-based
|
||||
fallback, but List.vue now opens it as a modal via open/id props.
|
||||
|
||||
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.
|
||||
-->
|
||||
<BaseFormModal
|
||||
v-if="asModal"
|
||||
:open="Boolean(open)"
|
||||
:id="id"
|
||||
:add-title="`دسته بندی ${typeSuffix}`"
|
||||
:edit-title="`دسته بندی ${typeSuffix}`"
|
||||
:loading="loading"
|
||||
:submitting="loading"
|
||||
width="lg"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<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>
|
||||
</BaseFormModal>
|
||||
|
||||
<!-- route-based fallback for direct links to /category/add/:id? -->
|
||||
<a-spin v-else :spinning="loading">
|
||||
<a-form layout="vertical" @submit.prevent="handleSubmit">
|
||||
<a-card :title="`دسته بندی ${typeSuffix}`" class="mb-base page-card">
|
||||
<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" class="form-submit" @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 BaseFormModal from '../../../components/common/BaseFormModal.vue';
|
||||
import api from '../../../api/client';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const asModal = props.open !== undefined;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = asModal ? props.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('اطلاعات با موفقیت به ثبت رسید.');
|
||||
if (asModal) {
|
||||
emit('success');
|
||||
emit('update:open', false);
|
||||
} else {
|
||||
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>
|
||||
|
||||
<!-- .grid-2 now comes from the global utilities.less (dedup: was a drifted 260px copy) -->
|
||||
@@ -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,86 @@
|
||||
<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="لیست دسته بندی ها" class="page-card">
|
||||
<XTable ref="tbl" :model="model" mobile-cards>
|
||||
<template #add>
|
||||
<ListPageHeader rid="collection.create" add-label="افزودن دسته" @add="openAdd" />
|
||||
</template>
|
||||
|
||||
<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="openEdit(record.id)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="hasPermission('collection.list.remove')" title="حذف">
|
||||
<a-popconfirm
|
||||
placement="top"
|
||||
ok-text="بله"
|
||||
cancel-text="خیر"
|
||||
@confirm="confirmDelete(`category/${record.id}`)"
|
||||
>
|
||||
<template #title><p>آیا مطمئن به حذف دسته مورد نظر هستید ؟</p></template>
|
||||
<DeleteOutlined class="cursor-pointer mx-1 text-danger" />
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</XTable>
|
||||
|
||||
<CategoryAdd :key="editingId ?? 'new'" :open="modalOpen" :id="editingId" @update:open="modalOpen = $event" @success="tbl?.fetch()" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
import XTable from '../../../components/x-table/XTable.vue';
|
||||
import ListPageHeader from '../../../components/common/ListPageHeader.vue';
|
||||
import CategoryAdd from './Add.vue';
|
||||
import { buildCategoryModel } from './CategoryTbl';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
|
||||
import api from '../../../api/client';
|
||||
|
||||
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);
|
||||
|
||||
const modalOpen = ref(false);
|
||||
const editingId = ref<string | undefined>(undefined);
|
||||
|
||||
// The backend refuses while products or child categories still reference it.
|
||||
const { remove: confirmDelete } = useDeleteHandler({
|
||||
onSuccess: () => tbl.value?.fetch(),
|
||||
successMessage: 'دسته حذف شد',
|
||||
successType: 'info',
|
||||
fallbackErrorMessage: 'خطا در حذف دسته',
|
||||
});
|
||||
|
||||
function openAdd() {
|
||||
editingId.value = undefined;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
editingId.value = id;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/apps/customer/AddCustomer.vue.
|
||||
|
||||
Three tabs, matching legacy's live markup one for one. An earlier revision
|
||||
of this port kept only the first: legacy's file is 278 lines and most of it
|
||||
IS commented out, but what's commented is an old inline "مشتری تک" form
|
||||
that ReagentCustomer/singleCustomer later replaced — not the tabs
|
||||
themselves. Dropping them left the page with a lone tab strip and, for
|
||||
anyone without the عمده permission, nothing at all.
|
||||
|
||||
Permission gates are legacy's, including the third tab's: legacy commented
|
||||
out its `persons.create.customer.tak` check (AddCustomer.vue:142), so the
|
||||
retail tab shows for anyone who can open the page. The tabs are drawn as
|
||||
selectable cards; which form shows is unchanged.
|
||||
-->
|
||||
<div id="page-user-view" class="pf-page">
|
||||
<header class="pf-head">
|
||||
<a-button class="pf-head__back" shape="circle" @click="handleBack">
|
||||
<template #icon><ArrowRightOutlined /></template>
|
||||
</a-button>
|
||||
<div class="pf-head__text">
|
||||
<h1>افزودن شخص</h1>
|
||||
<p>نوع شخص را انتخاب و اطلاعات او را تکمیل کنید.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<template v-if="hasPermission('persons.create') && kinds.length">
|
||||
<div class="kind-picker" role="tablist">
|
||||
<button
|
||||
v-for="k in kinds"
|
||||
:key="k.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="kind-card"
|
||||
:class="{ 'is-on': activeTab === k.key }"
|
||||
:aria-selected="activeTab === k.key"
|
||||
@click="activeTab = k.key"
|
||||
>
|
||||
<span class="kind-card__icon"><component :is="k.icon" /></span>
|
||||
<span class="kind-card__text">
|
||||
<b>{{ k.title }}</b>
|
||||
<small>{{ k.hint }}</small>
|
||||
</span>
|
||||
<CheckCircleFilled v-if="activeTab === k.key" class="kind-card__check" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- No KeepAlive here: it kept each kind's half-filled form alive, so a new
|
||||
person opened on the page showed the previous one's data. -->
|
||||
<component :is="FORMS[activeTab]" v-if="kinds.some((k) => k.key === activeTab)" :key="activeTab" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, type Component } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
ShopOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
ArrowRightOutlined,
|
||||
CheckCircleFilled,
|
||||
IdcardOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import BulkCustomer from './bulkCustomer.vue';
|
||||
import ReagentCustomer from './ReagentCustomer.vue';
|
||||
import SingleCustomer from './singleCustomer.vue';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const users = useUsersStore();
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'omde');
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
// Card picker (design): the same three forms and the same gates as the tabs it
|
||||
// replaces — عمده: persons.create.client, کارشناس: persons.create.seller,
|
||||
// تک: super admin only (D30).
|
||||
interface Kind {
|
||||
key: 'omde' | 'reagent' | 'tak';
|
||||
title: string;
|
||||
hint: string;
|
||||
icon: Component;
|
||||
}
|
||||
const kinds = computed<Kind[]>(() => {
|
||||
const out: Kind[] = [];
|
||||
if (hasPermission('persons.create.client')) {
|
||||
out.push({ key: 'omde', title: 'مشتری عمده', hint: 'مغازهدار، بنکدار و خریدار عمده', icon: ShopOutlined });
|
||||
}
|
||||
if (hasPermission('persons.create.seller')) {
|
||||
out.push({ key: 'reagent', title: 'کارشناس فروش', hint: 'کارشناس با کمیسیون و سقف اعتبار', icon: IdcardOutlined });
|
||||
}
|
||||
if (users.isSuperAdmin) {
|
||||
out.push({ key: 'tak', title: 'مشتری تک', hint: 'مشتری خردهفروشی حضوری', icon: UserOutlined });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const FORMS: Record<string, Component> = { omde: BulkCustomer, reagent: ReagentCustomer, tak: SingleCustomer };
|
||||
|
||||
function handleBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/customer/omde-list');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
.kind-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.kind-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1.5px solid #eef0f5;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.kind-card:hover {
|
||||
border-color: #c9d0dd;
|
||||
}
|
||||
.kind-card.is-on {
|
||||
border-color: #1c2d56;
|
||||
background: rgba(28, 45, 86, 0.03);
|
||||
box-shadow: 0 0 0 3px rgba(28, 45, 86, 0.08);
|
||||
}
|
||||
.kind-card__icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
color: #8a94a6;
|
||||
background: #f2f4f7;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.kind-card.is-on .kind-card__icon {
|
||||
color: #fff;
|
||||
background: #1c2d56;
|
||||
}
|
||||
.kind-card__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.kind-card__text b {
|
||||
font-size: 14px;
|
||||
color: #101828;
|
||||
}
|
||||
.kind-card__text small {
|
||||
font-size: 12px;
|
||||
color: #8a94a6;
|
||||
}
|
||||
.kind-card__check {
|
||||
margin-inline-start: auto;
|
||||
font-size: 16px;
|
||||
color: #1c2d56;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.kind-picker {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.kind-card {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.kind-card__icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 class="page-card" />
|
||||
</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 class="page-card" />
|
||||
</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 class="page-card" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SingleCustomerForm from './singleCustomer.vue';
|
||||
</script>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/apps/customer/ReagentCustomer.vue —
|
||||
the sales-expert ("کارشناس فروش") form. Legacy stored a sales agent as a
|
||||
Customer row (type=1) in the same `customers` table as every other
|
||||
wholesale kind, so this form used to share the wholesale customer's
|
||||
fields and post to the Customer endpoints.
|
||||
|
||||
FIXED: a sales agent is now an AdminUser (role=SALES_EXPERT) — see
|
||||
admin-users.service.ts and admin-customers.service.ts's listReagents().
|
||||
Posting to the Customer endpoints (as this form used to) created a
|
||||
Customer row that listReagents() never reads and every OTHER Customer
|
||||
list (including "لیست مشتریان تک", which had no kind filter at all)
|
||||
happily returned instead. This form now posts to admin/users, and the
|
||||
fields that only ever existed on Customer (کد ملی، نوع کاربری، شهر، سقف
|
||||
اعتبار) are dropped — AdminUser has no matching column for them; city
|
||||
was only ever used here to prefill the commission percentage, which the
|
||||
operator can still type in directly.
|
||||
-->
|
||||
<a-spin :spinning="loading">
|
||||
<a-form layout="vertical" class="pf-form" @submit.prevent="handleSubmit">
|
||||
<div class="pf-layout">
|
||||
<!-- ══════════ main column ══════════ -->
|
||||
<div class="pf-main">
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><UserOutlined /></span>
|
||||
<div>
|
||||
<h3>اطلاعات پایه</h3>
|
||||
<p>مشخصات هویتی و اطلاعات ورود کارشناس</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-grid-2">
|
||||
<a-form-item label="نام " required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
||||
<a-input v-model:value="form.fname" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
|
||||
<a-input v-model:value="form.lname" />
|
||||
</a-form-item>
|
||||
<!--
|
||||
D17 — both optional in the reference (required: false,
|
||||
ReagentCustomer.vue:31,95) with no uniqueness and no format rule,
|
||||
so none is invented here.
|
||||
-->
|
||||
<a-form-item label="کد ملی">
|
||||
<a-input v-model:value="form.ncode" class="ltr-input" inputmode="numeric" />
|
||||
</a-form-item>
|
||||
<a-form-item label="تلفن">
|
||||
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="شماره تلفن همراه"
|
||||
required
|
||||
:validate-status="err.mobile ? 'error' : ''"
|
||||
:help="err.mobile"
|
||||
class="mb-0"
|
||||
>
|
||||
<a-input v-model:value="form.mobile" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="رمز عبور"
|
||||
:required="!id"
|
||||
:validate-status="err.password ? 'error' : ''"
|
||||
:help="err.password"
|
||||
class="mb-0"
|
||||
>
|
||||
<a-input-password v-model:value="form.password" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><ShopOutlined /></span>
|
||||
<div>
|
||||
<h3>اطلاعات تجاری</h3>
|
||||
<p>کد و مشخصات فروشگاه</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-grid-3">
|
||||
<a-form-item label="کد مشتری" class="mb-0">
|
||||
<a-input v-model:value="form.mcode" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام مغازه" class="mb-0">
|
||||
<a-input v-model:value="form.store_name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام پاساژ" class="mb-0">
|
||||
<a-input v-model:value="form.pasazh_name" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><EnvironmentOutlined /></span>
|
||||
<div>
|
||||
<h3>آدرس</h3>
|
||||
<p>انتخاب شهر، درصد کمیسیون را بر اساس همان شهر پر میکند</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- D17 — «شهر» also drives the commission autofill (ReagentCustomer.vue:111,215-228). -->
|
||||
<a-form-item label="شهر">
|
||||
<CityTreeSelect
|
||||
:value="form.city_id"
|
||||
:cities="cities"
|
||||
placeholder="شهر را انتخاب نمایید"
|
||||
@change="onCityChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="آدرس" class="mb-0">
|
||||
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 3, maxRows: 5 }" />
|
||||
</a-form-item>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ══════════ side column ══════════ -->
|
||||
<aside class="pf-side">
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><CameraOutlined /></span>
|
||||
<div>
|
||||
<h3>عکس پروفایل</h3>
|
||||
<p>تصویر نمایش دادهشده برای کارشناس</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="avatar-slot">
|
||||
<ImageUploadInput
|
||||
v-model="form.image_url"
|
||||
purpose="reagent.avatar"
|
||||
placeholder="عکس"
|
||||
@ok="onAvatar"
|
||||
@update:modelValue="(v: string | null) => { if (!v) form.image_id = ''; }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><PercentageOutlined /></span>
|
||||
<div>
|
||||
<h3>کمیسیون و اعتبار</h3>
|
||||
<p>درصد کمیسیون و سقف اعتبار</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-form-item label="درصد کمیسیون">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :step="0.1" addon-after="٪" />
|
||||
</a-form-item>
|
||||
<a-form-item label="سقف اعتبار" class="mb-0">
|
||||
<a-input-number v-model:value="form.gram_level" class="w-full" addon-after="گرم" />
|
||||
</a-form-item>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="pf-actions">
|
||||
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستارهدار الزامی هستند.</span>
|
||||
<a-button @click="router.go(-1)">انصراف</a-button>
|
||||
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmit">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
ثبت اطلاعات
|
||||
</a-button>
|
||||
</div>
|
||||
</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 {
|
||||
TeamOutlined,
|
||||
ShopOutlined,
|
||||
EnvironmentOutlined,
|
||||
CheckOutlined,
|
||||
ArrowRightOutlined,
|
||||
CameraOutlined,
|
||||
InfoCircleOutlined,
|
||||
PercentageOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import api from '../../../api/client';
|
||||
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = route.params.id as string | undefined;
|
||||
const loading = ref(false);
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
fname: '',
|
||||
lname: '',
|
||||
password: '',
|
||||
mobile: '',
|
||||
mcode: '',
|
||||
store_name: '',
|
||||
pasazh_name: '',
|
||||
address: '',
|
||||
percent: undefined,
|
||||
ncode: '',
|
||||
phone: '',
|
||||
city_id: undefined,
|
||||
gram_level: undefined,
|
||||
image_id: '',
|
||||
image_url: null,
|
||||
});
|
||||
|
||||
const cities = ref<Array<{ id: string; title: string; parent?: string | null; percent?: number | null }>>([]);
|
||||
const err = reactive<Record<string, string>>({});
|
||||
const initial = reactive<Record<string, any>>({});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function onAvatar(file: Record<string, unknown>) {
|
||||
form.image_id = (file.id as string) ?? '';
|
||||
}
|
||||
|
||||
async function onCityChange(value: string | undefined) {
|
||||
form.city_id = value;
|
||||
if (!value) return;
|
||||
|
||||
let percent: number | null | undefined = cities.value.find((c) => c.id === value)?.percent;
|
||||
if (!percent) {
|
||||
try {
|
||||
const { data } = await api.get(`cities/get-percent-aux/${value}`);
|
||||
percent = data as number | null;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.percent = percent === null || percent === undefined ? undefined : Number(percent);
|
||||
}
|
||||
|
||||
const REQUIRED = 'لطفا مقدار را وارد کنید';
|
||||
|
||||
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 (!id && !form.password) err.password = REQUIRED;
|
||||
if (Object.keys(err).length) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const { image_url: _unusedAvatarUrl, ...allFields } = form;
|
||||
const fields = id ? diffFromInitial(['image_url', 'password']) : allFields;
|
||||
if (id && form.password) fields.password = form.password;
|
||||
const payload: Record<string, any> = { ...fields, role: 'SALES_EXPERT' };
|
||||
if (!id || 'fname' in fields || 'lname' in fields) {
|
||||
payload.name = `${form.fname} ${form.lname}`.trim();
|
||||
}
|
||||
if (id) {
|
||||
await api.put(`users/${id}`, payload);
|
||||
} else {
|
||||
await api.post('users', 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 { data } = await api.post('cities/list', { page: 1, limit: 500 });
|
||||
cities.value = (data.rows ?? []).map((c: any) => ({
|
||||
id: c.id,
|
||||
title: c.title ?? c.name,
|
||||
parent: c.parent ?? null,
|
||||
percent: c.percent === null || c.percent === undefined ? null : Number(c.percent),
|
||||
}));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
if (id) {
|
||||
try {
|
||||
const { data } = await api.get(`admin/users/${id}`);
|
||||
Object.assign(form, {
|
||||
fname: data.fname ?? '',
|
||||
lname: data.lname ?? '',
|
||||
mobile: data.mobile ?? '',
|
||||
address: data.address ?? '',
|
||||
percent: data.percent === null ? undefined : Number(data.percent),
|
||||
ncode: data.ncode ?? '',
|
||||
phone: data.phone ?? '',
|
||||
city_id: data.city_id ?? undefined,
|
||||
gram_level:
|
||||
data.gram_level === null || data.gram_level === undefined
|
||||
? undefined
|
||||
: Number(data.gram_level),
|
||||
image_id: data.image?.id ?? '',
|
||||
image_url: data.image?.image ?? null,
|
||||
store_name: data.store_name ?? '',
|
||||
pasazh_name: data.pasazh_name ?? '',
|
||||
mcode: data.mcode ?? '',
|
||||
});
|
||||
Object.assign(initial, JSON.parse(JSON.stringify(form)));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.avatar-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,488 @@
|
||||
<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.
|
||||
|
||||
Converted to a modal (BaseFormModal): /customer/add (via AddCustomer.vue)
|
||||
and /bulkCustomer/edit/:id (via EditBulkCustomer.vue) still exist and still
|
||||
render this component directly as a route-based fallback, but OmdeList.vue
|
||||
now opens it as a modal via open/id props.
|
||||
|
||||
«تاریخ تولد» (legacy's Jalali DateInput, birth_date) is back since the
|
||||
Customer schema has birthDate (PERS-03). Dropped: the MyUpload avatar widget,
|
||||
which belongs to a field 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.
|
||||
-->
|
||||
<BaseFormModal
|
||||
v-if="asModal"
|
||||
:open="Boolean(open)"
|
||||
:id="id"
|
||||
add-title="افزودن مشتری عمده"
|
||||
edit-title="ویرایش مشتری عمده"
|
||||
:loading="loading"
|
||||
:submitting="loading"
|
||||
width="lg"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
@submit="handleSubmitOmde"
|
||||
>
|
||||
<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>
|
||||
<DateInput
|
||||
:model-value="form.birth_date || undefined"
|
||||
label="تاریخ تولد"
|
||||
@update:model-value="(v) => (form.birth_date = v ?? '')"
|
||||
/>
|
||||
<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="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
|
||||
<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="شهر">
|
||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
|
||||
</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-card title="مدارک" class="mb-base">
|
||||
<div class="grid-3">
|
||||
<a-form-item label="عکس کارت ملی">
|
||||
<ImageUploadInput
|
||||
v-model="form.national_id_image"
|
||||
purpose="customer.nationalId"
|
||||
placeholder="عکس"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="عکس جواز کسب">
|
||||
<ImageUploadInput
|
||||
v-model="form.business_license"
|
||||
purpose="customer.businessLicense"
|
||||
placeholder="عکس"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="عکس سردر مغازه">
|
||||
<ImageUploadInput v-model="form.shop_image" purpose="customer.shop" placeholder="عکس" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
</BaseFormModal>
|
||||
|
||||
<!-- route-based fallback for /customer/add and /bulkCustomer/edit/:id -->
|
||||
<a-form v-else layout="vertical" :model="form" class="pf-form" @submit.prevent="handleSubmitOmde">
|
||||
<a-spin :spinning="loading">
|
||||
<div class="pf-layout">
|
||||
<!-- ══════════ main column ══════════ -->
|
||||
<div class="pf-main">
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><UserOutlined /></span>
|
||||
<div>
|
||||
<h3>اطلاعات پایه</h3>
|
||||
<p>مشخصات هویتی و اطلاعات ورود مشتری</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-grid-2">
|
||||
<a-form-item label="نام" required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
||||
<a-input v-model:value="form.fname" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام خانوادگی" required :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" class="ltr-input" inputmode="numeric" />
|
||||
</a-form-item>
|
||||
<DateInput
|
||||
:model-value="form.birth_date || undefined"
|
||||
label="تاریخ تولد"
|
||||
@update:model-value="(v) => (form.birth_date = v ?? '')"
|
||||
/>
|
||||
<a-form-item
|
||||
label="شماره تلفن همراه"
|
||||
required
|
||||
:validate-status="err.mobile ? 'error' : ''"
|
||||
:help="err.mobile"
|
||||
>
|
||||
<a-input v-model:value="form.mobile" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
|
||||
</a-form-item>
|
||||
<a-form-item label="رمز عبور">
|
||||
<a-input-password v-model:value="form.password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="تلفن ثابت" class="mb-0">
|
||||
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><ShopOutlined /></span>
|
||||
<div>
|
||||
<h3>اطلاعات تجاری</h3>
|
||||
<p>نوع کاربری و مشخصات فروشگاه</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-grid-2">
|
||||
<a-form-item label="نوع کاربری">
|
||||
<a-select
|
||||
v-model:value="form.type"
|
||||
show-search
|
||||
placeholder="انتخاب کنید"
|
||||
: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.mcode" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام مغازه" class="mb-0">
|
||||
<a-input v-model:value="form.store_name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام پاساژ" class="mb-0">
|
||||
<a-input v-model:value="form.pasazh_name" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><EnvironmentOutlined /></span>
|
||||
<div>
|
||||
<h3>آدرس</h3>
|
||||
<p>انتخاب شهر، درصد را بر اساس همان شهر پر میکند</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-form-item label="شهر">
|
||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" @change="onCityChange" />
|
||||
</a-form-item>
|
||||
<a-form-item label="آدرس" class="mb-0">
|
||||
<a-textarea v-model:value="form.address" :auto-size="{ minRows: 3, maxRows: 5 }" />
|
||||
</a-form-item>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ══════════ side column ══════════ -->
|
||||
<aside class="pf-side">
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><PercentageOutlined /></span>
|
||||
<div>
|
||||
<h3>کارشناس و اعتبار</h3>
|
||||
<p>کارشناس فروش، درصد و سقف اعتبار</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-form-item label="کارشناس فروش">
|
||||
<a-select
|
||||
v-model:value="form.reagent_id"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="انتخاب کنید"
|
||||
>
|
||||
<a-select-option v-for="r in reagent" :key="r.id" :value="r.id" :label="r.name">
|
||||
{{ r.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد" required :validate-status="err.percent ? 'error' : ''" :help="err.percent">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" addon-after="٪" />
|
||||
</a-form-item>
|
||||
<a-form-item label="سقف اعتبار" class="mb-0">
|
||||
<a-input-number v-model:value="form.gram_level" class="w-full" addon-after="گرم" />
|
||||
</a-form-item>
|
||||
</section>
|
||||
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><SafetyCertificateOutlined /></span>
|
||||
<div>
|
||||
<h3>مدارک</h3>
|
||||
<p>تصویر مدارک هویتی و فروشگاه</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-uploads">
|
||||
<a-form-item label="کارت ملی" class="mb-0">
|
||||
<ImageUploadInput v-model="form.national_id_image" purpose="customer.nationalId" placeholder="عکس" />
|
||||
</a-form-item>
|
||||
<a-form-item label="جواز کسب" class="mb-0">
|
||||
<ImageUploadInput v-model="form.business_license" purpose="customer.businessLicense" placeholder="عکس" />
|
||||
</a-form-item>
|
||||
<a-form-item label="سردر مغازه" class="mb-0">
|
||||
<ImageUploadInput v-model="form.shop_image" purpose="customer.shop" placeholder="عکس" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="pf-actions">
|
||||
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستارهدار الزامی هستند.</span>
|
||||
<a-button @click="router.go(-1)">انصراف</a-button>
|
||||
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmitOmde">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
ثبت اطلاعات
|
||||
</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 {
|
||||
UserOutlined,
|
||||
ShopOutlined,
|
||||
EnvironmentOutlined,
|
||||
FileImageOutlined,
|
||||
CheckOutlined,
|
||||
ArrowRightOutlined,
|
||||
InfoCircleOutlined,
|
||||
PercentageOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import BaseFormModal from '../../../components/common/BaseFormModal.vue';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
import api from '../../../api/client';
|
||||
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
||||
import DateInput from '../../../components/input/DateInput.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
asModal?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
const asModal = props.asModal === true;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = asModal ? props.id : (route.params.id as string | undefined);
|
||||
const loading = ref(false);
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null; percent?: unknown }[]>([]);
|
||||
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: '',
|
||||
birth_date: '',
|
||||
address: '',
|
||||
type: undefined,
|
||||
reagent_id: undefined,
|
||||
percent: 3,
|
||||
gram_level: undefined,
|
||||
national_id_image: '',
|
||||
business_license: '',
|
||||
shop_image: '',
|
||||
});
|
||||
|
||||
const userTypes = [
|
||||
{ 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>>({});
|
||||
const initial = reactive<Record<string, any>>({});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function onCityChange(value: string | undefined) {
|
||||
if (!value) return;
|
||||
let percent: unknown = cities.value.find((c) => c.id === value)?.percent;
|
||||
if (!Number(percent)) {
|
||||
try {
|
||||
const { data } = await api.get(`cities/get-percent-aux/${value}`);
|
||||
percent = data;
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.percent = percent === null || percent === undefined ? undefined : Number(percent);
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
Object.keys(err).forEach((k) => delete err[k]);
|
||||
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
|
||||
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
|
||||
if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید';
|
||||
if (form.percent === undefined || form.percent === null || (form.percent as unknown) === '') {
|
||||
err.percent = 'لطفا مقدار را وارد کنید';
|
||||
}
|
||||
return Object.keys(err).length === 0;
|
||||
}
|
||||
|
||||
async function handleSubmitOmde() {
|
||||
if (!validate()) return;
|
||||
try {
|
||||
loading.value = true;
|
||||
const changed = id ? diffFromInitial() : { ...form };
|
||||
const payload: Record<string, unknown> = {
|
||||
...changed,
|
||||
id: id ?? null,
|
||||
national_code: form.ncode,
|
||||
};
|
||||
if (!id || 'fname' in changed || 'lname' in changed) {
|
||||
payload.name = `${form.fname} ${form.lname}`.trim();
|
||||
}
|
||||
if (id) {
|
||||
await api.put(`customer/${id}`, payload);
|
||||
} else {
|
||||
await api.post('customer/create', payload);
|
||||
}
|
||||
message.info('اطلاعات با موفقیت به ثبت رسید.');
|
||||
if (asModal) {
|
||||
emit('success');
|
||||
emit('update:open', false);
|
||||
} else {
|
||||
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 { 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 === null || data.percent === undefined ? undefined : Number(data.percent),
|
||||
gram_level: data.gram_level ?? undefined,
|
||||
reagent_id: data.reagent?.id ?? undefined,
|
||||
store_name: data.store_name ?? '',
|
||||
pasazh_name: data.pasazh_name ?? '',
|
||||
mcode: data.mcode ?? '',
|
||||
birth_date: data.birth_date ?? '',
|
||||
phone: data.phone ?? '',
|
||||
type: data.type ?? undefined,
|
||||
national_id_image: data.national_id_image ?? '',
|
||||
business_license: data.business_license ?? '',
|
||||
shop_image: data.shop_image ?? '',
|
||||
});
|
||||
Object.assign(initial, JSON.parse(JSON.stringify(form)));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<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" class="pf-form" @submit.prevent="handleSubmit">
|
||||
<div class="pf-layout pf-layout--single">
|
||||
<div class="pf-main">
|
||||
<section class="pf-section">
|
||||
<div class="pf-section__head">
|
||||
<span class="pf-section__icon"><UserOutlined /></span>
|
||||
<div>
|
||||
<h3>{{ id ? 'ویرایش مشتری' : 'افزودن مشتری' }}</h3>
|
||||
<p>مشخصات مشتری تک</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pf-grid-2">
|
||||
<a-form-item label="نام " required :validate-status="err.fname ? 'error' : ''" :help="err.fname">
|
||||
<a-input v-model:value="form.fname" />
|
||||
</a-form-item>
|
||||
<a-form-item label="نام خانوادگی" required :validate-status="err.lname ? 'error' : ''" :help="err.lname">
|
||||
<a-input v-model:value="form.lname" />
|
||||
</a-form-item>
|
||||
<a-form-item label="شماره تلفن همراه" required :validate-status="err.phone ? 'error' : ''" :help="err.phone">
|
||||
<a-input v-model:value="form.phone" class="ltr-input" inputmode="tel" placeholder="09xxxxxxxxx" />
|
||||
</a-form-item>
|
||||
<DateInput
|
||||
:model-value="form.birth_day || undefined"
|
||||
label="تاریخ تولد"
|
||||
@update:model-value="(v) => (form.birth_day = v ?? '')"
|
||||
/>
|
||||
<a-form-item label="شهر" class="pf-span-all mb-0">
|
||||
<CityTreeSelect v-model:value="form.city_id" :cities="cities" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf-actions">
|
||||
<span class="pf-actions__hint"><InfoCircleOutlined /> فیلدهای ستارهدار الزامی هستند.</span>
|
||||
<a-button @click="router.go(-1)">انصراف</a-button>
|
||||
<a-button type="primary" class="form-submit" :loading="loading" @click="handleSubmit">
|
||||
<template #icon><CheckOutlined /></template>
|
||||
ثبت اطلاعات
|
||||
</a-button>
|
||||
</div>
|
||||
</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 {
|
||||
UserOutlined,
|
||||
EnvironmentOutlined,
|
||||
CheckOutlined,
|
||||
ArrowRightOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import api from '../../../api/client';
|
||||
import CityTreeSelect from '../../../components/common/CityTreeSelect.vue';
|
||||
import DateInput from '../../../components/input/DateInput.vue';
|
||||
|
||||
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; parent?: string | null }[]>([]);
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
fname: '',
|
||||
lname: '',
|
||||
phone: '',
|
||||
city_id: undefined,
|
||||
birth_day: '',
|
||||
});
|
||||
const err = reactive<Record<string, string>>({});
|
||||
const REQUIRED = 'لطفا مقدار را وارد کنید';
|
||||
|
||||
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,
|
||||
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}`);
|
||||
Object.assign(form, {
|
||||
fname: data.fname ?? '',
|
||||
lname: data.lname ?? '',
|
||||
phone: data.mobile ?? '',
|
||||
city_id: data.city_id ?? undefined,
|
||||
birth_day: data.birth_day ?? '',
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* the form sits under the full-width person-type cards; keep it the same width */
|
||||
.pf-layout--single {
|
||||
max-width: none;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
<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="تغییر درصد محصولات" class="page-card">
|
||||
<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" class="form-submit" :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>
|
||||
|
||||
<!-- .grid-3 now comes from the global utilities.less (dedup) -->
|
||||
@@ -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,197 @@
|
||||
<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="لیست محصولات" class="page-card">
|
||||
<template #extra>
|
||||
<!-- D27 §12: کارساخته و موجودی از حسابداری -->
|
||||
<AccountingSyncButton v-if="hasPermission('product.list')" endpoint="admin/products/accounting-sync" @done="tblProducts?.fetch()" />
|
||||
</template>
|
||||
<h6 class="selected-count">تعداد {{ selectedRows.length }} محصول انتخاب شده است.</h6>
|
||||
|
||||
<XTable
|
||||
ref="tblProducts"
|
||||
v-model="selectedRows"
|
||||
:model="model"
|
||||
:has-selection="true"
|
||||
mobile-cards
|
||||
>
|
||||
<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 #accounting="{ record }">
|
||||
<div v-if="record.accounting_stock" class="acc-stock">
|
||||
موجودی:
|
||||
<span :class="stockOf(record) <= 0 ? 'text-danger' : ''">
|
||||
{{ record.accounting_stock.weight != null ? `${Number(record.accounting_stock.weight)} گرم` : `${Number(record.accounting_stock.count)} عدد` }}
|
||||
</span>
|
||||
</div>
|
||||
<a-tag v-if="!record.ProductImage || !record.ProductImage.length" color="orange">بدون تصویر — در اپ پنهان</a-tag>
|
||||
<a-tag v-else-if="record.accounting_stock && stockOf(record) <= 0" color="red">ناموجود در حسابداری</a-tag>
|
||||
</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%" wrap-class-name="detail-modal">
|
||||
<div v-if="tempRow">
|
||||
<a-descriptions bordered size="small" :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 1 }">
|
||||
<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 } from 'ant-design-vue';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import XTable from '../../../components/x-table/XTable.vue';
|
||||
import AccountingSyncButton from '../../../components/accounting/AccountingSyncButton.vue';
|
||||
import { buildProductListModel } from './productListModel';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import { isBranch } from '../../../utils';
|
||||
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
|
||||
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;
|
||||
}
|
||||
|
||||
// The backend refuses when the product appears in past orders; show its
|
||||
// Farsi explanation rather than a generic failure.
|
||||
const { remove: confirmDeleteById } = useDeleteHandler({
|
||||
onSuccess: () => tblProducts.value?.fetch(),
|
||||
successMessage: 'محصول حذف شد',
|
||||
successType: 'info',
|
||||
fallbackErrorMessage: 'خطا در حذف محصول',
|
||||
});
|
||||
|
||||
function confirmDelete(id: string) {
|
||||
return confirmDeleteById(`delete/product/${id}`, { method: 'post' });
|
||||
}
|
||||
|
||||
const stockOf = (r: Record<string, any>) => Number(r.accounting_stock?.weight ?? r.accounting_stock?.count ?? 0);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
/* the actions sit in the card header on phones; a fixed 12rem would crowd the title */
|
||||
.w-48 {
|
||||
width: auto;
|
||||
}
|
||||
.selected-count {
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
// 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'),
|
||||
);
|
||||
// D27 — stock in the accounting software, and whether the app shows it at all.
|
||||
tbl.add(new Xtc('accounting_stock', 'حسابداری / نمایش').renderSlot('accounting').noSort());
|
||||
tbl.add(new Xtc('action', 'عملیات').renderSlot('op').noSort());
|
||||
return tbl;
|
||||
}
|
||||
|
||||
export default buildProductListModel;
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../../api/client';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
onMounted(async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
sessionStorage.removeItem('gb_admin_iam_sso_checked');
|
||||
|
||||
if (params.get('error') || !params.get('code') || !params.get('state')) {
|
||||
sessionStorage.setItem('gb_admin_iam_sso_skip', '1');
|
||||
await router.replace('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await api.post('admin/auth/sso/callback', {
|
||||
code: params.get('code'),
|
||||
state: params.get('state'),
|
||||
});
|
||||
const persisted = { ...data };
|
||||
delete persisted.roles;
|
||||
delete persisted.ut;
|
||||
auth.inject(persisted);
|
||||
await router.replace('/dashboard/ecommerce');
|
||||
} catch {
|
||||
sessionStorage.setItem('gb_admin_iam_sso_skip', '1');
|
||||
await router.replace('/auth/login');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="min-height: 100vh; display: grid; place-items: center">در حال ورود...</div>
|
||||
</template>
|
||||
@@ -0,0 +1,722 @@
|
||||
<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">
|
||||
<div class="aurora" aria-hidden="true">
|
||||
<span class="blob blob--gold"></span>
|
||||
<span class="blob blob--teal"></span>
|
||||
<span class="blob blob--navy"></span>
|
||||
</div>
|
||||
<div class="grid-bg" aria-hidden="true"></div>
|
||||
<div class="sparkles" aria-hidden="true">
|
||||
<i v-for="n in 14" :key="n" :style="sparkleStyle(n)"></i>
|
||||
</div>
|
||||
|
||||
<main class="login-layout">
|
||||
<section class="hero" aria-hidden="true">
|
||||
<div class="brand-row">
|
||||
<div class="logo-chip">
|
||||
<img src="../../assets/images/logo/brand-icon.png" alt="" />
|
||||
</div>
|
||||
<span class="brand-name">{{ brand.nameEn }}</span>
|
||||
</div>
|
||||
|
||||
<h2 class="hero-title">
|
||||
مدیریت هوشمند
|
||||
<span class="gold-text">کسبوکار طلا</span>
|
||||
</h2>
|
||||
<p class="hero-sub">سفارشها، مشتریان، محصولات و حسابداری؛ همه در یک پنل.</p>
|
||||
|
||||
<div class="ingots">
|
||||
<div class="ingot-glow"></div>
|
||||
<svg viewBox="0 0 400 240" class="ingot-svg">
|
||||
<defs>
|
||||
<linearGradient id="ig-front" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffe7a3" />
|
||||
<stop offset="0.45" stop-color="#e9b949" />
|
||||
<stop offset="1" stop-color="#b07a1e" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ig-top" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#fff6d6" />
|
||||
<stop offset="1" stop-color="#f4cf6a" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ig-side" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#c58f2a" />
|
||||
<stop offset="1" stop-color="#7d5313" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ig-shine" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity="0" />
|
||||
<stop offset="0.5" stop-color="#fff" stop-opacity="0.75" />
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<clipPath id="ig-clip">
|
||||
<polygon points="0,64 176,64 156,20 20,20" />
|
||||
</clipPath>
|
||||
<g id="ingot">
|
||||
<polygon points="20,20 156,20 172,6 36,6" fill="url(#ig-top)" />
|
||||
<polygon points="156,20 172,6 196,50 176,64" fill="url(#ig-side)" />
|
||||
<polygon points="0,64 176,64 156,20 20,20" fill="url(#ig-front)" />
|
||||
<g clip-path="url(#ig-clip)">
|
||||
<rect class="shine" x="-80" y="0" width="60" height="70" fill="url(#ig-shine)" />
|
||||
</g>
|
||||
<text x="88" y="49" text-anchor="middle" class="ingot-label">999.9</text>
|
||||
</g>
|
||||
</defs>
|
||||
<use href="#ingot" x="0" y="160" />
|
||||
<use href="#ingot" x="196" y="160" />
|
||||
<use href="#ingot" x="98" y="104" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<ul class="chips">
|
||||
<li><LineChartOutlined /> گزارش لحظهای</li>
|
||||
<li><TeamOutlined /> مدیریت مشتریان</li>
|
||||
<li><ShoppingOutlined /> پیگیری سفارش</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="form-side">
|
||||
<div class="glass-card">
|
||||
<div class="card-head">
|
||||
<div class="logo-chip logo-chip--sm">
|
||||
<img src="../../assets/images/logo/brand-icon.png" :alt="brand.nameEn" />
|
||||
</div>
|
||||
<span class="eyebrow"><span class="dot"></span>پنل مدیریت</span>
|
||||
</div>
|
||||
|
||||
<h1 class="title">ورود به محیط مدیریت</h1>
|
||||
<p class="subtitle">لطفا شماره تلفن همراه خود به همراه رمز عبور وارد کنید.</p>
|
||||
|
||||
<a-form layout="vertical" class="login-form" @submit.prevent="loginJWT">
|
||||
<a-form-item
|
||||
label="موبایل"
|
||||
:validate-status="mobileError ? 'error' : ''"
|
||||
:help="mobileError"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="mobile"
|
||||
autocomplete="off"
|
||||
inputmode="numeric"
|
||||
placeholder="09xxxxxxxxx"
|
||||
class="ltr-input"
|
||||
@blur="touchedMobile = true"
|
||||
>
|
||||
<template #prefix><MobileOutlined class="field-icon" /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="رمز عبور"
|
||||
:validate-status="passwordError ? 'error' : ''"
|
||||
:help="passwordError"
|
||||
>
|
||||
<a-input-password
|
||||
v-model:value="password"
|
||||
placeholder="••••••"
|
||||
class="ltr-input"
|
||||
@blur="touchedPassword = true"
|
||||
@pressEnter="loginJWT"
|
||||
>
|
||||
<template #prefix><LockOutlined class="field-icon" /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
class="submit-btn"
|
||||
:disabled="!validateForm"
|
||||
:loading="loading"
|
||||
@click="loginJWT"
|
||||
>
|
||||
<span>ورود</span>
|
||||
<ArrowLeftOutlined v-if="!loading" class="btn-arrow" />
|
||||
</a-button>
|
||||
</a-form>
|
||||
|
||||
<p class="secure-note">
|
||||
<SafetyCertificateOutlined />
|
||||
<span>اتصال شما رمزنگاری شده و امن است</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="page-footer">© {{ brand.nameEn }}</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { brand } from '../../brand';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
LineChartOutlined,
|
||||
LockOutlined,
|
||||
MobileOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ShoppingOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-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);
|
||||
|
||||
// Deterministic positions so the sparkles do not jump on re-render.
|
||||
function sparkleStyle(n: number) {
|
||||
const x = (n * 37) % 100;
|
||||
const y = (n * 53) % 100;
|
||||
return {
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
animationDelay: `${(n * 0.7) % 6}s`,
|
||||
transform: `scale(${0.6 + ((n * 13) % 10) / 15})`,
|
||||
};
|
||||
}
|
||||
|
||||
// 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 equivalent route without discarding the freshly-populated store.
|
||||
await router.push('/dashboard/ecommerce');
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
message.error('خطا در ورود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#page-login {
|
||||
--bg: #060b1c;
|
||||
--navy: #1c2d56;
|
||||
--gold-1: #fff1c1;
|
||||
--gold-2: #f2c95c;
|
||||
--gold-3: #c8902a;
|
||||
--teal: #13877a;
|
||||
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #e6ebf5;
|
||||
background:
|
||||
radial-gradient(1000px 700px at 85% -10%, #14234a 0%, transparent 60%),
|
||||
radial-gradient(800px 600px at 0% 110%, #0e2a33 0%, transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* ---------- animated background ---------- */
|
||||
.aurora {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
filter: blur(90px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: drift 18s ease-in-out infinite alternate;
|
||||
}
|
||||
.blob--gold {
|
||||
width: 46vmax;
|
||||
height: 46vmax;
|
||||
top: -18vmax;
|
||||
left: -10vmax;
|
||||
background: radial-gradient(circle, rgba(242, 201, 92, 0.38), transparent 65%);
|
||||
}
|
||||
.blob--teal {
|
||||
width: 40vmax;
|
||||
height: 40vmax;
|
||||
bottom: -20vmax;
|
||||
right: -8vmax;
|
||||
background: radial-gradient(circle, rgba(19, 135, 122, 0.42), transparent 65%);
|
||||
animation-duration: 22s;
|
||||
animation-direction: alternate-reverse;
|
||||
}
|
||||
.blob--navy {
|
||||
width: 36vmax;
|
||||
height: 36vmax;
|
||||
top: 30%;
|
||||
left: 40%;
|
||||
background: radial-gradient(circle, rgba(60, 90, 170, 0.35), transparent 65%);
|
||||
animation-duration: 26s;
|
||||
}
|
||||
@keyframes drift {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(6vmax, 4vmax) scale(1.08); }
|
||||
100% { transform: translate(-4vmax, 6vmax) scale(0.95); }
|
||||
}
|
||||
.grid-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
mask-image: radial-gradient(ellipse 70% 60% at 50% 45%, #000 20%, transparent 80%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 70% 60% at 50% 45%, #000 20%, transparent 80%);
|
||||
}
|
||||
.sparkles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sparkles i {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--gold-1);
|
||||
box-shadow: 0 0 10px 2px rgba(242, 201, 92, 0.7);
|
||||
opacity: 0;
|
||||
animation: twinkle 6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.login-layout {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 32px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 64px;
|
||||
}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.logo-chip {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2),
|
||||
0 10px 30px -8px rgba(242, 201, 92, 0.45);
|
||||
}
|
||||
.logo-chip img {
|
||||
width: 64%;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
color: #fff;
|
||||
}
|
||||
.hero-title {
|
||||
margin: 36px 0 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: #fff;
|
||||
}
|
||||
.gold-text {
|
||||
display: block;
|
||||
background: linear-gradient(100deg, var(--gold-1) 0%, var(--gold-2) 40%, var(--gold-3) 70%, var(--gold-1) 100%);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: gold-flow 6s linear infinite;
|
||||
}
|
||||
@keyframes gold-flow {
|
||||
to { background-position: 200% center; }
|
||||
}
|
||||
.hero-sub {
|
||||
margin: 0;
|
||||
max-width: 440px;
|
||||
font-size: 15px;
|
||||
line-height: 1.9;
|
||||
color: rgba(230, 235, 245, 0.65);
|
||||
}
|
||||
.ingots {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
margin: 40px 0 28px;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
.ingot-glow {
|
||||
position: absolute;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
bottom: -6%;
|
||||
height: 40%;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(ellipse, rgba(242, 201, 92, 0.55), transparent 70%);
|
||||
filter: blur(24px);
|
||||
}
|
||||
.ingot-svg {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 18px 24px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
.ingot-label {
|
||||
font: 700 13px/1 ui-sans-serif, system-ui, sans-serif;
|
||||
fill: rgba(110, 70, 10, 0.55);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.shine {
|
||||
animation: shine 4.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes shine {
|
||||
0%, 55% { transform: translateX(0); }
|
||||
100% { transform: translateX(320px); }
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-12px); }
|
||||
}
|
||||
.chips {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.chips li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.chips li :deep(.anticon) {
|
||||
color: var(--gold-2);
|
||||
}
|
||||
|
||||
/* ---------- glass card ---------- */
|
||||
.form-side {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.glass-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
padding: 40px 36px 32px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(160deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.03));
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(140%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12),
|
||||
0 30px 80px -20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.glass-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(140deg, rgba(242, 201, 92, 0.6), rgba(255, 255, 255, 0.08) 35%, rgba(19, 135, 122, 0.5));
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.logo-chip--sm {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--gold-2);
|
||||
background: rgba(242, 201, 92, 0.1);
|
||||
border: 1px solid rgba(242, 201, 92, 0.25);
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.2);
|
||||
}
|
||||
.title {
|
||||
margin: 28px 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 0 0 28px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.9;
|
||||
color: rgba(230, 235, 245, 0.6);
|
||||
}
|
||||
|
||||
/* ---------- form controls (dark) ---------- */
|
||||
.login-form :deep(.ant-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.login-form :deep(.ant-form-item-label > label) {
|
||||
color: rgba(230, 235, 245, 0.85);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
.login-form :deep(.ant-input-affix-wrapper) {
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: rgba(6, 11, 28, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.login-form :deep(.ant-input-affix-wrapper:hover) {
|
||||
border-color: rgba(242, 201, 92, 0.45);
|
||||
}
|
||||
.login-form :deep(.ant-input-affix-wrapper-focused) {
|
||||
background: rgba(6, 11, 28, 0.75);
|
||||
border-color: var(--gold-2);
|
||||
box-shadow: 0 0 0 4px rgba(242, 201, 92, 0.15);
|
||||
}
|
||||
.login-form :deep(.ant-input-affix-wrapper-status-error) {
|
||||
border-color: #f87171 !important;
|
||||
box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.12) !important;
|
||||
}
|
||||
.login-form :deep(.ant-input) {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
}
|
||||
.login-form :deep(.ant-input::placeholder) {
|
||||
color: rgba(230, 235, 245, 0.3);
|
||||
}
|
||||
.login-form :deep(.ant-input-password-icon),
|
||||
.login-form :deep(.ant-input-suffix .anticon) {
|
||||
color: rgba(230, 235, 245, 0.45);
|
||||
}
|
||||
.login-form :deep(.ant-input-password-icon:hover) {
|
||||
color: var(--gold-2);
|
||||
}
|
||||
.login-form :deep(.ant-form-item-explain-error) {
|
||||
color: #fca5a5;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.login-form :deep(input:-webkit-autofill) {
|
||||
-webkit-text-fill-color: #fff;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
.ltr-input :deep(input) {
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
.field-icon {
|
||||
color: var(--gold-2);
|
||||
font-size: 16px;
|
||||
margin-inline-end: 8px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ---------- button ---------- */
|
||||
.submit-btn {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 54px;
|
||||
margin-top: 8px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #2a1a02;
|
||||
background: linear-gradient(135deg, var(--gold-1) 0%, var(--gold-2) 45%, var(--gold-3) 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6),
|
||||
0 14px 30px -12px rgba(242, 201, 92, 0.75);
|
||||
transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
.submit-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 40%;
|
||||
left: -60%;
|
||||
background: linear-gradient(100deg, transparent, rgba(255, 255, 255, 0.55), transparent);
|
||||
transform: skewX(-20deg);
|
||||
}
|
||||
.submit-btn:not(:disabled):hover {
|
||||
color: #2a1a02;
|
||||
background: linear-gradient(135deg, var(--gold-1) 0%, var(--gold-2) 45%, var(--gold-3) 100%);
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.05);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6),
|
||||
0 18px 36px -12px rgba(242, 201, 92, 0.9);
|
||||
}
|
||||
.submit-btn:not(:disabled):hover::after {
|
||||
animation: btn-shine 0.9s ease;
|
||||
}
|
||||
@keyframes btn-shine {
|
||||
to { left: 120%; }
|
||||
}
|
||||
.submit-btn:disabled,
|
||||
.submit-btn:disabled:hover {
|
||||
color: rgba(230, 235, 245, 0.35);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.btn-arrow {
|
||||
font-size: 15px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.submit-btn:not(:disabled):hover .btn-arrow {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
.secure-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin: 22px 0 0;
|
||||
font-size: 12px;
|
||||
color: rgba(230, 235, 245, 0.45);
|
||||
}
|
||||
.secure-note :deep(.anticon) {
|
||||
color: #34d399;
|
||||
}
|
||||
.page-footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 0 18px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: rgba(230, 235, 245, 0.3);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width: 1100px) {
|
||||
.hero-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.login-layout {
|
||||
gap: 40px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 991px) {
|
||||
.login-layout {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 480px;
|
||||
padding: 32px 20px;
|
||||
gap: 0;
|
||||
}
|
||||
.hero {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 575px) {
|
||||
.login-layout {
|
||||
padding: 20px 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.glass-card {
|
||||
padding: 28px 20px 24px;
|
||||
border-radius: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.blob,
|
||||
.sparkles i,
|
||||
.gold-text,
|
||||
.ingots,
|
||||
.shine {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/branch/AddBranches.vue.
|
||||
Serves BOTH create and edit — EditBranches.vue is a wrapper over it.
|
||||
Converted to a modal (BaseFormModal): the /branch/add and /branch/edit/:id
|
||||
routes still exist and still render this component directly as a route-based
|
||||
fallback, but ListBranches.vue now opens it as a modal via open/id props.
|
||||
|
||||
A branch's manager is an EXISTING admin picked from user/list. (The workshop
|
||||
form differs: it creates its supervisor inline — see AddWorkshop.vue.)
|
||||
-->
|
||||
<BaseFormModal
|
||||
v-if="asModal"
|
||||
:open="Boolean(open)"
|
||||
:id="id"
|
||||
add-title="افزودن شعبه"
|
||||
edit-title="ویرایش شعبه"
|
||||
:loading="loading"
|
||||
:submitting="loading"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<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>
|
||||
</BaseFormModal>
|
||||
|
||||
<!-- route-based fallback for direct links to /branch/add and /branch/edit/:id -->
|
||||
<a-spin v-else :spinning="loading">
|
||||
<a-form layout="vertical" @submit.prevent="handleSubmit">
|
||||
<a-card :title="id ? 'ویرایش شعبه' : 'افزودن شعبه'" class="mb-base page-card">
|
||||
<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" class="form-submit" @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 BaseFormModal from '../../components/common/BaseFormModal.vue';
|
||||
import api from '../../api/client';
|
||||
|
||||
const props = defineProps<{
|
||||
/** Present -> rendered as a modal by the parent list page. */
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
// Fixed at mount: the parent decides once whether this instance is a modal
|
||||
// or the plain /branch/add or /branch/edit/:id route.
|
||||
const asModal = props.open !== undefined;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = asModal ? props.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('اطلاعات با موفقیت به ثبت رسید.');
|
||||
if (!id) {
|
||||
Object.assign(form, { name: '', phone: '', address: '', user_id: undefined });
|
||||
}
|
||||
if (asModal) {
|
||||
emit('success');
|
||||
emit('update:open', false);
|
||||
} else {
|
||||
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>
|
||||
|
||||
<!-- .grid-2 now comes from the global utilities.less (dedup) -->
|
||||
@@ -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,111 @@
|
||||
<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.
|
||||
|
||||
The add/edit modal itself is passed in via the default slot (branch and
|
||||
workshop use different forms), so this component stays generic instead of
|
||||
growing a v-if per caller.
|
||||
-->
|
||||
<a-card :title="title" class="page-card">
|
||||
<XTable ref="tbl" :model="model" mobile-cards>
|
||||
<template v-if="addRid" #add>
|
||||
<ListPageHeader :rid="addRid" :add-label="addLabel" @add="openAdd()" />
|
||||
</template>
|
||||
|
||||
<template #actions="{ record }">
|
||||
<div class="flex gap-2">
|
||||
<a-tooltip v-if="addRid" title="ویرایش">
|
||||
<EditOutlined class="cursor-pointer mx-1 text-primary" @click="openEdit(record.id)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-else 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>
|
||||
|
||||
<slot name="form" :open="modalOpen" :editing-id="editingId" :on-update-open="onUpdateOpen" :on-success="onSuccess" />
|
||||
</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 ListPageHeader from '../../components/common/ListPageHeader.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 when no modal form is supplied, e.g.
|
||||
* '/branch/edit' or '/workshop/edit'. */
|
||||
editPath: string;
|
||||
/** The toggle endpoint, e.g. 'branch/active' or 'workshop/active'. */
|
||||
activePath: string;
|
||||
/** When set, edit/add open the #form slot's modal instead of navigating,
|
||||
* gated on this rid (the same one the /add route used). */
|
||||
addRid?: string;
|
||||
addLabel?: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const tbl = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
|
||||
const modalOpen = ref(false);
|
||||
const editingId = ref<string | undefined>(undefined);
|
||||
|
||||
function openAdd() {
|
||||
editingId.value = undefined;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
editingId.value = id;
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function onUpdateOpen(value: boolean) {
|
||||
modalOpen.value = value;
|
||||
}
|
||||
|
||||
function onSuccess() {
|
||||
tbl.value?.fetch();
|
||||
}
|
||||
|
||||
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,24 @@
|
||||
<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"
|
||||
add-rid="branch.create"
|
||||
add-label="افزودن شعبه"
|
||||
>
|
||||
<template #form="{ open, editingId, onUpdateOpen, onSuccess }">
|
||||
<AddBranches :key="editingId ?? 'new'" :open="open" :id="editingId" @update:open="onUpdateOpen" @success="onSuccess" />
|
||||
</template>
|
||||
</FacilityList>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FacilityList from './FacilityList.vue';
|
||||
import AddBranches from './AddBranches.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 class="page-card">
|
||||
<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="فاکتور خرید" class="page-card">
|
||||
<XTable ref="tbl" :model="model" mobile-cards>
|
||||
<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%" wrap-class-name="detail-modal">
|
||||
<div v-if="tempRow">
|
||||
<a-descriptions bordered size="small" :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 1 }">
|
||||
<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,98 @@
|
||||
<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" class="page-card table-scroll">
|
||||
<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;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.mt-4 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
</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/brand-icon.png"
|
||||
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,268 @@
|
||||
<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="ثبت سفارش" class="page-card">
|
||||
<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" mobile-cards>
|
||||
<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 table-scroll">
|
||||
<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" class="form-submit" :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;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.customer-field {
|
||||
max-width: none;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.totals {
|
||||
margin: 12px 0;
|
||||
}
|
||||
h6 {
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
</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
|
||||
//
|
||||
// Legacy's 'level' column always rendered blank (the payload had no such
|
||||
// field); it is the parent city here, now that cities are a tree (PERS-02).
|
||||
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('parent_title', 'والد').renderSlot('parent_title').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,136 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/city/cityList.vue.
|
||||
The edit modal shows the city's OWN percents. Legacy filled it from
|
||||
get-percent / get-percent-aux, which return the parent's percent when the
|
||||
city has none — so saving silently copied the parent's onto the child.
|
||||
-->
|
||||
<div id="page-user-list">
|
||||
<a-card title="لیست شهر ها" class="page-card">
|
||||
<XTable ref="citiesTable" :model="model" mobile-cards>
|
||||
<template #add>
|
||||
<ListPageHeader rid="cities.create" add-label="افزودن شهر" @add="addOpen = true" />
|
||||
</template>
|
||||
|
||||
<template #parent_title="{ text }">
|
||||
{{ text ?? '—' }}
|
||||
</template>
|
||||
|
||||
<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(`cities/${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="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect
|
||||
v-model:value="form.parent"
|
||||
:cities="cities"
|
||||
parent-picker
|
||||
:exclude-id="tempRow?.id"
|
||||
placeholder="والد را انتخاب کنید"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<CreateCity :open="addOpen" @update:open="addOpen = $event" @success="citiesTable?.fetch()" />
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
import XTable from '../../components/x-table/XTable.vue';
|
||||
import ListPageHeader from '../../components/common/ListPageHeader.vue';
|
||||
import CreateCity from './createCity.vue';
|
||||
import { buildCityListModel } from './CityListModel';
|
||||
import { useUsersStore } from '../../stores/users';
|
||||
import { useDeleteHandler } from '../../composables/useDeleteHandler';
|
||||
import api from '../../api/client';
|
||||
import CityTreeSelect from '../../components/common/CityTreeSelect.vue';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
const model = buildCityListModel();
|
||||
const citiesTable = ref<{ fetch: () => Promise<void> } | null>(null);
|
||||
const addOpen = ref(false);
|
||||
const visible = ref(false);
|
||||
const tempRow = ref<Record<string, any> | undefined>(undefined);
|
||||
const form = reactive<Record<string, any>>({ title: '', parent: undefined, percent: null, percent_aux: null });
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null }[]>([]);
|
||||
|
||||
async function edit(row: Record<string, any>) {
|
||||
tempRow.value = row;
|
||||
form.title = row.title ?? '';
|
||||
form.parent = row.parent ?? undefined;
|
||||
form.percent = row.percent === null || row.percent === undefined ? null : Number(row.percent);
|
||||
form.percent_aux = row.percent_aux === null || row.percent_aux === undefined ? null : Number(row.percent_aux);
|
||||
try {
|
||||
const { data } = await api.post('cities/list');
|
||||
cities.value = data.rows ?? [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
await api.put(`cities/${tempRow.value?.id}`, {
|
||||
title: form.title,
|
||||
parent: form.parent ?? null,
|
||||
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 ?? 'خطا در ثبت تغییرات');
|
||||
}
|
||||
}
|
||||
|
||||
// The backend refuses while customers, branches or addresses reference it.
|
||||
const { remove: confirmDelete } = useDeleteHandler({
|
||||
onSuccess: () => citiesTable.value?.fetch(),
|
||||
successMessage: 'شهر حذف شد',
|
||||
fallbackErrorMessage: 'خطا در حذف شهر',
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<!--
|
||||
Ported from legacy henza_admin/src/views/city/createCity.vue.
|
||||
Converted from a standalone page to a modal (BaseFormModal): the /city/create
|
||||
route still exists and still renders this component directly for a route-based
|
||||
fallback (a-form as a plain page), but cityList.vue now opens it as a modal via
|
||||
the `open`/`id` props instead of navigating.
|
||||
-->
|
||||
<BaseFormModal
|
||||
v-if="asModal"
|
||||
:open="Boolean(open)"
|
||||
:id="id"
|
||||
add-title="افزودن شهر"
|
||||
edit-title="افزودن شهر"
|
||||
:loading="loading"
|
||||
:submitting="loading"
|
||||
width="sm"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<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="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect v-model:value="form.parent" :cities="cities" parent-picker placeholder="والد را انتخاب کنید" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد مشتری عمده">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد کارشناس فروش">
|
||||
<a-input-number v-model:value="form.percent_aux" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</BaseFormModal>
|
||||
|
||||
<!-- route-based fallback for direct links to /city/create -->
|
||||
<a-spin v-else :spinning="loading">
|
||||
<a-form layout="vertical" @submit.prevent="handleSubmit">
|
||||
<a-card title="افزودن شهر" class="page-card">
|
||||
<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="والد" help="خالی = سرشاخه، مثل کشور">
|
||||
<CityTreeSelect v-model:value="form.parent" :cities="cities" parent-picker placeholder="والد را انتخاب کنید" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد مشتری عمده">
|
||||
<a-input-number v-model:value="form.percent" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="درصد کارشناس فروش">
|
||||
<a-input-number v-model:value="form.percent_aux" class="w-full" :min="0" :max="100" :step="0.1" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-button type="primary" class="form-submit" @click="handleSubmit">ثبت اطلاعات</a-button>
|
||||
</a-card>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
import BaseFormModal from '../../components/common/BaseFormModal.vue';
|
||||
import api from '../../api/client';
|
||||
import CityTreeSelect from '../../components/common/CityTreeSelect.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** Present -> rendered as a modal by the parent list page. */
|
||||
open?: boolean;
|
||||
id?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}>();
|
||||
|
||||
// `asModal` is fixed at mount: the parent decides which way this component is
|
||||
// used (modal vs the plain /city/create route) by passing `open` or not.
|
||||
const asModal = props.open !== undefined;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const form = reactive<Record<string, any>>({ title: '', parent: undefined, percent: null, percent_aux: null });
|
||||
const cities = ref<{ id: string; title: string; parent?: string | null }[]>([]);
|
||||
|
||||
async function loadCities() {
|
||||
try {
|
||||
const { data } = await api.post('cities/list');
|
||||
cities.value = data.rows ?? [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
// Fresh each time the modal opens, so a city added a moment ago can be a parent.
|
||||
if (asModal) watch(() => props.open, (o) => o && loadCities(), { immediate: true });
|
||||
else onMounted(loadCities);
|
||||
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, parent: form.parent ?? null });
|
||||
message.info('اطلاعات با موفقیت به ثبت رسید.');
|
||||
if (asModal) {
|
||||
// Keep the parent: cities are usually added several under the same one.
|
||||
Object.assign(form, { title: '', percent: null, percent_aux: null });
|
||||
emit('success');
|
||||
emit('update:open', false);
|
||||
} else {
|
||||
router.push('/city/list');
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.message ?? 'خطا در ثبت اطلاعات.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- .grid-2 now comes from the global utilities.less (dedup) -->
|
||||
@@ -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,290 @@
|
||||
<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="لیست دیدگاه ها" class="page-card">
|
||||
<XTable ref="tbl" :model="model" mobile-cards>
|
||||
<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%" wrap-class-name="detail-modal">
|
||||
<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/brand-icon.png" 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';
|
||||
import { brand } from '../../../brand';
|
||||
|
||||
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:
|
||||
* <site>/product/<title-with-dashes>/<id>
|
||||
* The host is the live public site (brand.siteUrl), not the API.
|
||||
*/
|
||||
function productLink(record: Record<string, any>) {
|
||||
const slug = String(record.product?.title ?? '').split(' ').join('-');
|
||||
return `${brand.siteUrl}/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%;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.comment-item {
|
||||
gap: 10px;
|
||||
flex-wrap: nowrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.comment-item .img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.comment-body {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user