feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
+95
View File
@@ -0,0 +1,95 @@
// Ported from legacy henza_admin/src/store/auth/moduleAuth.js
import { defineStore } from 'pinia';
import api from '../api/client';
import { tokenName, userName } from '../utils';
import { useUsersStore } from './users';
import type { UserInfo } from './users';
/**
* LEGACY BUG, recorded in docs/ADMIN-PORT-PLAN.md (#3).
*
* Legacy declared these as FUNCTIONS in Vuex state, not values:
*
* const state = {
* user: () => JSON.parse(localStorage.getItem(...)),
* token: () => localStorage.getItem(...),
* }
*
* so `store.state.auth.token` was a function object — always truthy, even when
* logged out. The router guard's `store.state.auth.token && store.state.auth.user`
* check was therefore dead code that always passed, and the real gate was the
* separate `localStorage.getItem(token)` read in beforeEach.
*
* Here they are getters that return the actual value. The observable behaviour
* is unchanged, because the real gate was — and still is — the localStorage read;
* this only removes a permanently-true condition, it does not let anyone in.
*/
export const useAuthStore = defineStore('auth', {
state: () => ({
userPermission: [] as string[],
}),
getters: {
token: (): string | null => localStorage.getItem(tokenName),
user: (): UserInfo | null => {
const raw = localStorage.getItem(userName);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
},
isLoggedIn(): boolean {
return Boolean(this.token);
},
},
actions: {
/**
* Port of legacy action `login`.
*
* Legacy: POST 'admin-login', then
* dispatch('users/getRoles', data.roles.map(x => x.rid))
* delete data.roles; delete data.ut
* commit('INJECT', data)
*
* The endpoint name is translated by the mapping layer in api/endpoints.ts.
*/
async login(payload: { phone: string; password: string }) {
const { data } = await api.post('admin-login', payload);
const users = useUsersStore();
users.getRoles((data.roles ?? []).map((x: { rid: string }) => x.rid));
// Legacy stripped these before persisting the user blob. Kept: the panel
// reads permissions from the store, never from the cached user object.
const persisted = { ...data };
delete persisted.roles;
delete persisted.ut;
this.inject(persisted);
return persisted;
},
/** Port of legacy mutation INJECT. */
inject(payload: UserInfo & { api_token: string }) {
localStorage.setItem(tokenName, payload.api_token);
// Consumed once by App.vue to show the welcome toast, then removed.
localStorage.setItem('login', 'true');
localStorage.setItem(userName, JSON.stringify(payload));
},
/**
* Not present in legacy — legacy had no logout action at all, which is why
* its module-scoped `roles` survived a session change (bug #2). Clearing
* both storage and the roles store is the fix for that.
*/
logout() {
localStorage.removeItem(tokenName);
localStorage.removeItem(userName);
localStorage.removeItem('login');
const users = useUsersStore();
users.deleteRoles();
users.deleteUserInfo();
},
},
});
+125
View File
@@ -0,0 +1,125 @@
// Ported from legacy henza_admin/src/store/cart/cart.js
//
// The admin-side order builder's basket. Behaviour is preserved exactly,
// including the two guards legacy applied:
// - ADD_TO_CART refuses an out-of-stock variant that is not a pre-order
// (`!variant.is_pre_order && variant.qty === 0`)
// - init() re-fetches every cart product and drops any that became
// out-of-stock while the basket sat in localStorage
//
// Storage key differs per mode ('admin_cart_items' vs 'branch_cart_items'),
// as in legacy.
import { defineStore } from 'pinia';
import api from '../api/client';
import { isBranch } from '../utils';
const cartKey = isBranch() ? 'branch_cart_items' : 'admin_cart_items';
export interface CartVariant {
id: string;
is_pre_order?: boolean;
qty?: number;
price?: number;
price_with_discount?: number;
grams?: number;
title?: string;
}
export interface CartProduct {
id: string;
variants_all: CartVariant[];
promotion?: { id: string } | null;
[k: string]: unknown;
}
export interface CartItem {
vid: string;
pid: string;
product: CartProduct;
qty: number;
promotion_id?: string | null;
}
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
}),
actions: {
/** legacy mutation ADD_TO_CART */
addToCart(data: CartItem) {
const index = this.items.findIndex((item) => item.pid == data.pid && item.vid == data.vid);
const variant = data.product.variants_all.filter((x) => x.id === data.vid)[0];
// legacy guard: reject an out-of-stock, non-pre-order variant
if (variant && !(!variant.is_pre_order && variant.qty === 0)) {
if (index == -1) {
this.items.push(data);
} else {
this.items[index].product = data.product;
this.items[index].qty = data.qty;
}
}
},
/** legacy mutation INIT — also persists to localStorage */
setItems(res: CartItem[]) {
this.items = res;
localStorage.setItem(cartKey, JSON.stringify(res));
},
add(data: CartItem) {
this.addToCart(data);
this.setItems(this.items);
},
remove(item: { pid: string; vid: string }) {
const index = this.items.findIndex((v) => v.pid == item.pid && v.vid == item.vid);
if (index > -1) this.items.splice(index, 1);
this.setItems(this.items);
},
updateItemQuantity(payload: { index: number; quantity: number }) {
this.items[payload.index].qty = payload.quantity;
this.setItems(this.items);
},
clear() {
this.items = [];
this.setItems(this.items);
},
/**
* legacy init(): rehydrate from localStorage, then re-fetch each product so
* prices and stock are current, dropping anything now out of stock.
*/
async init() {
try {
const items: CartItem[] = JSON.parse(localStorage.getItem(cartKey) ?? '[]') || [];
const ids = items.map((item) => item.product.id);
if (ids.length === 0) {
this.setItems([]);
return;
}
const { data } = await api.post('product/variant', { products_id: ids });
if (data) {
const exp: CartItem[] = [];
for (const cartItem of items) {
const product = (data as CartProduct[]).find((item) => item.id == cartItem.product.id);
if (!product) continue;
if (!(!product.variants_all[0].is_pre_order && product.variants_all[0].qty === 0)) {
exp.push({
vid: cartItem.vid,
pid: product.id,
product,
qty: cartItem.qty,
promotion_id: product.promotion && product.promotion.id ? product.promotion.id : null,
});
}
}
this.setItems(exp);
}
} catch (e) {
console.log('error in cart', e);
}
},
},
});
@@ -0,0 +1,86 @@
// Ported from legacy henza_admin/src/store/invoiceCart/invoiceCart.js
//
// The basket behind the branch-allocation ("فاکتور") screen. Structurally a copy
// of the order cart, with two deliberate differences:
//
// 1. STORAGE KEY — legacy wrote BOTH carts to 'admin_cart_items', so building an
// allocation clobbered a half-finished order and vice versa. Verified in
// legacy: store/cart/cart.js:27 and store/invoiceCart/invoiceCart.js:27 use
// the same key. This cart gets its own key instead; reproducing silent data
// loss would be worse than the deviation. Recorded in docs/ADMIN-PORT-PLAN.md.
//
// 2. No re-hydrate fetch — the allocation screen only needs product ids, so
// there is no variant/stock refresh to perform.
import { defineStore } from 'pinia';
import { isBranch } from '../utils';
const cartKey = isBranch() ? 'branch_invoice_items' : 'admin_invoice_items';
export interface InvoiceCartItem {
pid: string;
vid?: string;
qty: number;
product: Record<string, unknown>;
}
export const useInvoiceCartStore = defineStore('invoiceCart', {
state: () => ({
items: [] as InvoiceCartItem[],
}),
actions: {
/** legacy mutation INIT — also persists */
setItems(res: InvoiceCartItem[]) {
this.items = res;
localStorage.setItem(cartKey, JSON.stringify(res));
},
/** legacy mutation ADD_TO_CART, including its out-of-stock guard */
add(data: InvoiceCartItem) {
const index = this.items.findIndex((i) => i.pid == data.pid && i.vid == data.vid);
const variants = (data.product?.variants_all ?? []) as {
id: string;
qty?: number;
is_pre_order?: boolean;
}[];
const variant = variants.filter((x) => x.id === data.vid)[0];
// legacy: when a variant was named, it must be in stock or a pre-order.
// The allocation screen adds whole products (no vid), which legacy let
// through only because `variant` was undefined — preserved.
if (data.vid && variant && !variant.is_pre_order && variant.qty === 0) return;
if (index == -1) {
this.items.push(data);
} else {
this.items[index].product = data.product;
this.items[index].qty = data.qty;
}
this.setItems(this.items);
},
remove(item: { pid: string; vid?: string }) {
const index = this.items.findIndex((v) => v.pid == item.pid && v.vid == item.vid);
if (index > -1) this.items.splice(index, 1);
this.setItems(this.items);
},
updateItemQuantity(payload: { index: number; quantity: number }) {
this.items[payload.index].qty = payload.quantity;
this.setItems(this.items);
},
clear() {
this.items = [];
this.setItems(this.items);
},
/** Rehydrate from localStorage on mount. */
init() {
try {
this.items = JSON.parse(localStorage.getItem(cartKey) ?? '[]') || [];
} catch {
this.items = [];
}
},
},
});
+37
View File
@@ -0,0 +1,37 @@
// Pinia replacement for the parts of legacy henza_admin/src/store/state.js +
// mutations.js that the app shell actually uses: window width, scroll position,
// and the active theme. The Vuexy customizer state (sidebar width, theme tour,
// footer type, ...) is carried in themeConfig.ts instead, as in legacy.
import { defineStore } from 'pinia';
import themeConfig, { type ThemeMode } from '../themeConfig';
export const useLayoutStore = defineStore('layout', {
state: () => ({
windowWidth: typeof window !== 'undefined' ? window.innerWidth : 0,
scrollY: 0,
theme: themeConfig.theme as ThemeMode,
// legacy state.js: sidebar visibility for the vertical nav menu
isSidebarActive: true,
sidebarCollapsed: themeConfig.sidebarCollapsed,
}),
getters: {
// legacy used breakpoints of 1200 (theme tour) and 992 (nav menu collapse)
isSmallScreen: (s) => s.windowWidth < 992,
},
actions: {
// legacy mutation: UPDATE_WINDOW_WIDTH
updateWindowWidth(width: number) {
this.windowWidth = width;
},
// legacy mutation: UPDATE_WINDOW_SCROLL_Y
updateWindowScrollY(y: number) {
this.scrollY = y;
},
toggleSidebar(value?: boolean) {
this.isSidebarActive = value ?? !this.isSidebarActive;
},
setSidebarCollapsed(value: boolean) {
this.sidebarCollapsed = value;
},
},
});
-9
View File
@@ -1,9 +0,0 @@
/**
* Placeholder for the logged-in admin's identity. No auth module exists yet
* (see migration notes) — this just tracks UI selection state until then.
*/
export declare const useSessionStore: import("pinia").StoreDefinition<"session", {
selectedCustomerId: string | null;
}, {}, {
selectCustomer(customerId: string): void;
}>;
-15
View File
@@ -1,15 +0,0 @@
import { defineStore } from 'pinia';
/**
* Placeholder for the logged-in admin's identity. No auth module exists yet
* (see migration notes) — this just tracks UI selection state until then.
*/
export const useSessionStore = defineStore('session', {
state: () => ({
selectedCustomerId: null,
}),
actions: {
selectCustomer(customerId) {
this.selectedCustomerId = customerId;
},
},
});
-16
View File
@@ -1,16 +0,0 @@
import { defineStore } from 'pinia';
/**
* Placeholder for the logged-in admin's identity. No auth module exists yet
* (see migration notes) — this just tracks UI selection state until then.
*/
export const useSessionStore = defineStore('session', {
state: () => ({
selectedCustomerId: null as string | null,
}),
actions: {
selectCustomer(customerId: string) {
this.selectedCustomerId = customerId;
},
},
});
+114
View File
@@ -0,0 +1,114 @@
// Ported from legacy henza_admin/src/store/users/index.js
import { defineStore } from 'pinia';
import api from '../api/client';
/**
* Ported VERBATIM in behaviour from legacy store/users/index.js:3-9.
*
* getParents(['a','b','c']) -> ['a.b', 'a']
*/
function getParents(keys: string[]): string[] {
const work = [...keys];
const parents: string[] = [];
while (work.length > 1) {
work.pop();
parents.push(work.join('.'));
}
return parents;
}
/**
* Ported from legacy store/users/index.js:10-22.
*
* Granting "persons.list.client.omde" implicitly grants "persons.list.client",
* "persons.list" and "persons", which is what keeps parent menu nodes visible.
*
* The legacy version assigned to a module-scoped `let rid` and relied on that
* outer variable inside the .map() callback. This version is pure — identical
* output, but no state leaking between calls (legacy's `rid` survived logout).
*
* NOTE it only walks UP; a rid never implies its children. That is why a user
* granted "wallet" still cannot see the nav item guarded by "wallet.list" —
* a legacy bug, preserved deliberately. See docs/ADMIN-PORT-PLAN.md.
*/
export function checkParent(values: string[]): string[] {
let rid: string[] = [...values];
values.forEach((x) => {
const keys = x.split('.');
if (keys.length > 1) {
rid = [...new Set([...rid, ...getParents(keys)])];
}
});
return rid;
}
export interface UserInfo {
id?: string;
name?: string;
fullName?: string;
/**
* Legacy split names into fname/lname. The schema stores one fullName, but the
* API returns both keys so ported templates binding either keep working.
*/
fname?: string;
lname?: string;
mobile?: string;
role?: string;
ut?: number | null;
branch_id?: string | null;
user_type?: { id: number; title: string } | null;
}
export const useUsersStore = defineStore('users', {
state: () => ({
// legacy: cityFilters — id -> title map used by table filters
cityFilters: {} as Record<string | number, string>,
userInfo: undefined as UserInfo | undefined,
// undefined = "not fetched yet" (distinct from [] = "fetched, has none").
// The router guard relies on that distinction, as legacy's did.
roles: undefined as string[] | undefined,
}),
getters: {
/**
* Port of the global `Vue.prototype.$hasPermission` from legacy main.js.
* Returns undefined when roles have not loaded — legacy did the same, which
* renders as falsy in v-if.
*/
hasPermission: (state) => (permissionId: string) => {
const roles = state.roles;
if (roles) {
return roles.includes(permissionId);
}
return undefined;
},
},
actions: {
// legacy mutation setCities: builds an id -> title lookup
setCities(payload: { id: string | number; title: string }[]) {
payload.forEach((x) => {
this.cityFilters[x.id] = x.title;
});
},
async getCities() {
try {
const res = await api.post('cities/list');
this.setCities(res.data.rows);
} catch (e) {
console.log(e);
}
},
setUserInfo(payload: UserInfo) {
this.userInfo = payload;
},
deleteUserInfo() {
this.userInfo = {};
},
/** legacy action getRoles: commits checkParent(roles), never the raw list. */
getRoles(roles: string[]) {
this.roles = checkParent(roles);
},
deleteRoles() {
this.roles = undefined;
},
},
});