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