Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bf09862c8 | ||
|
|
710dff90db | ||
|
|
11793efed9 | ||
|
|
3961978bf4 | ||
|
|
fc0147f046 | ||
|
|
e763ad457c | ||
|
|
99c1ca4b1e | ||
|
|
51bac652df | ||
|
|
10c53e7d50 | ||
|
|
7e8dab160b | ||
|
|
be63c7c07d | ||
|
|
2a4a9cb260 | ||
|
|
6ff7354e7a | ||
|
|
6075f1ad10 | ||
|
|
7ddacc0d9a | ||
|
|
82082cb862 | ||
|
|
b93b71b1b1 | ||
|
|
acee988018 | ||
|
|
5d9851c6c6 | ||
|
|
d94b9ac76c | ||
|
|
4ae98a00a3 | ||
|
|
f0114314a6 | ||
|
|
eaab2cea89 | ||
|
|
917f197b88 | ||
|
|
0c4e01ff1f | ||
|
|
9d0e299e38 | ||
|
|
dc2b9b7746 | ||
|
|
8eee23da7d | ||
|
|
806582604d | ||
|
|
70d62da0c9 | ||
|
|
3132b9262c | ||
|
|
d139f42651 | ||
|
|
3bb264fd1c | ||
|
|
04b55dad7e | ||
|
|
3c34264423 | ||
|
|
8013650c9a | ||
|
|
5a8973ffe8 | ||
|
|
91f9d1f7da | ||
|
|
9026a382aa | ||
|
|
0df736d3bc | ||
|
|
6174def815 | ||
|
|
944d597a7f | ||
|
|
c93b11033b | ||
|
|
b40291cb67 | ||
|
|
4fded15646 | ||
|
|
1382c74225 | ||
|
|
a0bf3b07a8 |
@@ -0,0 +1,2 @@
|
||||
# Development environment
|
||||
VITE_API_BASE_URL=https://saas-dev.maskantech.in
|
||||
@@ -0,0 +1,2 @@
|
||||
# Local environment
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
@@ -0,0 +1,2 @@
|
||||
# Production environment
|
||||
VITE_API_BASE_URL=https://api.yourdomain.com
|
||||
@@ -1,6 +1,4 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="https://maskantech.netlify.app/maskan_fav.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>saas_frontend</title>
|
||||
<title>SaaS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+95
-1
@@ -20,6 +20,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"react-i18next": "^16.5.3",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwindcss": "^4.1.18"
|
||||
@@ -2305,6 +2306,12 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2373,6 +2380,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/country-flag-icons": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.4.tgz",
|
||||
"integrity": "sha512-Z3Zi419FI889tlElMsVhCIS5eRkiLDWixr576J5DPiTe5RGxpbRi+enMpHdYVp5iK5WFjr8P/RgyIFAGhFsiFg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3156,6 +3169,27 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/input-format": {
|
||||
"version": "0.3.14",
|
||||
"resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz",
|
||||
"integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.1.0",
|
||||
"react-dom": ">=18.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3199,7 +3233,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -3313,6 +3346,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.12.34",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.34.tgz",
|
||||
"integrity": "sha512-v/Ip8k8eYdp7bINpzqDh46V/PaQ8sK+qi97nMQgjZzFlb166YFqlR/HVI+MzsI9JqcyyVWCOipmmretiaSyQyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -3585,6 +3624,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3695,6 +3746,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/open-location-code": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/open-location-code/-/open-location-code-1.0.3.tgz",
|
||||
@@ -3841,6 +3901,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -3921,6 +3992,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-phone-number-input": {
|
||||
"version": "3.4.14",
|
||||
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
|
||||
"integrity": "sha512-T9MziNuvthzv6+JAhKD71ab/jVXW5U20nQZRBJd6+q+ujmkC+/ISOf2GYo8pIi4VGjdIYRIHDftMAYn3WKZT3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"country-flag-icons": "^1.5.17",
|
||||
"input-format": "^0.3.14",
|
||||
"libphonenumber-js": "^1.12.27",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||
|
||||
+9
-2
@@ -4,8 +4,14 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"local": "vite --mode localhost",
|
||||
"dev": "vite --mode development",
|
||||
"test": "vite --mode test",
|
||||
"prod": "vite --mode production",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:dev": "tsc -b && vite build --mode development",
|
||||
"build:test": "tsc -b && vite build --mode test",
|
||||
"build:prod": "tsc -b && vite build --mode production",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -22,6 +28,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"react-i18next": "^16.5.3",
|
||||
"react-phone-number-input": "^3.4.14",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwindcss": "^4.1.18"
|
||||
@@ -40,4 +47,4 @@
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -1,17 +1,30 @@
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
import "./toast.css";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { ThemeProvider } from "./context/ThemeContext";
|
||||
import AppRoutes from "./routes";
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
hideProgressBar={false}
|
||||
newestOnTop
|
||||
closeOnClick
|
||||
pauseOnHover
|
||||
draggable
|
||||
closeButton={true}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+4
-6
@@ -31,7 +31,7 @@ export const authApi = {
|
||||
payload,
|
||||
{ ...withTenantHeader(options?.tenantId), successMessage: "Account created", errorMessage: "Failed to create account" }
|
||||
),
|
||||
logout: () => apiClient.post<LogoutResponse>("/api/auth/logout", { successMessage: "Signed out", errorMessage: "Failed to sign out" }),
|
||||
logout: () => apiClient.post<LogoutResponse>("/api/auth/logout", null, { successMessage: "Signed out", errorMessage: "Failed to sign out" }),
|
||||
me: () => apiClient.get<AuthUser>("/api/auth/me"),
|
||||
resetPassword: (oldPassword: string, newPassword: string) =>
|
||||
apiClient.post<{ message: string }>("/api/auth/reset-password", {
|
||||
@@ -69,8 +69,6 @@ export const authApi = {
|
||||
silent: true,
|
||||
}),
|
||||
|
||||
refresh: (refreshToken: string) =>
|
||||
apiClient.post<TokenResponse>("/api/auth/refresh", {
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
};
|
||||
refresh: () =>
|
||||
apiClient.post<TokenResponse>("/api/auth/refresh", {}, { silent: true }),
|
||||
};
|
||||
+13
-2
@@ -6,11 +6,20 @@ export type Access = {
|
||||
};
|
||||
|
||||
export type Role = {
|
||||
id: string;
|
||||
role_name: string;
|
||||
id?: string | null;
|
||||
role_name?: string | null;
|
||||
accesses: string[];
|
||||
};
|
||||
|
||||
export type SubscriptionDetails = {
|
||||
plan_id?: string | null;
|
||||
plan_name?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
status?: string | null;
|
||||
is_active?: boolean | null;
|
||||
};
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -21,6 +30,7 @@ export type AuthUser = {
|
||||
tenant_id?: string | null;
|
||||
tenant_name?: string | null;
|
||||
tenant_logo_url?: string | null;
|
||||
subscription_details?: SubscriptionDetails | null;
|
||||
preferred_language?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -30,6 +40,7 @@ export type AuthUser = {
|
||||
export type SigninRequest = {
|
||||
email: string;
|
||||
password: string;
|
||||
remember_me?: boolean;
|
||||
};
|
||||
|
||||
export type SignupRequest = {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Mail, Lock, } from "lucide-react";
|
||||
import { CustomInput, CustomButton, CustomOTPInput, CustomBackButton } from "../../../components/Custom";
|
||||
import { CustomInput, CustomButton, CustomOTPInput, CustomBackButton } from "../../../components/custom";
|
||||
import { authApi } from "../AuthApi";
|
||||
|
||||
type Step = "email" | "otp" | "password";
|
||||
+1
-42
@@ -14,7 +14,7 @@ export default function SignInForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const { login } = useAuth(); // Retrieve login function from context
|
||||
const { login } = useAuth();
|
||||
|
||||
const handleSignIn = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -27,10 +27,8 @@ export default function SignInForm() {
|
||||
};
|
||||
|
||||
try {
|
||||
// Use the context login function to ensure state is updated
|
||||
await login(payload, isChecked);
|
||||
|
||||
// Redirect to the dashboard after successful signin.
|
||||
navigate("/dashboard");
|
||||
} catch (error) {
|
||||
const message =
|
||||
@@ -57,45 +55,6 @@ export default function SignInForm() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-black transition-colors bg-white rounded-lg px-7 hover:bg-gray-50 hover:text-gray-900 w-full">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z"
|
||||
fill="#EB4335"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative py-3 sm:py-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-700"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="p-2 text-gray-400 bg-slate-900 sm:px-5 sm:py-2">
|
||||
Or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSignIn}>
|
||||
<div className="space-y-6">
|
||||
<CustomInput
|
||||
+1
-4
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/Custom";
|
||||
import { CustomInput, CustomCheckBox, CustomButton } from "../../../components/custom";
|
||||
import type { SignupRequest } from "../AuthTypes";
|
||||
import { authApi } from "../AuthApi";
|
||||
|
||||
@@ -21,7 +21,6 @@ export default function SignUpForm() {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!isChecked) {
|
||||
// Require terms acceptance before sending the signup request.
|
||||
setErrorMessage("Please accept the terms and conditions to continue.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -35,10 +34,8 @@ export default function SignUpForm() {
|
||||
};
|
||||
|
||||
try {
|
||||
// Call the backend signup API.
|
||||
await authApi.signup(payload);
|
||||
|
||||
// Signup does not return tokens, so take the user to signin.
|
||||
navigate("/signin");
|
||||
} catch (error) {
|
||||
const message =
|
||||
@@ -1,25 +1,202 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ExternalLink, Box, Search, Rocket } from 'lucide-react';
|
||||
import { moduleApi, type Module } from '../modules/user/UserModuleApi';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomInput from '../../components/custom/CustomInput';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { user } = useAuth();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModules = async () => {
|
||||
try {
|
||||
const fetchedModules = await moduleApi.getAvailableModules();
|
||||
setModules(fetchedModules);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch modules', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchModules();
|
||||
}, []);
|
||||
|
||||
const handleModuleClick = async (moduleId: string) => {
|
||||
try {
|
||||
const response = await moduleApi.launchModule(moduleId);
|
||||
|
||||
await fetch(response.target_url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...response.headers },
|
||||
body: JSON.stringify(response.payload),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.redirect_url) {
|
||||
try {
|
||||
const url = new URL(response.redirect_url);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
throw new Error("Unsafe protocol");
|
||||
}
|
||||
window.location.href = response.redirect_url;
|
||||
} catch {
|
||||
console.error("Invalid redirect URL from SSO");
|
||||
alert("Failed to launch module. Please try again.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert("Failed to launch module. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const groupedModules = useMemo(() => {
|
||||
const filtered = modules.filter(m =>
|
||||
m.module_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(m.description && m.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const groups: Record<string, Module[]> = {};
|
||||
|
||||
if (filtered.length === 0) return {};
|
||||
|
||||
filtered.forEach(module => {
|
||||
const category = module.category || t('dashboard:allModules');
|
||||
if (!groups[category]) {
|
||||
groups[category] = [];
|
||||
}
|
||||
groups[category].push(module);
|
||||
});
|
||||
|
||||
return Object.keys(groups).sort().reduce(
|
||||
(obj, key) => {
|
||||
obj[key] = groups[key];
|
||||
return obj;
|
||||
},
|
||||
{} as Record<string, Module[]>
|
||||
);
|
||||
}, [modules, searchTerm, t]);
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex flex-col items-center justify-center p-4 text-center animate-fade-in">
|
||||
<div className="bg-[var(--card-bg)] p-8 rounded-2xl border border-[var(--card-border)] shadow-sm max-w-lg w-full flex flex-col items-center">
|
||||
<div className="bg-orange-50 p-4 rounded-full mb-6">
|
||||
<Construction className="w-12 h-12 text-orange-500" />
|
||||
<div className="space-y-6 min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 animate-fade-in-up">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">
|
||||
{t('dashboard:title')}, {user?.first_name || 'Admin'}! 👋
|
||||
</h1>
|
||||
<p className="text-sm text-(--text-secondary)">
|
||||
{t('dashboard:subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||
<div className="w-full md:w-80">
|
||||
<CustomInput
|
||||
placeholder={t('dashboard:searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
leftIcon={<Search size={18} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-[var(--text-primary)] mb-3">
|
||||
{t('title')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[var(--text-secondary)] text-lg mb-8 leading-relaxed">
|
||||
{t('message')}
|
||||
</p>
|
||||
{/* Modules Grid */}
|
||||
<div className="space-y-12">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<div key={i} className="bg-(--card-bg) border border-(--card-border) rounded-2xl p-6 h-48 animate-pulse shadow-sm flex flex-col">
|
||||
<div className="h-12 w-12 bg-gray-200 rounded-xl mb-4"></div>
|
||||
<div className="h-6 bg-gray-200 rounded w-3/4 mb-3"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-auto"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4 mt-4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : Object.keys(groupedModules).length > 0 ? (
|
||||
Object.entries(groupedModules).map(([category, categoryModules]) => (
|
||||
<section key={category} className="animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-(--text-primary) mb-6 flex items-center gap-2">
|
||||
<span className="w-1.5 h-6 bg-blue-500 rounded-full inline-block"></span>
|
||||
{category}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{categoryModules.map((module) => (
|
||||
<div
|
||||
key={module.module_id}
|
||||
className="group bg-white border border-gray-100 rounded-2xl p-6 hover:shadow-xl hover:border-blue-100 transition-all duration-300 relative overflow-hidden flex flex-col h-full"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-6 opacity-0 group-hover:opacity-100 transition-opacity transform translate-x-2 group-hover:translate-x-0 duration-300">
|
||||
<ExternalLink className="text-blue-500" size={20} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mb-6 relative">
|
||||
<div className="absolute inset-0 bg-blue-500/5 rounded-xl blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
{module.icon_url ? (
|
||||
<img
|
||||
src={module.icon_url}
|
||||
alt={module.module_name}
|
||||
className="w-14 h-14 rounded-xl object-contain relative z-10 bg-white p-1 shadow-sm border border-gray-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-xl bg-linear-to-br from-blue-50 to-indigo-50 border border-blue-100 flex items-center justify-center relative z-10 text-blue-600 shadow-sm">
|
||||
<Box className="w-7 h-7" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors">
|
||||
{module.module_name}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-500 leading-relaxed line-clamp-2 mb-4">
|
||||
{module.description || "Access powerful tools designed for efficient management and scalability."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-50 mt-auto">
|
||||
<button
|
||||
onClick={() => handleModuleClick(module.module_id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-50 hover:bg-blue-600 text-gray-700 hover:text-white rounded-xl font-medium transition-all duration-200 group-hover:shadow-md"
|
||||
>
|
||||
<Rocket size={18} className="transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
|
||||
{t('dashboard:actions.launch')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-12 bg-white border border-gray-100 rounded-2xl text-center shadow-sm">
|
||||
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-4">
|
||||
<Box className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">{t('common:common.noData')}</h3>
|
||||
<p className="text-gray-500 max-w-sm mx-auto">
|
||||
{searchTerm ? t('common:common.noData') : "Your dashboard is currently empty. Contact your administrator to assign modules."}
|
||||
</p>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="mt-4 text-blue-600 font-medium hover:underline"
|
||||
>
|
||||
{t('common:actions.reset')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CustomColumnFilter, CustomButton } from "../../components/custom";
|
||||
import { DataTable } from "../../components/custom/CustomTable";
|
||||
import type { ColumnDef } from "../../components/custom/CustomTable";
|
||||
import {
|
||||
buildColumnFilterOptions,
|
||||
resolveColumnSortState,
|
||||
} from "../../components/custom/CustomColumnFilter.utils";
|
||||
import type { ColumnSortDirection } from "../../components/custom/CustomColumnFilter";
|
||||
import { useDebounce } from "../../components/hooks/useDebounce";
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import { buildQueryString } from "../../lib/queryParams";
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
resolveStoredTablePageSize,
|
||||
} from "../../lib/tablePageSize";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
|
||||
interface AuditLog extends Record<string, unknown> {
|
||||
id: string;
|
||||
module_name: string;
|
||||
action_type: string;
|
||||
entity_name: string;
|
||||
performed_by_email: string;
|
||||
ip_address: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuditLogListResponse {
|
||||
items: AuditLog[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
const LogsPage = () => {
|
||||
const { t, i18n } = useTranslation(["logs", "common"]);
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [allLogsForCounts, setAllLogsForCounts] = useState<AuditLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(() =>
|
||||
resolveStoredTablePageSize({
|
||||
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
defaultPageSize: 10,
|
||||
})
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [moduleFilter, setModuleFilter] = useState<string[]>([]);
|
||||
const [actionFilter, setActionFilter] = useState<string[]>([]);
|
||||
const [emailFilter, setEmailFilter] = useState<string[]>([]);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: "module_name" | "action_type" | "performed_by_email" | "created_at" | null;
|
||||
direction: ColumnSortDirection;
|
||||
}>({ column: "created_at", direction: "desc" });
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
const latestLogsRequestRef = useRef(0);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
const requestId = ++latestLogsRequestRef.current;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const queryString = buildQueryString({
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
search: debouncedSearch || undefined,
|
||||
module_names: moduleFilter,
|
||||
action_types: actionFilter,
|
||||
performed_by_emails: emailFilter,
|
||||
sort_by: activeSort.column ?? undefined,
|
||||
sort_order: activeSort.direction ?? undefined,
|
||||
});
|
||||
|
||||
const response = await apiClient.get<AuditLogListResponse>(
|
||||
`/api/admin/audit-logs/${queryString}`,
|
||||
{ silent: true }
|
||||
);
|
||||
|
||||
if (requestId === latestLogsRequestRef.current) {
|
||||
setLogs(response.items);
|
||||
setTotalRows(response.total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(t("messages.loadError"), error);
|
||||
} finally {
|
||||
if (requestId === latestLogsRequestRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [actionFilter, activeSort, debouncedSearch, emailFilter, page, pageSize, moduleFilter, t]);
|
||||
|
||||
const fetchFilterOptions = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiClient.get<AuditLogListResponse>(
|
||||
"/api/admin/audit-logs/?limit=500&offset=0",
|
||||
{ silent: true }
|
||||
);
|
||||
setAllLogsForCounts(response.items);
|
||||
} catch (error) {
|
||||
console.error(t("messages.loadError"), error);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilterOptions();
|
||||
}, [fetchFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, moduleFilter, actionFilter, emailFilter, activeSort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim()) {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
prevLoadingRef.current = isLoading;
|
||||
}, [isLoading, search]);
|
||||
|
||||
const moduleCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allLogsForCounts.forEach((log) => {
|
||||
counts.set(log.module_name, (counts.get(log.module_name) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allLogsForCounts]);
|
||||
|
||||
const actionCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allLogsForCounts.forEach((log) => {
|
||||
counts.set(log.action_type, (counts.get(log.action_type) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allLogsForCounts]);
|
||||
|
||||
const emailCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allLogsForCounts.forEach((log) => {
|
||||
counts.set(log.performed_by_email, (counts.get(log.performed_by_email) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allLogsForCounts]);
|
||||
|
||||
const columns: ColumnDef<AuditLog>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "module_name",
|
||||
visibilityLabel: t("columns.module"),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t("columns.module")}
|
||||
<CustomColumnFilter
|
||||
title={t("columns.module")}
|
||||
options={buildColumnFilterOptions(moduleCounts.entries())}
|
||||
selectedValues={moduleFilter}
|
||||
sortDirection={activeSort.column === "module_name" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setModuleFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "module_name", direction));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
},
|
||||
{
|
||||
key: "action_type",
|
||||
visibilityLabel: t("columns.action"),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t("columns.action")}
|
||||
<CustomColumnFilter
|
||||
title={t("columns.action")}
|
||||
options={buildColumnFilterOptions(actionCounts.entries())}
|
||||
selectedValues={actionFilter}
|
||||
sortDirection={activeSort.column === "action_type" ? activeSort.direction : null}
|
||||
enableSearch={false}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setActionFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "action_type", direction));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-bold ${
|
||||
row.action_type === "CREATE"
|
||||
? "bg-green-100 text-green-700"
|
||||
: row.action_type === "UPDATE"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: row.action_type === "DELETE"
|
||||
? "bg-red-100 text-red-700"
|
||||
: "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{row.action_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "description", header: t("columns.description") },
|
||||
{
|
||||
key: "performed_by_email",
|
||||
visibilityLabel: t("columns.performedBy"),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t("columns.performedBy")}
|
||||
<CustomColumnFilter
|
||||
title={t("columns.performedBy")}
|
||||
options={buildColumnFilterOptions(emailCounts.entries())}
|
||||
selectedValues={emailFilter}
|
||||
sortDirection={activeSort.column === "performed_by_email" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setEmailFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "performed_by_email", direction));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
},
|
||||
{ key: "ip_address", header: t("columns.ipAddress") },
|
||||
{
|
||||
key: "created_at",
|
||||
visibilityLabel: t("columns.timestamp"),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t("columns.timestamp")}
|
||||
<CustomColumnFilter
|
||||
title={t("columns.timestamp")}
|
||||
options={[]}
|
||||
selectedValues={[]}
|
||||
sortDirection={activeSort.column === "created_at" ? activeSort.direction : null}
|
||||
enableSearch={false}
|
||||
enableSelectAll={false}
|
||||
onApply={(_, direction) => {
|
||||
setPage(1);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "created_at", direction));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) =>
|
||||
new Date(row.created_at).toLocaleString(
|
||||
i18n.language === "ar" ? "ar-EG" : "en-GB"
|
||||
),
|
||||
},
|
||||
],
|
||||
[actionCounts, actionFilter, activeSort, emailCounts, emailFilter, i18n.language, moduleCounts, moduleFilter, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Track and audit all system activities and user actions.</p>
|
||||
</div>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
onClick={fetchLogs}
|
||||
loading={isLoading}
|
||||
leftIcon={<RefreshCcw size={16} />}
|
||||
>
|
||||
{t("common:actions.refresh")}
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<DataTable<AuditLog>
|
||||
data={logs}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="logs-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
isLoading={isLoading}
|
||||
exportEnabled={false}
|
||||
exportFileName={t("exportFileName")}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
totalRows={totalRows}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsPage;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { apiClient } from '../../../lib/apiClient';
|
||||
import type {
|
||||
Module,
|
||||
ModuleCreate,
|
||||
ModuleUpdate,
|
||||
ModuleEnvironment,
|
||||
EnvironmentCreate,
|
||||
EnvironmentUpdate,
|
||||
ModulePermission,
|
||||
TenantModule,
|
||||
TenantModuleCreate,
|
||||
TenantModuleUpdate
|
||||
} from './AdminModuleTypes';
|
||||
|
||||
export const adminModuleApi = {
|
||||
listModules: (): Promise<Module[]> =>
|
||||
apiClient.get<Module[]>('/api/admin/modules/', { toast: false }),
|
||||
|
||||
createModule: (data: ModuleCreate): Promise<Module> =>
|
||||
apiClient.post<Module>('/api/admin/modules/', data, { toast: false }),
|
||||
|
||||
getModule: (moduleId: string): Promise<Module> =>
|
||||
apiClient.get<Module>(`/api/admin/modules/${moduleId}`, { toast: false }),
|
||||
|
||||
updateModule: (moduleId: string, data: ModuleUpdate): Promise<Module> =>
|
||||
apiClient.put<Module>(`/api/admin/modules/${moduleId}`, data, { toast: false }),
|
||||
|
||||
deleteModule: (moduleId: string): Promise<void> =>
|
||||
apiClient.delete<void>(`/api/admin/modules/${moduleId}`, { toast: false }),
|
||||
|
||||
listEnvironments: (moduleId: string): Promise<ModuleEnvironment[]> =>
|
||||
apiClient.get<ModuleEnvironment[]>(`/api/admin/modules/${moduleId}/environments`, { toast: false }),
|
||||
|
||||
createEnvironment: (moduleId: string, data: EnvironmentCreate): Promise<ModuleEnvironment> =>
|
||||
apiClient.post<ModuleEnvironment>(`/api/admin/modules/${moduleId}/environments`, data, { toast: false }),
|
||||
|
||||
updateEnvironment: (moduleId: string, envId: string, data: EnvironmentUpdate): Promise<ModuleEnvironment> =>
|
||||
apiClient.put<ModuleEnvironment>(`/api/admin/modules/${moduleId}/environments/${envId}`, data, { toast: false }),
|
||||
|
||||
setDefaultEnvironment: (moduleId: string, envId: string): Promise<void> =>
|
||||
apiClient.patch<void>(`/api/admin/modules/${moduleId}/environments/${envId}/default`, undefined, { toast: false }),
|
||||
|
||||
deleteEnvironment: (moduleId: string, envId: string): Promise<void> =>
|
||||
apiClient.delete<void>(`/api/admin/modules/${moduleId}/environments/${envId}`, { toast: false }),
|
||||
|
||||
getModulePermissions: (moduleId: string): Promise<ModulePermission[]> =>
|
||||
apiClient.get<ModulePermission[]>(`/api/admin/modules/${moduleId}/permissions`, { toast: false }),
|
||||
|
||||
syncModulePermissions: (moduleId: string): Promise<{ message: string; synced_count: number }> =>
|
||||
apiClient.post<{ message: string; synced_count: number }>(`/api/admin/modules/${moduleId}/permissions/sync`, undefined, { toast: false }),
|
||||
|
||||
listTenantModules: (tenantId: string): Promise<TenantModule[]> =>
|
||||
apiClient.get<TenantModule[]>(`/api/admin/tenants/${tenantId}/modules`, { toast: false }),
|
||||
|
||||
assignModuleToTenant: (tenantId: string, data: TenantModuleCreate): Promise<TenantModule> =>
|
||||
apiClient.post<TenantModule>(`/api/admin/tenants/${tenantId}/modules`, data, { toast: false }),
|
||||
|
||||
updateTenantModule: (tenantId: string, tenantModuleId: string, data: TenantModuleUpdate): Promise<TenantModule> =>
|
||||
apiClient.put<TenantModule>(`/api/admin/tenants/${tenantId}/modules/${tenantModuleId}`, data, { toast: false }),
|
||||
|
||||
removeTenantModule: (tenantId: string, tenantModuleId: string): Promise<void> =>
|
||||
apiClient.delete<void>(`/api/admin/tenants/${tenantId}/modules/${tenantModuleId}`, { toast: false }),
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
export type ModuleStatus = 'active' | 'inactive';
|
||||
export type EnvironmentTrustType = 'internal' | 'full' | 'none';
|
||||
|
||||
export interface Module {
|
||||
id: string;
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
description?: string;
|
||||
icon_url?: string;
|
||||
status: ModuleStatus;
|
||||
display_order: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ModuleCreate {
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
description?: string;
|
||||
icon_url?: string;
|
||||
status?: ModuleStatus;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface ModuleUpdate {
|
||||
module_name?: string;
|
||||
description?: string;
|
||||
icon_url?: string;
|
||||
status?: ModuleStatus;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface ModuleEnvironment {
|
||||
id: string;
|
||||
module_id: string;
|
||||
slug: string;
|
||||
frontend_base_url: string;
|
||||
backend_base_url: string;
|
||||
sso_entry_path: string;
|
||||
permission_sync_endpoint: string;
|
||||
provisioning_endpoint: string;
|
||||
trust_type: EnvironmentTrustType;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentCreate {
|
||||
slug: string;
|
||||
frontend_base_url: string;
|
||||
backend_base_url: string;
|
||||
sso_entry_path?: string;
|
||||
permission_sync_endpoint?: string;
|
||||
provisioning_endpoint?: string;
|
||||
trust_type?: EnvironmentTrustType;
|
||||
trust_credentials?: Record<string, any>;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface EnvironmentUpdate {
|
||||
slug?: string;
|
||||
frontend_base_url?: string;
|
||||
backend_base_url?: string;
|
||||
sso_entry_path?: string;
|
||||
permission_sync_endpoint?: string;
|
||||
provisioning_endpoint?: string;
|
||||
trust_type?: EnvironmentTrustType;
|
||||
trust_credentials?: Record<string, any>;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ModulePermission {
|
||||
id: string;
|
||||
access_code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
parent_id?: string;
|
||||
scope: string;
|
||||
module_id: string;
|
||||
}
|
||||
|
||||
export interface TenantModule {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
module_icon_url?: string;
|
||||
assigned_environment_slug: string;
|
||||
is_active: boolean;
|
||||
module_config?: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TenantModuleCreate {
|
||||
module_id: string;
|
||||
assigned_environment_slug?: string;
|
||||
is_active?: boolean;
|
||||
module_config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface TenantModuleUpdate {
|
||||
assigned_environment_slug?: string;
|
||||
is_active?: boolean;
|
||||
module_config?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import type { ModuleEnvironment, EnvironmentCreate, EnvironmentUpdate } from '../AdminModuleTypes';
|
||||
import CustomModal from '../../../../components/custom/CustomModal';
|
||||
|
||||
interface EnvironmentFormProps {
|
||||
moduleId: string;
|
||||
environment: ModuleEnvironment | null;
|
||||
onClose: (saved: boolean) => void;
|
||||
}
|
||||
|
||||
const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProps) => {
|
||||
const [formData, setFormData] = useState({
|
||||
slug: '',
|
||||
frontend_base_url: '',
|
||||
backend_base_url: '',
|
||||
sso_entry_path: '/sso/callback',
|
||||
permission_sync_endpoint: '/internal/permissions',
|
||||
provisioning_endpoint: '/internal/tenants/provision',
|
||||
trust_type: 'hmac',
|
||||
hmac_secret: '',
|
||||
is_default: false,
|
||||
is_active: true,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (environment) {
|
||||
setFormData({
|
||||
slug: environment.slug,
|
||||
frontend_base_url: environment.frontend_base_url,
|
||||
backend_base_url: environment.backend_base_url,
|
||||
sso_entry_path: environment.sso_entry_path,
|
||||
permission_sync_endpoint: environment.permission_sync_endpoint,
|
||||
provisioning_endpoint: environment.provisioning_endpoint,
|
||||
trust_type: environment.trust_type,
|
||||
hmac_secret: '',
|
||||
is_default: environment.is_default,
|
||||
is_active: environment.is_active,
|
||||
});
|
||||
}
|
||||
}, [environment]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const trust_credentials = formData.trust_type === 'hmac'
|
||||
? { hmac_secret: formData.hmac_secret }
|
||||
: { secret_key: formData.hmac_secret };
|
||||
|
||||
try {
|
||||
if (environment) {
|
||||
const updateData: EnvironmentUpdate = {
|
||||
slug: formData.slug,
|
||||
frontend_base_url: formData.frontend_base_url,
|
||||
backend_base_url: formData.backend_base_url,
|
||||
sso_entry_path: formData.sso_entry_path,
|
||||
permission_sync_endpoint: formData.permission_sync_endpoint,
|
||||
provisioning_endpoint: formData.provisioning_endpoint,
|
||||
trust_type: formData.trust_type as any,
|
||||
...(formData.hmac_secret && { trust_credentials }),
|
||||
is_default: formData.is_default,
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
await adminModuleApi.updateEnvironment(moduleId, environment.id, updateData);
|
||||
} else {
|
||||
const createData: EnvironmentCreate = {
|
||||
slug: formData.slug,
|
||||
frontend_base_url: formData.frontend_base_url,
|
||||
backend_base_url: formData.backend_base_url,
|
||||
sso_entry_path: formData.sso_entry_path,
|
||||
permission_sync_endpoint: formData.permission_sync_endpoint,
|
||||
provisioning_endpoint: formData.provisioning_endpoint,
|
||||
trust_type: formData.trust_type as any,
|
||||
trust_credentials,
|
||||
is_default: formData.is_default,
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
await adminModuleApi.createEnvironment(moduleId, createData);
|
||||
}
|
||||
onClose(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to save environment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomModal
|
||||
isOpen={true}
|
||||
onClose={() => onClose(false)}
|
||||
title={environment ? "Edit Environment" : "Create Environment"}
|
||||
size="lg"
|
||||
footer={
|
||||
<div className="flex gap-3 w-full">
|
||||
<button
|
||||
type="submit"
|
||||
form="env-form"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "Saving..." : environment ? "Update" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose(false)}
|
||||
className="flex-1 px-4 py-2 bg-[#334155] text-white rounded-lg hover:bg-[#1e293b] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form id="env-form" onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
|
||||
Environment Slug *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="prod, staging, eu-prod"
|
||||
required
|
||||
disabled={!!environment}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
|
||||
Trust Type
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value="HMAC-SHA256"
|
||||
disabled
|
||||
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-secondary) rounded-lg opacity-60 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
|
||||
Frontend Base URL *
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.frontend_base_url}
|
||||
onChange={(e) => setFormData({ ...formData, frontend_base_url: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://module.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
|
||||
Backend Base URL *
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.backend_base_url}
|
||||
onChange={(e) => setFormData({ ...formData, backend_base_url: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://api.module.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
|
||||
SSO Entry Path *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sso_entry_path}
|
||||
onChange={(e) => setFormData({ ...formData, sso_entry_path: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
|
||||
Permission Sync Endpoint *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.permission_sync_endpoint}
|
||||
onChange={(e) => setFormData({ ...formData, permission_sync_endpoint: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
|
||||
Provisioning Endpoint *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.provisioning_endpoint}
|
||||
onChange={(e) => setFormData({ ...formData, provisioning_endpoint: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-(--text-primary)">
|
||||
HMAC Secret {!environment && "*"}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.hmac_secret}
|
||||
onChange={(e) => setFormData({ ...formData, hmac_secret: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-(--background) border border-(--card-border) text-(--text-primary) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={environment ? "Leave blank to keep existing" : "Enter secret"}
|
||||
required={!environment}
|
||||
/>
|
||||
<p className="text-xs text-(--text-secondary) mt-1">
|
||||
Stored securely on backend
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_default}
|
||||
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||
className="rounded border-(--card-border) bg-(--background)"
|
||||
/>
|
||||
<span className="text-sm text-(--text-primary)">Set as default</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded border-(--card-border) bg-(--background)"
|
||||
/>
|
||||
<span className="text-sm text-(--text-primary)">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentForm;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Plus, Edit, Trash2, Check, X } from 'lucide-react';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import type { ModuleEnvironment, Module } from '../AdminModuleTypes';
|
||||
import Loader from '../../../../components/custom/CustomLoader';
|
||||
import EnvironmentForm from './EnvironmentForm';
|
||||
import { CustomConfirmationModal } from '../../../../components/custom';
|
||||
|
||||
const ModuleEnvironments = () => {
|
||||
const { moduleId } = useParams<{ moduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [module, setModule] = useState<Module | null>(null);
|
||||
const [environments, setEnvironments] = useState<ModuleEnvironment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedEnv, setSelectedEnv] = useState<ModuleEnvironment | null>(null);
|
||||
const [envToDelete, setEnvToDelete] = useState<ModuleEnvironment | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!moduleId) return;
|
||||
|
||||
try {
|
||||
const [moduleData, envsData] = await Promise.all([
|
||||
adminModuleApi.getModule(moduleId),
|
||||
adminModuleApi.listEnvironments(moduleId)
|
||||
]);
|
||||
setModule(moduleData);
|
||||
setEnvironments(envsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [moduleId]);
|
||||
|
||||
const handleSetDefault = async (envId: string) => {
|
||||
if (!moduleId) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.setDefaultEnvironment(moduleId, envId);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to set default', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (env: ModuleEnvironment) => {
|
||||
setEnvToDelete(env);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!moduleId || !envToDelete) return;
|
||||
try {
|
||||
await adminModuleApi.deleteEnvironment(moduleId, envToDelete.id);
|
||||
setEnvToDelete(null);
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete environment', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/modules')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">
|
||||
{module?.module_name || 'Loading...'} - Environments
|
||||
</h1>
|
||||
<p className="text-sm text-(--text-secondary)">Configure environment-specific settings</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={() => { setSelectedEnv(null); setShowForm(true); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add Environment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-12 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<Loader size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{environments.map((env) => (
|
||||
<div key={env.id} className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">{env.slug}</h3>
|
||||
{env.is_default && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{env.is_active ? (
|
||||
<Check className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Frontend:</span>
|
||||
<p className="text-(--text-primary) font-mono text-xs break-all">{env.frontend_base_url}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Backend:</span>
|
||||
<p className="text-(--text-primary) font-mono text-xs break-all">{env.backend_base_url}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-gray-500">Trust:</span>
|
||||
<p className="text-(--text-primary)">{env.trust_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">SSO Path:</span>
|
||||
<p className="text-(--text-primary) font-mono text-xs">{env.sso_entry_path}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
{!env.is_default && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(env.id)}
|
||||
className="flex-1 px-3 py-2 bg-green-50 text-green-600 rounded-lg hover:bg-green-100 text-sm"
|
||||
>
|
||||
Set Default
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setSelectedEnv(env); setShowForm(true); }}
|
||||
className="flex-1 px-3 py-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit size={16} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(env)}
|
||||
className="flex-1 px-3 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{environments.length === 0 && !loading && (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
|
||||
<p className="text-(--text-secondary) mb-4">No environments configured</p>
|
||||
<button
|
||||
onClick={() => { setSelectedEnv(null); setShowForm(true); }}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Add First Environment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!envToDelete}
|
||||
onClose={() => setEnvToDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Environment"
|
||||
description={`Are you sure you want to delete ${envToDelete?.slug} environment?`}
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{showForm && moduleId && (
|
||||
<EnvironmentForm
|
||||
moduleId={moduleId}
|
||||
environment={selectedEnv}
|
||||
onClose={(saved) => {
|
||||
setShowForm(false);
|
||||
setSelectedEnv(null);
|
||||
if (saved) fetchData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleEnvironments;
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { X } from 'lucide-react';
|
||||
import { useModuleApi } from '../hooks/useModuleApi';
|
||||
import type { Module, ModuleCreate, ModuleUpdate, ModuleStatus } from '../AdminModuleTypes';
|
||||
import CustomInput from '../../../../components/custom/CustomInput';
|
||||
import CustomButton from '../../../../components/custom/CustomButton';
|
||||
|
||||
interface ModuleFormProps {
|
||||
module: Module | null;
|
||||
onClose: (saved: boolean) => void;
|
||||
}
|
||||
|
||||
interface ModuleFormData {
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
description: string;
|
||||
icon_url: string;
|
||||
status: ModuleStatus;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
const ModuleForm = ({ module, onClose }: ModuleFormProps) => {
|
||||
const { createModule, updateModule, loading } = useModuleApi();
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, reset } = useForm<ModuleFormData>({
|
||||
defaultValues: {
|
||||
module_id: '',
|
||||
module_name: '',
|
||||
description: '',
|
||||
icon_url: '',
|
||||
status: 'active',
|
||||
display_order: 0,
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (module) {
|
||||
reset({
|
||||
module_id: module.module_id,
|
||||
module_name: module.module_name,
|
||||
description: module.description || '',
|
||||
icon_url: module.icon_url || '',
|
||||
status: module.status,
|
||||
display_order: module.display_order,
|
||||
});
|
||||
}
|
||||
}, [module, reset]);
|
||||
|
||||
const onSubmit = async (data: ModuleFormData) => {
|
||||
let success;
|
||||
if (module) {
|
||||
const updateData: ModuleUpdate = {
|
||||
module_name: data.module_name,
|
||||
description: data.description || undefined,
|
||||
icon_url: data.icon_url || undefined,
|
||||
status: data.status,
|
||||
display_order: data.display_order,
|
||||
};
|
||||
success = await updateModule(module.id, updateData);
|
||||
} else {
|
||||
const createData: ModuleCreate = {
|
||||
module_id: data.module_id,
|
||||
module_name: data.module_name,
|
||||
description: data.description || undefined,
|
||||
icon_url: data.icon_url || undefined,
|
||||
status: data.status,
|
||||
display_order: data.display_order,
|
||||
};
|
||||
success = await createModule(createData);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
onClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-(--card-bg) rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-(--card-border)">
|
||||
<div className="sticky top-0 bg-(--card-bg) border-b border-(--card-border) px-6 py-4 flex items-center justify-between z-10">
|
||||
<h2 className="text-xl font-semibold text-(--text-primary)">
|
||||
{module ? 'Edit Module' : 'Create Module'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => onClose(false)}
|
||||
className="p-2 hover:bg-(--background) rounded-lg transition-colors text-(--text-secondary) hover:text-(--text-primary)"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-4">
|
||||
<CustomInput
|
||||
label="Module ID *"
|
||||
placeholder="e.g., inventory"
|
||||
{...register('module_id', {
|
||||
required: 'Module ID is required',
|
||||
pattern: {
|
||||
value: /^[a-z0-9-]+$/,
|
||||
message: 'Only lowercase alphanumeric and hyphens allowed'
|
||||
}
|
||||
})}
|
||||
error={errors.module_id?.message}
|
||||
disabled={!!module}
|
||||
/>
|
||||
<p className="text-xs text-(--text-secondary) -mt-3 ml-1">Unique identifier (lowercase, alphanumeric, hyphens)</p>
|
||||
|
||||
<CustomInput
|
||||
label="Module Name *"
|
||||
placeholder="e.g., Inventory Management"
|
||||
{...register('module_name', { required: 'Module Name is required' })}
|
||||
error={errors.module_name?.message}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-(--text-primary)">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
className="w-full px-3 py-2 border border-(--card-border) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-(--background) text-(--text-primary) placeholder-(--text-secondary)/50"
|
||||
placeholder="Brief description of the module"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Icon URL"
|
||||
placeholder="https://example.com/icon.png"
|
||||
type="url"
|
||||
{...register('icon_url')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-(--text-primary)">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
{...register('status')}
|
||||
className="w-full px-3 py-2 border border-(--card-border) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-(--background) text-(--text-primary)"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Display Order"
|
||||
type="number"
|
||||
{...register('display_order', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4">
|
||||
<CustomButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{module ? 'Update Module' : 'Create Module'}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onClose(false)}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleForm;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Edit, Trash2, Settings, Shield, Eye, EyeOff } from 'lucide-react';
|
||||
import { useModuleApi } from '../hooks/useModuleApi';
|
||||
import type { Module } from '../AdminModuleTypes';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import ModuleForm from './ModuleForm';
|
||||
import CustomConfirmationModal from '../../../../components/custom/CustomConfirmationModal';
|
||||
import CustomButton from '../../../../components/custom/CustomButton';
|
||||
|
||||
const ModuleList = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['modules', 'common']);
|
||||
const { listModules, deleteModule, loading } = useModuleApi();
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedModule, setSelectedModule] = useState<Module | null>(null);
|
||||
const [moduleToDelete, setModuleToDelete] = useState<Module | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
const fetchModules = async () => {
|
||||
const data = await listModules();
|
||||
if (data) {
|
||||
setModules(data);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchModules();
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedModule(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (module: Module) => {
|
||||
setSelectedModule(module);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const confirmDelete = (module: Module) => {
|
||||
setModuleToDelete(module);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!moduleToDelete) return;
|
||||
const success = await deleteModule(moduleToDelete.id);
|
||||
if (success) {
|
||||
fetchModules();
|
||||
}
|
||||
setModuleToDelete(null);
|
||||
};
|
||||
|
||||
const handleFormClose = (saved: boolean) => {
|
||||
setShowForm(false);
|
||||
setSelectedModule(null);
|
||||
if (saved) {
|
||||
fetchModules();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && modules.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-300 rounded w-1/4"></div>
|
||||
<div className="h-64 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">{t('registry.title')}</h1>
|
||||
<p className="text-sm text-(--text-secondary)">{t('registry.subtitle')}</p>
|
||||
</div>
|
||||
<CustomButton
|
||||
onClick={handleCreate}
|
||||
leftIcon={<Plus size={20} />}
|
||||
>
|
||||
{t('registry.create_button')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{modules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{module.icon_url ? (
|
||||
<img src={module.icon_url} alt={module.module_name} className="w-12 h-12 rounded-lg" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Settings className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-(--text-primary)">{module.module_name}</h3>
|
||||
<p className="text-sm text-(--text-secondary)">{module.module_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1 transition-opacity ${togglingId === module.id
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:text-blue-600'
|
||||
}`}
|
||||
title={module.status === 'active' ? 'Deactivate' : 'Activate'}
|
||||
onClick={async () => {
|
||||
if (togglingId) return;
|
||||
try {
|
||||
setTogglingId(module.id);
|
||||
const updated = await adminModuleApi.updateModule(module.id, {
|
||||
status: module.status === 'active' ? 'inactive' : 'active'
|
||||
});
|
||||
if (updated) {
|
||||
setModules(prev => prev.map(m =>
|
||||
m.id === module.id ? { ...m, status: updated.status } : m
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Toggle failed:", error);
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{module.status === 'active' ? (
|
||||
<Eye className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<EyeOff className="w-5 h-5 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{module.description && (
|
||||
<p className="text-sm text-(--text-secondary) line-clamp-2">{module.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-(--card-border)">
|
||||
<button
|
||||
onClick={() => navigate(`/admin/modules/${module.id}/environments`)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm text-(--text-primary)"
|
||||
>
|
||||
<Settings size={16} />
|
||||
{t('registry.card.environments')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/modules/${module.id}/permissions`)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm text-(--text-primary)"
|
||||
>
|
||||
<Shield size={16} />
|
||||
{t('registry.card.permissions')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(module)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
<Edit size={16} />
|
||||
{t('common:actions.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirmDelete(module)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('common:actions.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{modules.length === 0 && !loading && (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
|
||||
<Settings className="w-16 h-16 text-(--text-secondary) mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-(--text-primary) mb-2">{t('registry.empty_state.title')}</h3>
|
||||
<p className="text-(--text-secondary) mb-4">{t('registry.empty_state.subtitle')}</p>
|
||||
<CustomButton onClick={handleCreate}>
|
||||
{t('registry.create_button')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ModuleForm
|
||||
module={selectedModule}
|
||||
onClose={handleFormClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!moduleToDelete}
|
||||
onClose={() => setModuleToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title={t('delete_modal.title')}
|
||||
description={t('delete_modal.description', { name: moduleToDelete?.module_name })}
|
||||
confirmText={t('delete_modal.confirm')}
|
||||
cancelText={t('delete_modal.cancel')}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleList;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, RefreshCw, Shield } from 'lucide-react';
|
||||
import { useModuleApi } from '../hooks/useModuleApi';
|
||||
import Loader from '../../../../components/custom/CustomLoader';
|
||||
import type { ModulePermission, Module } from '../AdminModuleTypes';
|
||||
import CustomButton from '../../../../components/custom/CustomButton';
|
||||
import { NodeGroupAccessViewer } from '../../../roles/components/NodeGroupAccessViewer';
|
||||
import type { RoleAccess } from '../../../roles/RolesTypes';
|
||||
|
||||
const ModulePermissions = () => {
|
||||
const { moduleId } = useParams<{ moduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { getModule, getPermissions, syncPermissions, loading } = useModuleApi();
|
||||
|
||||
const [module, setModule] = useState<Module | null>(null);
|
||||
const [permissions, setPermissions] = useState<ModulePermission[]>([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!moduleId) return;
|
||||
|
||||
const [moduleData, permsData] = await Promise.all([
|
||||
getModule(moduleId),
|
||||
getPermissions(moduleId)
|
||||
]);
|
||||
|
||||
if (moduleData) setModule(moduleData);
|
||||
if (permsData) setPermissions(permsData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [moduleId]);
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!moduleId) return;
|
||||
|
||||
setSyncing(true);
|
||||
const result = await syncPermissions(moduleId);
|
||||
if (result) {
|
||||
fetchData();
|
||||
}
|
||||
setSyncing(false);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const viewerAccesses: RoleAccess[] = permissions.map(p => ({
|
||||
id: p.id,
|
||||
access_code: p.access_code,
|
||||
name: p.name,
|
||||
category: p.category || 'General',
|
||||
parent_id: p.parent_id
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/modules')}
|
||||
className="p-2 hover:bg-(--background) rounded-lg transition-colors text-(--text-secondary) hover:text-(--text-primary)"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">
|
||||
{module?.module_name || 'Loading...'} - Permissions
|
||||
</h1>
|
||||
<p className="text-sm text-(--text-secondary)">View and sync permissions from module</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<CustomButton
|
||||
onClick={handleSync}
|
||||
loading={syncing}
|
||||
leftIcon={!syncing && <RefreshCw size={20} />}
|
||||
>
|
||||
{syncing ? 'Syncing...' : 'Sync Permissions'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className=" p-12 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<Loader size="md" />
|
||||
</div>
|
||||
) : permissions.length > 0 ? (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6">
|
||||
<NodeGroupAccessViewer accesses={viewerAccesses} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
|
||||
<Shield className="w-16 h-16 text-(--text-secondary) mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-(--text-primary) mb-2">No Permissions Synced</h3>
|
||||
<p className="text-(--text-secondary) mb-4">Click "Sync Permissions" to fetch from the module</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModulePermissions;
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import type { TenantModule, Module } from '../AdminModuleTypes';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import Loader from '../../../../components/custom/CustomLoader';
|
||||
|
||||
const TenantModuleAssignment = () => {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const [assignedModules, setAssignedModules] = useState<TenantModule[]>([]);
|
||||
const [availableModules, setAvailableModules] = useState<Module[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAssignDialog, setShowAssignDialog] = useState(false);
|
||||
const [selectedModule, setSelectedModule] = useState('');
|
||||
const [selectedEnv, setSelectedEnv] = useState('prod');
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
const [assigned, all] = await Promise.all([
|
||||
adminModuleApi.listTenantModules(tenantId),
|
||||
adminModuleApi.listModules()
|
||||
]);
|
||||
setAssignedModules(assigned);
|
||||
const assignedIds = assigned.map(a => a.module_id);
|
||||
setAvailableModules(all.filter(m => !assignedIds.includes(m.id)));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [tenantId]);
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!tenantId || !selectedModule) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.assignModuleToTenant(tenantId, {
|
||||
module_id: selectedModule,
|
||||
assigned_environment_slug: selectedEnv,
|
||||
is_active: true
|
||||
});
|
||||
setShowAssignDialog(false);
|
||||
setSelectedModule('');
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to assign module');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (tm: TenantModule) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.updateTenantModule(tenantId, tm.id, { is_active: !tm.is_active });
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeEnv = async (tm: TenantModule, newSlug: string) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.updateTenantModule(tenantId, tm.id, { assigned_environment_slug: newSlug });
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to update', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (tm: TenantModule) => {
|
||||
if (!tenantId || !confirm(`Remove ${tm.module_name}?`)) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.removeTenantModule(tenantId, tm.id);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">Tenant Modules</h1>
|
||||
<p className="text-sm text-(--text-secondary)">Manage module assignments for this tenant</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAssignDialog(true)}
|
||||
disabled={availableModules.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Assign Module
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<Loader size="lg" />
|
||||
<p className="mt-4 text-(--text-secondary)">Loading assignments...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{assignedModules.map((tm) => (
|
||||
<div key={tm.id} className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{tm.module_icon_url && (
|
||||
<img src={tm.module_icon_url} alt={tm.module_name} className="w-10 h-10 rounded" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-(--text-primary)">{tm.module_name}</h3>
|
||||
<p className="text-xs text-(--text-secondary)">Environment: {tm.assigned_environment_slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={tm.assigned_environment_slug}
|
||||
onChange={(e) => handleChangeEnv(tm, e.target.value)}
|
||||
className="flex-1 px-3 py-2 border rounded-lg text-sm"
|
||||
>
|
||||
<option value="prod">Production</option>
|
||||
<option value="staging">Staging</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleToggleActive(tm)}
|
||||
className={`px-3 py-2 rounded-lg text-sm ${tm.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
{tm.is_active ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(tm)}
|
||||
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAssignDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-semibold mb-4">Assign Module</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Module</label>
|
||||
<select
|
||||
value={selectedModule}
|
||||
onChange={(e) => setSelectedModule(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="">Select Module</option>
|
||||
{availableModules.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.module_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Environment</label>
|
||||
<select
|
||||
value={selectedEnv}
|
||||
onChange={(e) => setSelectedEnv(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="prod">Production</option>
|
||||
<option value="staging">Staging</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={!selectedModule}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAssignDialog(false)}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantModuleAssignment;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import type {
|
||||
Module,
|
||||
ModuleCreate,
|
||||
ModuleUpdate,
|
||||
ModuleEnvironment,
|
||||
EnvironmentCreate,
|
||||
EnvironmentUpdate,
|
||||
ModulePermission,
|
||||
TenantModule,
|
||||
TenantModuleCreate,
|
||||
TenantModuleUpdate
|
||||
} from '../AdminModuleTypes';
|
||||
|
||||
interface UseModuleApiResult {
|
||||
loading: boolean;
|
||||
listModules: () => Promise<Module[] | undefined>;
|
||||
getModule: (id: string) => Promise<Module | undefined>;
|
||||
createModule: (data: ModuleCreate) => Promise<Module | undefined>;
|
||||
updateModule: (id: string, data: ModuleUpdate) => Promise<Module | undefined>;
|
||||
deleteModule: (id: string) => Promise<boolean>;
|
||||
|
||||
listEnvironments: (moduleId: string) => Promise<ModuleEnvironment[] | undefined>;
|
||||
createEnvironment: (moduleId: string, data: EnvironmentCreate) => Promise<ModuleEnvironment | undefined>;
|
||||
updateEnvironment: (moduleId: string, envId: string, data: EnvironmentUpdate) => Promise<ModuleEnvironment | undefined>;
|
||||
setDefaultEnvironment: (moduleId: string, envId: string) => Promise<boolean>;
|
||||
deleteEnvironment: (moduleId: string, envId: string) => Promise<boolean>;
|
||||
|
||||
getPermissions: (moduleId: string) => Promise<ModulePermission[] | undefined>;
|
||||
syncPermissions: (moduleId: string) => Promise<{ message: string; synced_count: number } | undefined>;
|
||||
|
||||
listTenantModules: (tenantId: string) => Promise<TenantModule[] | undefined>;
|
||||
assignTenantModule: (tenantId: string, data: TenantModuleCreate) => Promise<TenantModule | undefined>;
|
||||
updateTenantModule: (tenantId: string, assignmentId: string, data: TenantModuleUpdate) => Promise<TenantModule | undefined>;
|
||||
removeTenantModule: (tenantId: string, assignmentId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useModuleApi = (): UseModuleApiResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleError = (error: unknown, action: string) => {
|
||||
console.error(`Failed to ${action}`, error);
|
||||
const message = error instanceof Error ? error.message : `Failed to ${action}`;
|
||||
toast.error(message);
|
||||
};
|
||||
|
||||
const wrapRequest = useCallback(async <T>(
|
||||
request: () => Promise<T>,
|
||||
actionName: string,
|
||||
successMessage?: string
|
||||
): Promise<T | undefined> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await request();
|
||||
if (successMessage) {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
handleError(error, actionName);
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const listModules = useCallback(() =>
|
||||
wrapRequest(() => adminModuleApi.listModules(), 'fetch modules'),
|
||||
[]);
|
||||
|
||||
const getModule = useCallback((id: string) =>
|
||||
wrapRequest(() => adminModuleApi.getModule(id), 'fetch module details'),
|
||||
[]);
|
||||
|
||||
const createModule = useCallback((data: ModuleCreate) =>
|
||||
wrapRequest(() => adminModuleApi.createModule(data), 'create module', 'Module created successfully'),
|
||||
[]);
|
||||
|
||||
const updateModule = useCallback((id: string, data: ModuleUpdate) =>
|
||||
wrapRequest(() => adminModuleApi.updateModule(id, data), 'update module'),
|
||||
[]);
|
||||
|
||||
const deleteModule = useCallback(async (id: string) => {
|
||||
const result = await wrapRequest(() => adminModuleApi.deleteModule(id), 'delete module', 'Module deleted successfully');
|
||||
return result !== undefined;
|
||||
}, []);
|
||||
|
||||
const listEnvironments = useCallback((moduleId: string) =>
|
||||
wrapRequest(() => adminModuleApi.listEnvironments(moduleId), 'fetch environments'),
|
||||
[]);
|
||||
|
||||
const createEnvironment = useCallback((moduleId: string, data: EnvironmentCreate) =>
|
||||
wrapRequest(() => adminModuleApi.createEnvironment(moduleId, data), 'create environment', 'Environment created'),
|
||||
[]);
|
||||
|
||||
const updateEnvironment = useCallback((moduleId: string, envId: string, data: EnvironmentUpdate) =>
|
||||
wrapRequest(() => adminModuleApi.updateEnvironment(moduleId, envId, data), 'update environment', 'Environment updated'),
|
||||
[]);
|
||||
|
||||
const setDefaultEnvironment = useCallback(async (moduleId: string, envId: string) => {
|
||||
const result = await wrapRequest(() => adminModuleApi.setDefaultEnvironment(moduleId, envId), 'set default environment', 'Default environment updated');
|
||||
return result !== undefined;
|
||||
}, []);
|
||||
|
||||
const deleteEnvironment = useCallback(async (moduleId: string, envId: string) => {
|
||||
const result = await wrapRequest(() => adminModuleApi.deleteEnvironment(moduleId, envId), 'delete environment', 'Environment deleted');
|
||||
return result !== undefined;
|
||||
}, []);
|
||||
|
||||
const getPermissions = useCallback((moduleId: string) =>
|
||||
wrapRequest(() => adminModuleApi.getModulePermissions(moduleId), 'fetch permissions'),
|
||||
[]);
|
||||
|
||||
const syncPermissions = useCallback((moduleId: string) =>
|
||||
wrapRequest(() => adminModuleApi.syncModulePermissions(moduleId), 'sync permissions'),
|
||||
[]);
|
||||
|
||||
const listTenantModules = useCallback((tenantId: string) =>
|
||||
wrapRequest(() => adminModuleApi.listTenantModules(tenantId), 'fetch tenant modules'),
|
||||
[]);
|
||||
|
||||
const assignTenantModule = useCallback((tenantId: string, data: TenantModuleCreate) =>
|
||||
wrapRequest(() => adminModuleApi.assignModuleToTenant(tenantId, data), 'assign module', 'Module assigned to tenant'),
|
||||
[]);
|
||||
|
||||
const updateTenantModule = useCallback((tenantId: string, assignmentId: string, data: TenantModuleUpdate) =>
|
||||
wrapRequest(() => adminModuleApi.updateTenantModule(tenantId, assignmentId, data), 'update assignment', 'Assignment updated'),
|
||||
[]);
|
||||
|
||||
const removeTenantModule = useCallback(async (tenantId: string, assignmentId: string) => {
|
||||
const result = await wrapRequest(() => adminModuleApi.removeTenantModule(tenantId, assignmentId), 'remove assignment', 'Module unassigned from tenant');
|
||||
return result !== undefined;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
listModules,
|
||||
getModule,
|
||||
createModule,
|
||||
updateModule,
|
||||
deleteModule,
|
||||
listEnvironments,
|
||||
createEnvironment,
|
||||
updateEnvironment,
|
||||
setDefaultEnvironment,
|
||||
deleteEnvironment,
|
||||
getPermissions,
|
||||
syncPermissions,
|
||||
listTenantModules,
|
||||
assignTenantModule,
|
||||
updateTenantModule,
|
||||
removeTenantModule
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import ModuleList from "./components/ModuleList";
|
||||
import ModuleEnvironments from "./components/ModuleEnvironments";
|
||||
import ModulePermissions from "./components/ModulePermissions";
|
||||
|
||||
const Modules: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<ModuleList />} />
|
||||
<Route path=":moduleId/environments" element={<ModuleEnvironments />} />
|
||||
<Route path=":moduleId/permissions" element={<ModulePermissions />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modules;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiClient } from "../../../lib/apiClient";
|
||||
|
||||
export interface Module {
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
description: string | null;
|
||||
icon_url: string | null;
|
||||
display_order: number;
|
||||
category: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface LaunchResponse {
|
||||
target_url: string;
|
||||
payload: Record<string, any>;
|
||||
headers: Record<string, string>;
|
||||
redirect_url: string;
|
||||
}
|
||||
|
||||
export const moduleApi = {
|
||||
getAvailableModules: (): Promise<Module[]> =>
|
||||
apiClient.get<Module[]>("/api/modules/available", { toast: false }),
|
||||
|
||||
launchModule: (moduleId: string): Promise<LaunchResponse> =>
|
||||
apiClient.post<LaunchResponse>("/api/sso/initiate", { module_id: moduleId }, { toast: false }),
|
||||
};
|
||||
@@ -129,8 +129,8 @@ const ProfilePage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
setPasswordError(t('messages.passwordMismatch'));
|
||||
if (passwordForm.newPassword === passwordForm.confirmPassword) {
|
||||
setPasswordError("New password must be different from your current password");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -239,8 +239,8 @@ const ProfilePage: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{t('title')}</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('subTitle')}
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import { buildQueryString } from "../../lib/queryParams";
|
||||
import type {
|
||||
Role,
|
||||
RoleCreateRequest,
|
||||
@@ -9,17 +10,6 @@ import type {
|
||||
|
||||
type RoleWithAccesses = Role & { accesses: RoleAccess[] };
|
||||
|
||||
const buildQueryString = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const query = searchParams.toString();
|
||||
return query ? `?${query}` : "";
|
||||
};
|
||||
|
||||
export const rolesApi = {
|
||||
getAll: () => apiClient.get<Role[]>("/api/role/get"),
|
||||
|
||||
@@ -41,11 +31,19 @@ export const rolesApi = {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
filter_role_names?: string[];
|
||||
filter_tenant_ids?: string[];
|
||||
sort_by?: "name" | "tenant";
|
||||
sort_order?: "asc" | "desc";
|
||||
}) => {
|
||||
const queryString = buildQueryString({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
search: params.search,
|
||||
filter_role_names: params.filter_role_names ?? undefined,
|
||||
filter_tenant_ids: params.filter_tenant_ids ?? undefined,
|
||||
sort_by: params.sort_by ?? undefined,
|
||||
sort_order: params.sort_order ?? undefined,
|
||||
});
|
||||
return apiClient.get<RolePaginatedResponse>(`/api/role/list${queryString}`);
|
||||
},
|
||||
|
||||
@@ -14,6 +14,8 @@ export type RoleAccess = {
|
||||
category: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
module_id?: string;
|
||||
module_name?: string;
|
||||
};
|
||||
|
||||
export type RoleCreateRequest = {
|
||||
|
||||
@@ -140,14 +140,32 @@ const AddRoles = () => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Filter accessOptions to only show what the current user has access to
|
||||
const availableAccessOptions = useMemo(() => {
|
||||
if (canReadAllTenants) return accessOptions;
|
||||
|
||||
if (!user?.role?.accesses) return [];
|
||||
// If the user has access codes in their token, filter the list
|
||||
return accessOptions.filter((option) =>
|
||||
user.role?.accesses.includes(option.access_code)
|
||||
);
|
||||
}, [accessOptions, user]);
|
||||
|
||||
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
accessOptions.forEach((option) => {
|
||||
if (user.role?.accesses.includes(option.access_code)) {
|
||||
let current: RoleAccess | undefined = option;
|
||||
while (current) {
|
||||
if (includedIds.has(current.id)) break;
|
||||
includedIds.add(current.id);
|
||||
|
||||
if (current.parent_id) {
|
||||
current = accessMap.get(current.parent_id);
|
||||
} else {
|
||||
current = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return accessOptions.filter((option) => includedIds.has(option.id));
|
||||
}, [accessOptions, user, canReadAllTenants]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -178,12 +196,12 @@ const AddRoles = () => {
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomBackButton to="/roles" tooltip={t('backToRoles')} />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">{t('add')}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('subTitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{t('add')}</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('subTitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -277,4 +295,4 @@ const AddRoles = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AddRoles;
|
||||
export default AddRoles;
|
||||
@@ -4,6 +4,7 @@ import { Edit2, Eye, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomColumnFilter,
|
||||
CustomConfirmationModal,
|
||||
CustomInput,
|
||||
CustomModal,
|
||||
@@ -15,6 +16,12 @@ import {
|
||||
import { GroupedAccessSelector } from "./GroupedAccessSelector";
|
||||
import { GroupedAccessViewer } from "./GroupedAccessViewer";
|
||||
import type { ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import {
|
||||
buildColumnFilterOptions,
|
||||
resolveColumnSortState,
|
||||
} from "../../../components/custom/CustomColumnFilter.utils";
|
||||
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
||||
import { formatDate } from "../../../lib/dateFormat";
|
||||
import type {
|
||||
Role,
|
||||
RoleAccess,
|
||||
@@ -27,31 +34,11 @@ import type { Tenant } from "../../tenants/TenantsTypes";
|
||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { useDebounce } from "../../../components/hooks/useDebounce";
|
||||
|
||||
const formatDate = (dateString?: string | null, language: string = 'en') => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString;
|
||||
|
||||
const hasTime = dateString.includes("T") || dateString.includes(":");
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
if (hasTime) {
|
||||
options.hour = "2-digit";
|
||||
options.minute = "2-digit";
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString(language === 'ar' ? 'ar-EG' : 'en-GB', options);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
resolveStoredTablePageSize,
|
||||
} from "../../../lib/tablePageSize";
|
||||
|
||||
const AllRoles = () => {
|
||||
const { t, i18n } = useTranslation(['roles', 'common']);
|
||||
@@ -64,14 +51,27 @@ const AllRoles = () => {
|
||||
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(() =>
|
||||
resolveStoredTablePageSize({
|
||||
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
defaultPageSize: 10,
|
||||
})
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [roleNameFilter, setRoleNameFilter] = useState<string[]>([]);
|
||||
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [, setTotalPages] = useState(0);
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: "role_name" | "tenant_id" | null;
|
||||
direction: ColumnSortDirection;
|
||||
}>({ column: null, direction: null });
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
const latestRolesRequestRef = useRef(0);
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||
const [selectedRoleAccesses, setSelectedRoleAccesses] = useState<RoleAccess[] | null>(null);
|
||||
@@ -83,6 +83,7 @@ const AllRoles = () => {
|
||||
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
|
||||
const [isAccessLoading, setIsAccessLoading] = useState(false);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [allRolesForCounts, setAllRolesForCounts] = useState<Role[]>([]);
|
||||
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
|
||||
|
||||
const [editForm, setEditForm] = useState<{ role_name: string; is_default?: boolean }>({ role_name: "" });
|
||||
@@ -92,21 +93,32 @@ const AllRoles = () => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Filter access options based on current user's permissions
|
||||
const availableAccessOptions = useMemo(() => {
|
||||
if (
|
||||
!currentUser ||
|
||||
!currentUser.role ||
|
||||
!currentUser.role.accesses ||
|
||||
!Array.isArray(currentUser.role.accesses)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (canReadAllTenants) return accessOptions;
|
||||
|
||||
return accessOptions.filter((opt) =>
|
||||
currentUser.role!.accesses.includes(opt.access_code)
|
||||
);
|
||||
}, [accessOptions, currentUser]);
|
||||
if (!currentUser?.role?.accesses) return [];
|
||||
|
||||
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
accessOptions.forEach((option) => {
|
||||
if (currentUser.role?.accesses.includes(option.access_code)) {
|
||||
let current: RoleAccess | undefined = option;
|
||||
while (current) {
|
||||
if (includedIds.has(current.id)) break;
|
||||
includedIds.add(current.id);
|
||||
|
||||
if (current.parent_id) {
|
||||
current = accessMap.get(current.parent_id);
|
||||
} else {
|
||||
current = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return accessOptions.filter((option) => includedIds.has(option.id));
|
||||
}, [accessOptions, currentUser, canReadAllTenants]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -117,6 +129,9 @@ const AllRoles = () => {
|
||||
const accesses = await rolesApi.getAccesses();
|
||||
if (isMounted) setAccessOptions(accesses);
|
||||
|
||||
const allRoles = await rolesApi.getAll();
|
||||
if (isMounted) setAllRolesForCounts(allRoles);
|
||||
|
||||
if (canReadAllTenants) {
|
||||
const tenantsData = await tenantsApi.getAll();
|
||||
if (isMounted) setTenants(tenantsData);
|
||||
@@ -132,6 +147,7 @@ const AllRoles = () => {
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
const requestId = ++latestRolesRequestRef.current;
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
@@ -140,20 +156,29 @@ const AllRoles = () => {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: debouncedSearch || undefined,
|
||||
filter_role_names: roleNameFilter,
|
||||
filter_tenant_ids: tenantFilter,
|
||||
sort_by:
|
||||
activeSort.column === "role_name"
|
||||
? "name"
|
||||
: activeSort.column === "tenant_id"
|
||||
? "tenant"
|
||||
: undefined,
|
||||
sort_order: activeSort.direction ?? undefined,
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
if (isMounted && requestId === latestRolesRequestRef.current) {
|
||||
setRoles(response.items);
|
||||
setTotalRows(response.total);
|
||||
setTotalPages(response.total_pages);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
if (isMounted && requestId === latestRolesRequestRef.current) {
|
||||
const msg = err instanceof Error ? err.message : t('messages.error');
|
||||
setErrorMessage(msg);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
if (isMounted && requestId === latestRolesRequestRef.current) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,15 +197,16 @@ const AllRoles = () => {
|
||||
page,
|
||||
pageSize,
|
||||
debouncedSearch,
|
||||
roleNameFilter,
|
||||
tenantFilter,
|
||||
activeSort,
|
||||
t
|
||||
]);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
}, [debouncedSearch, roleNameFilter, tenantFilter, activeSort]);
|
||||
|
||||
// Restore search focus after loading
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim()) {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
@@ -202,6 +228,23 @@ const AllRoles = () => {
|
||||
[tenants, currentTenant, currentUser]
|
||||
);
|
||||
|
||||
const roleNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allRolesForCounts.forEach((role) => {
|
||||
counts.set(role.role_name, (counts.get(role.role_name) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allRolesForCounts]);
|
||||
|
||||
const tenantCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allRolesForCounts.forEach((role) => {
|
||||
if (!role.tenant_id) return;
|
||||
counts.set(role.tenant_id, (counts.get(role.tenant_id) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allRolesForCounts]);
|
||||
|
||||
const openView = useCallback(async (role: Role) => {
|
||||
setSelectedRole(role);
|
||||
setSelectedRoleAccesses(null);
|
||||
@@ -306,10 +349,50 @@ const AllRoles = () => {
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<Role>>>(
|
||||
() => [
|
||||
{ key: "role_name", header: t('columns.roleName') },
|
||||
{
|
||||
key: "role_name",
|
||||
visibilityLabel: t('columns.roleName'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.roleName')}
|
||||
<CustomColumnFilter
|
||||
title={t('columns.roleName')}
|
||||
options={buildColumnFilterOptions(roleNameCounts.entries())}
|
||||
selectedValues={roleNameFilter}
|
||||
sortDirection={activeSort.column === "role_name" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setRoleNameFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "role_name", direction));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tenant_id",
|
||||
header: t('columns.tenant'),
|
||||
visibilityLabel: t('columns.tenant'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.tenant')}
|
||||
{canReadAllTenants ? (
|
||||
<CustomColumnFilter
|
||||
title={t('columns.tenant')}
|
||||
options={tenants.map((tenant) => ({
|
||||
label: `${tenant.tenant_name} (${tenantCounts.get(tenant.id) ?? 0})`,
|
||||
value: tenant.id,
|
||||
}))}
|
||||
selectedValues={tenantFilter}
|
||||
sortDirection={activeSort.column === "tenant_id" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setTenantFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "tenant_id", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
render: (row) => getTenantName(row.tenant_id),
|
||||
},
|
||||
{
|
||||
@@ -361,14 +444,29 @@ const AllRoles = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[getTenantName, openView, openEdit, openDelete, t, i18n.language]
|
||||
[
|
||||
activeSort,
|
||||
canReadAllTenants,
|
||||
getTenantName,
|
||||
i18n.language,
|
||||
openDelete,
|
||||
openEdit,
|
||||
openView,
|
||||
roleNameCounts,
|
||||
roleNameFilter,
|
||||
t,
|
||||
tenantCounts,
|
||||
tenantFilter,
|
||||
tenants,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{t('title')}</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Define and manage user roles and their associated permissions.</p>
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="admin.role.create">
|
||||
<Link to="/roles/add">
|
||||
@@ -379,18 +477,19 @@ const AllRoles = () => {
|
||||
|
||||
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<CustomTable
|
||||
isLoading={isLoading}
|
||||
data={roles}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="roles-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
@@ -458,7 +557,7 @@ const AllRoles = () => {
|
||||
footer={
|
||||
<>
|
||||
<CustomButton variant="outlined" onClick={closeEdit} disabled={isSaving}>{t('actions.cancel')}</CustomButton>
|
||||
<CustomButton type="submit" form="edit-role-form" variant="primary" loading={isSaving}>{t('update')}</CustomButton>
|
||||
<CustomButton type="submit" form="edit-role-form" variant="primary" loading={isSaving}>{t('actions.update')}</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -516,4 +615,4 @@ const AllRoles = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AllRoles;
|
||||
export default AllRoles;
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { RoleAccess } from "../RolesTypes";
|
||||
import { CustomLoader } from "../../../components/custom";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
interface GroupedAccessSelectorProps {
|
||||
allAccesses: RoleAccess[];
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
accesses: RoleAccess[];
|
||||
children: Record<string, CategoryGroup>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const GroupedAccessSelector = ({
|
||||
@@ -20,163 +16,282 @@ export const GroupedAccessSelector = ({
|
||||
selectedIds = [],
|
||||
onChange,
|
||||
isLoading = false,
|
||||
title = "Role Permissions",
|
||||
}: GroupedAccessSelectorProps) => {
|
||||
const hierarchicalGroups = useMemo(() => {
|
||||
const parents: RoleAccess[] = [];
|
||||
const children: Record<string, RoleAccess[]> = {};
|
||||
|
||||
const { moduleGroups, childMap, allIds } = useMemo(() => {
|
||||
const ids = new Set(allAccesses.map((a) => a.id));
|
||||
const cMap: Record<string, RoleAccess[]> = {};
|
||||
const roots: RoleAccess[] = [];
|
||||
|
||||
allAccesses.forEach(access => {
|
||||
if (!access.parent_id) {
|
||||
parents.push(access);
|
||||
allAccesses.forEach((access) => {
|
||||
const hasParent = access.parent_id && ids.has(access.parent_id);
|
||||
|
||||
if (hasParent) {
|
||||
if (!cMap[access.parent_id!]) {
|
||||
cMap[access.parent_id!] = [];
|
||||
}
|
||||
cMap[access.parent_id!].push(access);
|
||||
} else {
|
||||
if (!children[access.parent_id]) {
|
||||
children[access.parent_id] = [];
|
||||
}
|
||||
children[access.parent_id].push(access);
|
||||
roots.push(access);
|
||||
}
|
||||
});
|
||||
|
||||
const modGroups: Record<string, Record<string, RoleAccess[]>> = {};
|
||||
|
||||
const categoryGroups: Record<string, CategoryGroup> = {};
|
||||
|
||||
parents.forEach(parent => {
|
||||
if (!categoryGroups[parent.category]) {
|
||||
categoryGroups[parent.category] = {
|
||||
category: parent.category,
|
||||
accesses: [],
|
||||
children: {}
|
||||
};
|
||||
roots.forEach((root) => {
|
||||
const moduleName = root.module_name || "SaaS (Internal)";
|
||||
const cat = root.category || "General";
|
||||
|
||||
if (!modGroups[moduleName]) {
|
||||
modGroups[moduleName] = {};
|
||||
}
|
||||
|
||||
categoryGroups[parent.category].accesses.push(parent);
|
||||
|
||||
const parentChildren = children[parent.id] || [];
|
||||
parentChildren.forEach(child => {
|
||||
if (!categoryGroups[parent.category].children[child.category]) {
|
||||
categoryGroups[parent.category].children[child.category] = {
|
||||
category: child.category,
|
||||
accesses: [],
|
||||
children: {}
|
||||
};
|
||||
}
|
||||
categoryGroups[parent.category].children[child.category].accesses.push(child);
|
||||
});
|
||||
if (!modGroups[moduleName][cat]) {
|
||||
modGroups[moduleName][cat] = [];
|
||||
}
|
||||
modGroups[moduleName][cat].push(root);
|
||||
});
|
||||
|
||||
return categoryGroups;
|
||||
|
||||
const sortedModuleNames = Object.keys(modGroups).sort((a, b) => {
|
||||
if (a === "SaaS (Internal)") return -1;
|
||||
if (b === "SaaS (Internal)") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const orderedGroups = sortedModuleNames.map(name => ({
|
||||
name,
|
||||
categories: modGroups[name]
|
||||
}));
|
||||
|
||||
return { moduleGroups: orderedGroups, childMap: cMap, allIds: ids };
|
||||
}, [allAccesses]);
|
||||
|
||||
const getCategoryAccesses = (group: CategoryGroup): RoleAccess[] => {
|
||||
const accesses = [...group.accesses];
|
||||
Object.values(group.children).forEach(child => {
|
||||
accesses.push(...child.accesses);
|
||||
const [expandedModules, setExpandedModules] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleModuleExpansion = (moduleName: string) => {
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[moduleName]: !prev[moduleName]
|
||||
}));
|
||||
};
|
||||
|
||||
const getBranchIds = (id: string): string[] => {
|
||||
const ids = [id];
|
||||
const children = childMap[id] || [];
|
||||
children.forEach((c) => {
|
||||
ids.push(...getBranchIds(c.id));
|
||||
});
|
||||
return accesses;
|
||||
return ids;
|
||||
};
|
||||
|
||||
const isCategorySelected = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
return (
|
||||
categoryAccesses.length > 0 &&
|
||||
categoryAccesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
const isBranchSelected = (id: string) => {
|
||||
const ids = getBranchIds(id);
|
||||
return ids.every((i) => selectedIds.includes(i));
|
||||
};
|
||||
|
||||
const isCategoryIndeterminate = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
const selectedCount = categoryAccesses.filter((access) =>
|
||||
selectedIds.includes(access.id)
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < categoryAccesses.length;
|
||||
const isBranchIndeterminate = (id: string) => {
|
||||
const ids = getBranchIds(id);
|
||||
const selectedCount = ids.filter((i) => selectedIds.includes(i)).length;
|
||||
return selectedCount > 0 && selectedCount < ids.length;
|
||||
};
|
||||
|
||||
const toggleCategory = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
const allSelected = isCategorySelected(group);
|
||||
const categoryIds = categoryAccesses.map((a) => a.id);
|
||||
const toggleBranch = (id: string) => {
|
||||
const ids = getBranchIds(id);
|
||||
const allSelected = ids.every((i) => selectedIds.includes(i));
|
||||
|
||||
let newIds: string[];
|
||||
if (allSelected) {
|
||||
newIds = selectedIds.filter((id) => !categoryIds.includes(id));
|
||||
newIds = selectedIds.filter((i) => !ids.includes(i));
|
||||
} else {
|
||||
const uniqueIds = new Set([...selectedIds, ...categoryIds]);
|
||||
newIds = Array.from(uniqueIds);
|
||||
const unique = new Set([...selectedIds, ...ids]);
|
||||
newIds = Array.from(unique);
|
||||
}
|
||||
onChange(newIds);
|
||||
};
|
||||
|
||||
// --- Subcategory Helpers ---
|
||||
const isSubcategorySelected = (accesses: RoleAccess[]) => {
|
||||
return (
|
||||
accesses.length > 0 &&
|
||||
accesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
};
|
||||
|
||||
const isSubcategoryIndeterminate = (accesses: RoleAccess[]) => {
|
||||
const selectedCount = accesses.filter((access) =>
|
||||
selectedIds.includes(access.id)
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < accesses.length;
|
||||
};
|
||||
|
||||
const toggleSubcategory = (accesses: RoleAccess[]) => {
|
||||
const allSelected = isSubcategorySelected(accesses);
|
||||
const accessIds = accesses.map((a) => a.id);
|
||||
|
||||
let newIds: string[];
|
||||
if (allSelected) {
|
||||
newIds = selectedIds.filter((id) => !accessIds.includes(id));
|
||||
const toggleSingle = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((sid) => sid !== id));
|
||||
} else {
|
||||
const uniqueIds = new Set([...selectedIds, ...accessIds]);
|
||||
newIds = Array.from(uniqueIds);
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
onChange(newIds);
|
||||
};
|
||||
|
||||
// --- Global Helpers ---
|
||||
const isAllSelected = () => {
|
||||
return (
|
||||
allAccesses.length > 0 &&
|
||||
allAccesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
const getCategoryIds = (roots: RoleAccess[]) => {
|
||||
const ids: string[] = [];
|
||||
roots.forEach((root) => {
|
||||
ids.push(...getBranchIds(root.id));
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const isAllIndeterminate = () => {
|
||||
const selectedCount = selectedIds.length;
|
||||
return selectedCount > 0 && selectedCount < allAccesses.length;
|
||||
const isCategorySelected = (roots: RoleAccess[]) => {
|
||||
const ids = getCategoryIds(roots);
|
||||
return ids.length > 0 && ids.every((id) => selectedIds.includes(id));
|
||||
};
|
||||
|
||||
const isCategoryIndeterminate = (roots: RoleAccess[]) => {
|
||||
const ids = getCategoryIds(roots);
|
||||
const count = ids.filter((id) => selectedIds.includes(id)).length;
|
||||
return count > 0 && count < ids.length;
|
||||
};
|
||||
|
||||
const toggleCategory = (roots: RoleAccess[]) => {
|
||||
const ids = getCategoryIds(roots);
|
||||
const allSelected = ids.every((id) => selectedIds.includes(id));
|
||||
if (allSelected) {
|
||||
onChange(selectedIds.filter((id) => !ids.includes(id)));
|
||||
} else {
|
||||
const unique = new Set([...selectedIds, ...ids]);
|
||||
onChange(Array.from(unique));
|
||||
}
|
||||
};
|
||||
|
||||
const getModuleIds = (categories: Record<string, RoleAccess[]>) => {
|
||||
return Object.values(categories).flatMap(roots => getCategoryIds(roots));
|
||||
};
|
||||
|
||||
const isModuleSelected = (categories: Record<string, RoleAccess[]>) => {
|
||||
const ids = getModuleIds(categories);
|
||||
return ids.length > 0 && ids.every(id => selectedIds.includes(id));
|
||||
};
|
||||
|
||||
const isModuleIndeterminate = (categories: Record<string, RoleAccess[]>) => {
|
||||
const ids = getModuleIds(categories);
|
||||
const count = ids.filter(id => selectedIds.includes(id)).length;
|
||||
return count > 0 && count < ids.length;
|
||||
};
|
||||
|
||||
const toggleModule = (categories: Record<string, RoleAccess[]>) => {
|
||||
const ids = getModuleIds(categories);
|
||||
const allSelected = ids.every(id => selectedIds.includes(id));
|
||||
if (allSelected) {
|
||||
onChange(selectedIds.filter(id => !ids.includes(id)));
|
||||
} else {
|
||||
const unique = new Set([...selectedIds, ...ids]);
|
||||
onChange(Array.from(unique));
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = () =>
|
||||
allIds.size > 0 && Array.from(allIds).every((id) => selectedIds.includes(id));
|
||||
const isAllIndeterminate = () =>
|
||||
selectedIds.length > 0 && selectedIds.length < allIds.size;
|
||||
const toggleAll = () => {
|
||||
if (isAllSelected()) {
|
||||
onChange([]);
|
||||
} else {
|
||||
const allIds = allAccesses.map((a) => a.id);
|
||||
onChange(allIds);
|
||||
onChange(Array.from(allIds));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Individual Helper ---
|
||||
const toggleAccess = (accessId: string) => {
|
||||
const newIds = selectedIds.includes(accessId)
|
||||
? selectedIds.filter((id) => id !== accessId)
|
||||
: [...selectedIds, accessId];
|
||||
onChange(newIds);
|
||||
const renderNode = (node: RoleAccess, depth: number = 0) => {
|
||||
const children = childMap[node.id];
|
||||
const hasChildren = children && children.length > 0;
|
||||
const isRoot = depth === 0;
|
||||
|
||||
const renderChildrenList = (items: RoleAccess[]) => {
|
||||
if (items.some(c => childMap[c.id])) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasChildren) {
|
||||
if (isRoot) {
|
||||
const childGroups: Record<string, RoleAccess[]> = {};
|
||||
let hasMultipleGroups = false;
|
||||
children.forEach(c => {
|
||||
const cat = c.category || 'General';
|
||||
if (!childGroups[cat]) childGroups[cat] = [];
|
||||
childGroups[cat].push(c);
|
||||
});
|
||||
hasMultipleGroups = Object.keys(childGroups).length > 1;
|
||||
|
||||
return (
|
||||
<div key={node.id} className="rounded-md border border-gray-200 bg-white p-3 mb-3 break-inside-avoid shadow-xs">
|
||||
<label className="flex items-center gap-2 mb-3 cursor-pointer group pb-2 border-b border-gray-50/50 select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isBranchSelected(node.id)}
|
||||
ref={(el) => { if (el) el.indeterminate = isBranchIndeterminate(node.id); }}
|
||||
onChange={() => toggleBranch(node.id)}
|
||||
/>
|
||||
<span className="font-semibold text-gray-800">{node.name}</span>
|
||||
</label>
|
||||
|
||||
<div className="pl-2">
|
||||
{hasMultipleGroups ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(childGroups).map(([groupName, groupItems]) => (
|
||||
<div key={groupName} className="bg-gray-50/50 rounded-md border border-gray-200/50 p-3">
|
||||
<h5 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 border-b border-gray-100 pb-1">
|
||||
{groupName}
|
||||
</h5>
|
||||
{renderChildrenList(groupItems)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
renderChildrenList(children)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={node.id} className="rounded-md border border-gray-100 bg-white p-3">
|
||||
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-gray-50 select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isBranchSelected(node.id)}
|
||||
ref={(el) => { if (el) el.indeterminate = isBranchIndeterminate(node.id); }}
|
||||
onChange={() => toggleBranch(node.id)}
|
||||
/>
|
||||
<span className="font-medium text-sm text-gray-700">{node.name}</span>
|
||||
</div>
|
||||
{renderChildrenList(children)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<label
|
||||
key={node.id}
|
||||
className="flex items-start gap-2 cursor-pointer p-1.5 hover:bg-gray-50/50 rounded-md transition-colors select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={selectedIds.includes(node.id)}
|
||||
onChange={() => toggleSingle(node.id)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">{node.name}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <CustomLoader />;
|
||||
}
|
||||
|
||||
if (allAccesses.length === 0) {
|
||||
if (isLoading) return <CustomLoader />;
|
||||
if (allAccesses.length === 0)
|
||||
return <p className="text-sm text-gray-500">No permissions available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium text-gray-900">Permissions</h3>
|
||||
|
||||
{/* Global Select All */}
|
||||
{/* Global Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -188,108 +303,92 @@ export const GroupedAccessSelector = ({
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<label
|
||||
htmlFor="select-all"
|
||||
<label
|
||||
htmlFor="select-all"
|
||||
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
Select All Permissions
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-6">
|
||||
{Object.entries(hierarchicalGroups).map(([category, group]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50/50 p-4"
|
||||
>
|
||||
{/* Category Header */}
|
||||
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isCategorySelected(group)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isCategoryIndeterminate(group);
|
||||
}}
|
||||
onChange={() => toggleCategory(group)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-800 uppercase tracking-wide">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Parent Accesses */}
|
||||
{group.accesses.length > 0 && (
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{group.accesses.map((access) => (
|
||||
<label
|
||||
key={access.id}
|
||||
className="flex items-start gap-3 cursor-pointer group"
|
||||
{moduleGroups.map((moduleGroup) => {
|
||||
const isExpanded = expandedModules[moduleGroup.name];
|
||||
return (
|
||||
<div key={moduleGroup.name} className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden">
|
||||
{/* Module Header (Collapsible) */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => toggleModuleExpansion(moduleGroup.name)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
|
||||
checked={selectedIds.includes(access.id)}
|
||||
onChange={() => toggleAccess(access.id)}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 group-hover:text-gray-900">
|
||||
{access.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Child Categories */}
|
||||
{Object.keys(group.children).length > 0 && (
|
||||
<div className="space-y-4 mt-4">
|
||||
{Object.entries(group.children).map(([childCategory, childGroup]) => (
|
||||
<div
|
||||
key={childCategory}
|
||||
className="rounded-md border border-gray-300 bg-white p-3 ml-4"
|
||||
>
|
||||
{/* Subcategory Header */}
|
||||
<div className="mb-3 flex items-center gap-2 border-b border-gray-200 pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isSubcategorySelected(childGroup.accesses)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isSubcategoryIndeterminate(childGroup.accesses);
|
||||
}}
|
||||
onChange={() => toggleSubcategory(childGroup.accesses)}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||
{childCategory}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subcategory Accesses */}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{childGroup.accesses.map((access) => (
|
||||
<label
|
||||
key={access.id}
|
||||
className="flex items-start gap-2 cursor-pointer group"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
|
||||
checked={selectedIds.includes(access.id)}
|
||||
onChange={() => toggleAccess(access.id)}
|
||||
/>
|
||||
<span className="text-xs text-gray-600 group-hover:text-gray-900">
|
||||
{access.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? <ChevronDown size={20} className="text-gray-500" /> : <ChevronRight size={20} className="text-gray-500" />}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent collapse when clicking checkbox
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isModuleSelected(moduleGroup.categories)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isModuleIndeterminate(moduleGroup.categories);
|
||||
}}
|
||||
onChange={() => toggleModule(moduleGroup.categories)}
|
||||
/>
|
||||
<h2 className="text-base font-bold text-gray-900 select-none">{moduleGroup.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Module Content (Collapsible) */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 border-t border-gray-200 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div className="space-y-8">
|
||||
{Object.entries(moduleGroup.categories).map(([category, roots]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50/10 p-4"
|
||||
>
|
||||
{/* Category Header with Select All Category */}
|
||||
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isCategorySelected(roots)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isCategoryIndeterminate(roots);
|
||||
}}
|
||||
onChange={() => toggleCategory(roots)}
|
||||
/>
|
||||
<span className="text-sm font-bold text-gray-700 uppercase tracking-widest select-none">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 1. Root Branches (Cards) */}
|
||||
{roots
|
||||
.filter((r) => childMap[r.id]?.length > 0)
|
||||
.map((root) => renderNode(root, 0))}
|
||||
|
||||
{/* 2. Root Leaves (Grid) */}
|
||||
{roots.some((r) => !childMap[r.id]?.length) && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 mt-4 ml-1">
|
||||
{roots
|
||||
.filter((r) => !childMap[r.id]?.length)
|
||||
.map((root) => renderNode(root, 0))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from "react";
|
||||
import type { RoleAccess } from "../RolesTypes";
|
||||
|
||||
interface NodeGroupAccessViewerProps {
|
||||
accesses: RoleAccess[];
|
||||
}
|
||||
|
||||
export const NodeGroupAccessViewer = ({ accesses }: NodeGroupAccessViewerProps) => {
|
||||
const { categoryGroups, childMap } = useMemo(() => {
|
||||
const childMap: Record<string, RoleAccess[]> = {};
|
||||
const categoryGroups: Record<string, RoleAccess[]> = {};
|
||||
|
||||
const idMap = new Set(accesses.map(a => a.id));
|
||||
|
||||
accesses.forEach(node => {
|
||||
if (!node.parent_id || !idMap.has(node.parent_id)) {
|
||||
const category = node.category || 'General';
|
||||
if (!categoryGroups[category]) {
|
||||
categoryGroups[category] = [];
|
||||
}
|
||||
categoryGroups[category].push(node);
|
||||
} else {
|
||||
if (!childMap[node.parent_id]) {
|
||||
childMap[node.parent_id] = [];
|
||||
}
|
||||
childMap[node.parent_id].push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return { categoryGroups, childMap };
|
||||
}, [accesses]);
|
||||
|
||||
const renderNode = (node: RoleAccess, depth: number = 0) => {
|
||||
const children = childMap[node.id];
|
||||
const hasChildren = children && children.length > 0;
|
||||
|
||||
const renderChildrenList = (items: RoleAccess[]) => {
|
||||
if (items.some(c => childMap[c.id])) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (depth === 0) {
|
||||
if (hasChildren) {
|
||||
const childGroups: Record<string, RoleAccess[]> = {};
|
||||
let hasMultipleGroups = false;
|
||||
|
||||
children.forEach(c => {
|
||||
const cat = c.category || 'General';
|
||||
if (!childGroups[cat]) childGroups[cat] = [];
|
||||
childGroups[cat].push(c);
|
||||
});
|
||||
|
||||
hasMultipleGroups = Object.keys(childGroups).length > 1;
|
||||
|
||||
return (
|
||||
<div key={node.id} className="col-span-1 sm:col-span-2 rounded-lg border border-gray-200 bg-gray-50/50 p-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 mb-3 border-b border-gray-200 pb-2">
|
||||
<span className="size-2 rounded-full bg-blue-600 shrink-0" />
|
||||
<span className="text-base font-semibold text-gray-800 tracking-wide">
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pl-2">
|
||||
{hasMultipleGroups ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(childGroups).map(([groupName, groupItems]) => (
|
||||
<div key={groupName} className="bg-white/50 rounded-md border border-gray-200/50 p-3">
|
||||
<h5 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 border-b border-gray-100 pb-1">
|
||||
{groupName}
|
||||
</h5>
|
||||
{renderChildrenList(groupItems)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
renderChildrenList(children)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={node.id} className="flex items-center gap-2 p-3 bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
<span className="size-2 rounded-full bg-blue-500 shrink-0" />
|
||||
<span className="font-medium text-gray-700">{node.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div key={node.id} className="rounded-md border border-gray-100 bg-white p-3">
|
||||
<div className="mb-2 pb-2 border-b border-gray-50">
|
||||
<span className="font-medium text-sm text-gray-700 block">{node.name}</span>
|
||||
</div>
|
||||
{renderChildrenList(children)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.id} className="flex items-center gap-2 text-sm text-gray-600 bg-white p-2 rounded-md border border-transparent hover:border-gray-100 shadow-sm hover:shadow-md transition-all">
|
||||
<span className="size-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
<span className="font-medium leading-tight">{node.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!accesses || accesses.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
||||
<p className="text-gray-500">No permissions found in this module.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(categoryGroups).map(([category, roots]) => (
|
||||
<div key={category} className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<div className="bg-gray-50/80 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-bold text-gray-800 text-lg">{category}</h3>
|
||||
</div>
|
||||
|
||||
{/* Roots in this Category - Grid Layout */}
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{roots.map(root => renderNode(root, 0))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { CustomDropdown, CustomConfirmationModal } from '../../components/custom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { languages } from '../../i18n/config';
|
||||
import type { SupportedLanguage } from '../../i18n/config';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
const { user, updateLanguage } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedLanguage] = useState<SupportedLanguage>(
|
||||
(i18n.language as SupportedLanguage) || 'en'
|
||||
);
|
||||
const [pendingLanguage, setPendingLanguage] = useState<SupportedLanguage | null>(null);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
const handleLanguageChange = (newLanguage: SupportedLanguage) => {
|
||||
if (newLanguage === selectedLanguage) return;
|
||||
|
||||
setPendingLanguage(newLanguage);
|
||||
setShowConfirmation(true);
|
||||
};
|
||||
|
||||
const handleConfirmLanguageChange = async () => {
|
||||
if (!pendingLanguage) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setShowConfirmation(false);
|
||||
|
||||
try {
|
||||
localStorage.setItem('preferred_language', pendingLanguage);
|
||||
|
||||
if (user) {
|
||||
await updateLanguage(pendingLanguage);
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to update language:', error);
|
||||
toast.error(t('messages.error'));
|
||||
setIsLoading(false);
|
||||
setPendingLanguage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelLanguageChange = () => {
|
||||
setPendingLanguage(null);
|
||||
setShowConfirmation(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-1 mb-6">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">
|
||||
{t('settings.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Manage your account settings and application preferences.</p>
|
||||
</div>
|
||||
|
||||
{/* Language Settings Section */}
|
||||
<div className="bg-(--card-bg) rounded-lg border border-(--card-border) p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Globe className="w-5 h-5 text-(--primary)" />
|
||||
<h2 className="text-lg font-semibold text-(--text-primary)">
|
||||
{t('settings.language.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-(--text-secondary) mb-6">
|
||||
{t('settings.language.description')}
|
||||
</p>
|
||||
|
||||
<div className="max-w-md">
|
||||
<CustomDropdown
|
||||
label={t('settings.language.selectLabel') || "Select Language"}
|
||||
options={(Object.keys(languages) as SupportedLanguage[]).map(lang => ({
|
||||
label: languages[lang].name,
|
||||
value: lang
|
||||
}))}
|
||||
value={selectedLanguage}
|
||||
onChange={(e) => handleLanguageChange(e.target.value as SupportedLanguage)}
|
||||
disabled={isLoading}
|
||||
leftIcon={<Globe size={18} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={showConfirmation}
|
||||
onClose={handleCancelLanguageChange}
|
||||
onConfirm={handleConfirmLanguageChange}
|
||||
title={t('settings.language.confirmTitle') || 'Change Language?'}
|
||||
description={t('settings.language.confirmMessage') || 'The page will reload to apply the new language settings. Continue?'}
|
||||
confirmText={t('actions.confirm')}
|
||||
cancelText={t('actions.cancel')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface LanguageSettings {
|
||||
code: string;
|
||||
name: string;
|
||||
dir: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
export interface SettingsFormData {
|
||||
language: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type SubscriptionPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
price?: number | null;
|
||||
duration_days?: number | null;
|
||||
max_users_allowed?: number | null;
|
||||
is_public: boolean;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type SubscriptionPlanDetail = SubscriptionPlan & {
|
||||
access_ids: string[];
|
||||
module_access_ids: string[];
|
||||
};
|
||||
|
||||
export type SubscriptionPlanCreateRequest = {
|
||||
name: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
duration_days?: number;
|
||||
max_users_allowed?: number;
|
||||
is_public?: boolean;
|
||||
status?: string;
|
||||
access_ids?: string[];
|
||||
module_access_ids?: string[];
|
||||
};
|
||||
|
||||
export type SubscriptionPlanUpdateRequest = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
duration_days?: number;
|
||||
max_users_allowed?: number;
|
||||
is_public?: boolean;
|
||||
status?: string;
|
||||
access_ids?: string[];
|
||||
module_access_ids?: string[];
|
||||
};
|
||||
|
||||
export type SubscriptionPlanPaginatedResponse = {
|
||||
items: SubscriptionPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import { buildQueryString } from "../../lib/queryParams";
|
||||
import type {
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlanDetail,
|
||||
SubscriptionPlanCreateRequest,
|
||||
SubscriptionPlanUpdateRequest,
|
||||
SubscriptionPlanPaginatedResponse,
|
||||
} from "./SubscriptionTypes";
|
||||
import type { RoleAccess } from "../roles/RolesTypes";
|
||||
|
||||
export const subscriptionsApi = {
|
||||
getAll: (params?: { is_public?: boolean; status?: string }) => {
|
||||
const queryString = params ? buildQueryString(params) : "";
|
||||
return apiClient.get<SubscriptionPlan[]>(
|
||||
`/api/subscription-plan/all${queryString}`
|
||||
);
|
||||
},
|
||||
|
||||
getById: (planId: string) =>
|
||||
apiClient.get<SubscriptionPlanDetail>(
|
||||
`/api/subscription-plan/get/${planId}`
|
||||
),
|
||||
|
||||
create: (payload: SubscriptionPlanCreateRequest) =>
|
||||
apiClient.post<SubscriptionPlan>("/api/subscription-plan/create", payload, {
|
||||
successMessage: "Subscription plan created successfully",
|
||||
errorMessage: "Failed to create subscription plan",
|
||||
}),
|
||||
|
||||
update: (planId: string, payload: SubscriptionPlanUpdateRequest) =>
|
||||
apiClient.put<SubscriptionPlan>(
|
||||
`/api/subscription-plan/update/${planId}`,
|
||||
payload,
|
||||
{
|
||||
successMessage: "Subscription plan updated successfully",
|
||||
errorMessage: "Failed to update subscription plan",
|
||||
}
|
||||
),
|
||||
|
||||
remove: (planId: string) =>
|
||||
apiClient.delete<{ message: string }>(
|
||||
`/api/subscription-plan/delete/${planId}`,
|
||||
{
|
||||
successMessage: "Subscription plan deleted",
|
||||
errorMessage: "Failed to delete subscription plan",
|
||||
}
|
||||
),
|
||||
|
||||
getPaginated: (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
filter_names?: string[];
|
||||
statuses?: string[];
|
||||
visibility?: boolean[];
|
||||
sort_by?: "name" | "price" | "status" | "visibility";
|
||||
sort_order?: "asc" | "desc";
|
||||
}) => {
|
||||
const queryString = buildQueryString({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
search: params.search,
|
||||
filter_names: params.filter_names ?? undefined,
|
||||
statuses: params.statuses ?? undefined,
|
||||
visibility: params.visibility ?? undefined,
|
||||
sort_by: params.sort_by ?? undefined,
|
||||
sort_order: params.sort_order ?? undefined,
|
||||
});
|
||||
return apiClient.get<SubscriptionPlanPaginatedResponse>(
|
||||
`/api/subscription-plan/list${queryString}`
|
||||
);
|
||||
},
|
||||
|
||||
getAccesses: () => apiClient.get<RoleAccess[]>("/api/access/get"),
|
||||
};
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomDropdown,
|
||||
CustomInput,
|
||||
CustomBackButton,
|
||||
} from "../../../components/custom";
|
||||
import { subscriptionsApi } from "../SubscriptionsApi";
|
||||
import type { SubscriptionPlanCreateRequest } from "../SubscriptionTypes";
|
||||
import type { RoleAccess } from "../../roles/RolesTypes";
|
||||
import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector";
|
||||
|
||||
const AddSubscriptions = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formData, setFormData] = useState<SubscriptionPlanCreateRequest>({
|
||||
name: "",
|
||||
description: "",
|
||||
price: undefined,
|
||||
duration_days: undefined,
|
||||
max_users_allowed: undefined,
|
||||
is_public: true,
|
||||
status: "active",
|
||||
access_ids: [],
|
||||
module_access_ids: [],
|
||||
});
|
||||
|
||||
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
|
||||
const [selectedAccessIds, setSelectedAccessIds] = useState<string[]>([]);
|
||||
const [isAccessLoading, setIsAccessLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadAccesses = async () => {
|
||||
setIsAccessLoading(true);
|
||||
try {
|
||||
const data = await subscriptionsApi.getAccesses();
|
||||
if (isMounted) {
|
||||
setAllAccesses(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load access options.";
|
||||
setErrorMessage(message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsAccessLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadAccesses();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const splitAccessIds = (ids: string[]) => {
|
||||
const accessIds: string[] = [];
|
||||
const moduleAccessIds: string[] = [];
|
||||
const accessMap = new Map(allAccesses.map((a) => [a.id, a]));
|
||||
|
||||
ids.forEach((id) => {
|
||||
const access = accessMap.get(id);
|
||||
if (access?.module_id) {
|
||||
moduleAccessIds.push(id);
|
||||
} else {
|
||||
accessIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return { accessIds, moduleAccessIds };
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { accessIds, moduleAccessIds } = splitAccessIds(selectedAccessIds);
|
||||
|
||||
const payload: SubscriptionPlanCreateRequest = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description?.trim() || undefined,
|
||||
price: formData.price ? Number(formData.price) : undefined,
|
||||
duration_days: formData.duration_days ? Number(formData.duration_days) : undefined,
|
||||
max_users_allowed: formData.max_users_allowed ?? undefined,
|
||||
is_public: formData.is_public,
|
||||
status: formData.status,
|
||||
access_ids: accessIds,
|
||||
module_access_ids: moduleAccessIds,
|
||||
};
|
||||
|
||||
await subscriptionsApi.create(payload);
|
||||
navigate("/subscriptions");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to create subscription plan.";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomBackButton to="/subscriptions" tooltip="Back to Subscriptions" />
|
||||
<div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
Add Subscription Plan
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Create a new plan with pricing and access permissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-lg border border-gray-200 bg-white p-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput
|
||||
label="Plan Name"
|
||||
name="name"
|
||||
placeholder="Enter plan name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<CustomInput
|
||||
label="Price"
|
||||
name="price"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={formData.price ?? ""}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
price: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Duration (Days)"
|
||||
name="duration_days"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
value={formData.duration_days ?? ""}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
duration_days: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Max Allowed Users"
|
||||
name="max_users_allowed"
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
value={formData.max_users_allowed ?? ""}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
max_users_allowed: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Description"
|
||||
name="description"
|
||||
placeholder="Enter plan description"
|
||||
value={formData.description ?? ""}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
name="status"
|
||||
value={formData.status ?? "active"}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, status: e.target.value }))
|
||||
}
|
||||
options={[
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-end pb-1">
|
||||
<CustomCheckBox
|
||||
label="Publicly Visible"
|
||||
name="is_public"
|
||||
checked={formData.is_public ?? true}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
is_public: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<GroupedAccessSelector
|
||||
allAccesses={allAccesses}
|
||||
selectedIds={selectedAccessIds}
|
||||
onChange={setSelectedAccessIds}
|
||||
isLoading={isAccessLoading}
|
||||
title="Plan Permissions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<CustomButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Create Plan
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSubscriptions;
|
||||
@@ -0,0 +1,849 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Edit2, Eye, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomColumnFilter,
|
||||
CustomConfirmationModal,
|
||||
CustomDropdown,
|
||||
CustomInput,
|
||||
CustomModal,
|
||||
CustomTable,
|
||||
CustomStatus,
|
||||
CustomLoader,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
} from "../../../components/custom";
|
||||
import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector";
|
||||
import { GroupedAccessViewer } from "../../roles/components/GroupedAccessViewer";
|
||||
import type { ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import {
|
||||
buildColumnFilterOptions,
|
||||
resolveColumnSortState,
|
||||
} from "../../../components/custom/CustomColumnFilter.utils";
|
||||
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
||||
import { formatDate } from "../../../lib/dateFormat";
|
||||
import type { SubscriptionPlan, SubscriptionPlanUpdateRequest } from "../SubscriptionTypes";
|
||||
import type { RoleAccess } from "../../roles/RolesTypes";
|
||||
import { subscriptionsApi } from "../SubscriptionsApi";
|
||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { useDebounce } from "../../../components/hooks/useDebounce";
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
resolveStoredTablePageSize,
|
||||
} from "../../../lib/tablePageSize";
|
||||
|
||||
const formatPrice = (price?: number | null) => {
|
||||
if (price == null) return "-";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
interface EditFormState {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number | undefined;
|
||||
duration_days: number | undefined;
|
||||
max_users_allowed: number | undefined;
|
||||
is_public: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const AllSubscriptions = () => {
|
||||
const { isLoading: isAuthLoading } = useAuth();
|
||||
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(() =>
|
||||
resolveStoredTablePageSize({
|
||||
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
defaultPageSize: 10,
|
||||
})
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [nameFilter, setNameFilter] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [visibilityFilter, setVisibilityFilter] = useState<boolean[]>([]);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [, setTotalPages] = useState(0);
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: "name" | "status" | "is_public" | null;
|
||||
direction: ColumnSortDirection;
|
||||
}>({ column: null, direction: null });
|
||||
const [allPlansForCounts, setAllPlansForCounts] = useState<SubscriptionPlan[]>([]);
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
const latestPlansRequestRef = useRef(0);
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
|
||||
const [isAccessLoading, setIsAccessLoading] = useState(false);
|
||||
const [viewAccessIds, setViewAccessIds] = useState<string[]>([]);
|
||||
const [editAccessIds, setEditAccessIds] = useState<string[]>([]);
|
||||
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
||||
|
||||
const [editForm, setEditForm] = useState<EditFormState>({
|
||||
name: "",
|
||||
description: "",
|
||||
price: undefined,
|
||||
duration_days: undefined,
|
||||
max_users_allowed: undefined,
|
||||
is_public: true,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const [editError, setEditError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const loadAccesses = async () => {
|
||||
setIsAccessLoading(true);
|
||||
try {
|
||||
const data = await subscriptionsApi.getAccesses();
|
||||
if (isMounted) setAllAccesses(data);
|
||||
|
||||
const allPlans = await subscriptionsApi.getAll();
|
||||
if (isMounted) setAllPlansForCounts(allPlans);
|
||||
} catch (err) {
|
||||
console.error("Failed to load accesses:", err);
|
||||
} finally {
|
||||
if (isMounted) setIsAccessLoading(false);
|
||||
}
|
||||
};
|
||||
loadAccesses();
|
||||
return () => { isMounted = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPlans = async () => {
|
||||
if (isAuthLoading) return;
|
||||
|
||||
const requestId = ++latestPlansRequestRef.current;
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const response = await subscriptionsApi.getPaginated({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: debouncedSearch || undefined,
|
||||
filter_names: nameFilter,
|
||||
statuses: statusFilter,
|
||||
visibility: visibilityFilter,
|
||||
sort_by:
|
||||
activeSort.column === "name"
|
||||
? "name"
|
||||
: activeSort.column === "status"
|
||||
? "status"
|
||||
: activeSort.column === "is_public"
|
||||
? "visibility"
|
||||
: undefined,
|
||||
sort_order: activeSort.direction ?? undefined,
|
||||
});
|
||||
|
||||
if (isMounted && requestId === latestPlansRequestRef.current) {
|
||||
setPlans(response.items);
|
||||
setTotalRows(response.total);
|
||||
setTotalPages(response.total_pages);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted && requestId === latestPlansRequestRef.current) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to load plans.";
|
||||
setErrorMessage(message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted && requestId === latestPlansRequestRef.current) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPlans();
|
||||
return () => { isMounted = false; };
|
||||
}, [activeSort, debouncedSearch, isAuthLoading, nameFilter, page, pageSize, statusFilter, visibilityFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, nameFilter, statusFilter, visibilityFilter, activeSort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim()) {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
prevLoadingRef.current = isLoading;
|
||||
}, [isLoading, search]);
|
||||
|
||||
const viewAccesses = useMemo(() => {
|
||||
if (!viewAccessIds.length || !allAccesses.length) return [];
|
||||
const idSet = new Set(viewAccessIds);
|
||||
return allAccesses.filter((a) => idSet.has(a.id));
|
||||
}, [viewAccessIds, allAccesses]);
|
||||
|
||||
const nameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allPlansForCounts.forEach((plan) => {
|
||||
counts.set(plan.name, (counts.get(plan.name) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allPlansForCounts]);
|
||||
|
||||
const planStatusCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allPlansForCounts.forEach((plan) => {
|
||||
counts.set(plan.status, (counts.get(plan.status) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allPlansForCounts]);
|
||||
|
||||
const visibilityCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allPlansForCounts.forEach((plan) => {
|
||||
const key = plan.is_public ? "true" : "false";
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allPlansForCounts]);
|
||||
|
||||
const splitAccessIds = useCallback(
|
||||
(ids: string[]) => {
|
||||
const accessIds: string[] = [];
|
||||
const moduleAccessIds: string[] = [];
|
||||
const accessMap = new Map(allAccesses.map((a) => [a.id, a]));
|
||||
|
||||
ids.forEach((id) => {
|
||||
const access = accessMap.get(id);
|
||||
if (access?.module_id) {
|
||||
moduleAccessIds.push(id);
|
||||
} else {
|
||||
accessIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
return { accessIds, moduleAccessIds };
|
||||
},
|
||||
[allAccesses]
|
||||
);
|
||||
|
||||
const openView = useCallback(async (plan: SubscriptionPlan) => {
|
||||
setSelectedPlan(plan);
|
||||
setViewAccessIds([]);
|
||||
setIsViewOpen(true);
|
||||
setIsDetailLoading(true);
|
||||
try {
|
||||
const detail = await subscriptionsApi.getById(plan.id);
|
||||
setViewAccessIds([...detail.access_ids, ...detail.module_access_ids]);
|
||||
} catch {
|
||||
setViewAccessIds([]);
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback(async (plan: SubscriptionPlan) => {
|
||||
setSelectedPlan(plan);
|
||||
setEditForm({
|
||||
name: plan.name,
|
||||
description: plan.description ?? "",
|
||||
price: plan.price ?? undefined,
|
||||
duration_days: plan.duration_days ?? undefined,
|
||||
max_users_allowed: plan.max_users_allowed ?? undefined,
|
||||
is_public: plan.is_public,
|
||||
status: plan.status,
|
||||
});
|
||||
setEditAccessIds([]);
|
||||
setEditError("");
|
||||
setIsEditOpen(true);
|
||||
try {
|
||||
const detail = await subscriptionsApi.getById(plan.id);
|
||||
setEditAccessIds([...detail.access_ids, ...detail.module_access_ids]);
|
||||
} catch (err) {
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : "Failed to load plan details."
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openDelete = useCallback((plan: SubscriptionPlan) => {
|
||||
setSelectedPlan(plan);
|
||||
setDeleteError("");
|
||||
setIsDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeView = useCallback(() => {
|
||||
setIsViewOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setViewAccessIds([]);
|
||||
}, []);
|
||||
|
||||
const closeEdit = useCallback(() => {
|
||||
setIsEditOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setEditError("");
|
||||
}, []);
|
||||
|
||||
const closeDelete = useCallback(() => {
|
||||
setIsDeleteOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setDeleteError("");
|
||||
}, []);
|
||||
|
||||
const handleEditChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setEditForm((prev) => ({ ...prev, [name]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleUpdate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedPlan) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setEditError("");
|
||||
|
||||
try {
|
||||
const { accessIds, moduleAccessIds } = splitAccessIds(editAccessIds);
|
||||
|
||||
const payload: SubscriptionPlanUpdateRequest = {
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description.trim() || undefined,
|
||||
price: editForm.price ? Number(editForm.price) : undefined,
|
||||
duration_days: editForm.duration_days ? Number(editForm.duration_days) : undefined,
|
||||
max_users_allowed: editForm.max_users_allowed ?? undefined,
|
||||
is_public: editForm.is_public,
|
||||
status: editForm.status,
|
||||
access_ids: accessIds,
|
||||
module_access_ids: moduleAccessIds,
|
||||
};
|
||||
|
||||
const updated = await subscriptionsApi.update(selectedPlan.id, payload);
|
||||
setPlans((prev) =>
|
||||
prev.map((p) => (p.id === updated.id ? updated : p))
|
||||
);
|
||||
closeEdit();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to update plan.";
|
||||
setEditError(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedPlan) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setDeleteError("");
|
||||
|
||||
try {
|
||||
await subscriptionsApi.remove(selectedPlan.id);
|
||||
setPlans((prev) => prev.filter((p) => p.id !== selectedPlan.id));
|
||||
closeDelete();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to delete plan.";
|
||||
setDeleteError(message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<SubscriptionPlan>>>(
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
visibilityLabel: "Plan Name",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Plan Name
|
||||
<CustomColumnFilter
|
||||
title="Plan Name"
|
||||
options={buildColumnFilterOptions(nameCounts.entries())}
|
||||
selectedValues={nameFilter}
|
||||
sortDirection={activeSort.column === "name" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setNameFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "name", direction));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "price",
|
||||
header: "Price",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatPrice(row.price)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "duration_days",
|
||||
header: "Duration",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{row.duration_days ? `${row.duration_days} days` : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "max_users_allowed",
|
||||
header: "User Limit",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{row.max_users_allowed == null ? "Unlimited" : row.max_users_allowed}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
visibilityLabel: "Status",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
<CustomColumnFilter
|
||||
title="Status"
|
||||
options={[
|
||||
{ label: `Active (${planStatusCounts.get("active") ?? 0})`, value: "active" },
|
||||
{ label: `Inactive (${planStatusCounts.get("inactive") ?? 0})`, value: "inactive" },
|
||||
]}
|
||||
selectedValues={statusFilter}
|
||||
sortDirection={activeSort.column === "status" ? activeSort.direction : null}
|
||||
enableSearch={false}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setStatusFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "status", direction));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomStatus
|
||||
status={row.status === "active" ? "Active" : "Inactive"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_public",
|
||||
visibilityLabel: "Visibility",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Visibility
|
||||
<CustomColumnFilter
|
||||
title="Visibility"
|
||||
options={[
|
||||
{ label: `Public (${visibilityCounts.get("true") ?? 0})`, value: "true" },
|
||||
{ label: `Private (${visibilityCounts.get("false") ?? 0})`, value: "false" },
|
||||
]}
|
||||
selectedValues={visibilityFilter.map((value) => String(value))}
|
||||
sortDirection={activeSort.column === "is_public" ? activeSort.direction : null}
|
||||
enableSearch={false}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setVisibilityFilter(values.map((value) => value === "true"));
|
||||
setActiveSort(resolveColumnSortState(activeSort, "is_public", direction));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
row.is_public
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{row.is_public ? "Public" : "Private"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Created",
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(row.created_at)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "id",
|
||||
header: "Action",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomActionMenu>
|
||||
<ProtectedComponent requiredAccess="superadmin.plan.read">
|
||||
<CustomActionItem onClick={() => openView(row)}>
|
||||
<Eye size={16} className="mr-2" /> View Details
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="superadmin.plan.update">
|
||||
<CustomActionItem onClick={() => openEdit(row)}>
|
||||
<Edit2 size={16} className="mr-2" /> Edit Plan
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="superadmin.plan.delete">
|
||||
<CustomActionItem
|
||||
onClick={() => openDelete(row)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 size={16} className="mr-2" /> Delete Plan
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
activeSort,
|
||||
nameCounts,
|
||||
nameFilter,
|
||||
openDelete,
|
||||
openEdit,
|
||||
openView,
|
||||
planStatusCounts,
|
||||
statusFilter,
|
||||
visibilityCounts,
|
||||
visibilityFilter,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">Subscription Plans</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Create and manage different subscription tiers and their permissions.</p>
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="superadmin.plan.create">
|
||||
<Link to="/subscriptions/add">
|
||||
<CustomButton variant="primary">+ Add Plan</CustomButton>
|
||||
</Link>
|
||||
</ProtectedComponent>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<CustomTable
|
||||
isLoading={isLoading}
|
||||
data={plans}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="subscriptions-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
totalRows={totalRows}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Modal */}
|
||||
<CustomModal
|
||||
isOpen={isViewOpen}
|
||||
onClose={closeView}
|
||||
title="Plan Details"
|
||||
size="lg"
|
||||
footer={
|
||||
<CustomButton type="button" variant="outlined" onClick={closeView}>
|
||||
Close
|
||||
</CustomButton>
|
||||
}
|
||||
>
|
||||
{selectedPlan ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Name
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedPlan.name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Price
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatPrice(selectedPlan.price)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Duration
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedPlan.duration_days ? `${selectedPlan.duration_days} days` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
User Limit
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedPlan.max_users_allowed == null ? "Unlimited" : selectedPlan.max_users_allowed}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Status
|
||||
</p>
|
||||
<CustomStatus
|
||||
status={
|
||||
selectedPlan.status === "active" ? "Active" : "Inactive"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Visibility
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedPlan.is_public ? "Public" : "Private"}
|
||||
</p>
|
||||
</div>
|
||||
{selectedPlan.description && (
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedPlan.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Created
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(selectedPlan.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Updated
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(selectedPlan.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)] mb-2">
|
||||
Plan Permissions
|
||||
</p>
|
||||
{isDetailLoading ? (
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : (
|
||||
<GroupedAccessViewer accesses={viewAccesses} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
No plan selected.
|
||||
</p>
|
||||
)}
|
||||
</CustomModal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<CustomModal
|
||||
isOpen={isEditOpen}
|
||||
onClose={closeEdit}
|
||||
title="Edit Subscription Plan"
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<CustomButton
|
||||
type="button"
|
||||
variant="outlined"
|
||||
onClick={closeEdit}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="submit"
|
||||
form="edit-plan-form"
|
||||
variant="primary"
|
||||
loading={isSaving}
|
||||
>
|
||||
Save Changes
|
||||
</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="edit-plan-form"
|
||||
onSubmit={handleUpdate}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput
|
||||
label="Plan Name"
|
||||
name="name"
|
||||
placeholder="Enter plan name"
|
||||
value={editForm.name}
|
||||
onChange={handleEditChange}
|
||||
required
|
||||
/>
|
||||
<CustomInput
|
||||
label="Price"
|
||||
name="price"
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={editForm.price ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
price: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Duration (Days)"
|
||||
name="duration_days"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
value={editForm.duration_days ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
duration_days: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Max Allowed Users"
|
||||
name="max_users_allowed"
|
||||
type="number"
|
||||
placeholder="Unlimited"
|
||||
value={editForm.max_users_allowed ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
max_users_allowed: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Description"
|
||||
name="description"
|
||||
placeholder="Enter plan description"
|
||||
value={editForm.description}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
name="status"
|
||||
value={editForm.status}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, status: e.target.value }))
|
||||
}
|
||||
options={[
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Inactive", value: "inactive" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-end pb-1">
|
||||
<CustomCheckBox
|
||||
label="Publicly Visible"
|
||||
name="is_public"
|
||||
checked={editForm.is_public}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
is_public: e.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<GroupedAccessSelector
|
||||
allAccesses={allAccesses}
|
||||
selectedIds={editAccessIds}
|
||||
onChange={setEditAccessIds}
|
||||
isLoading={isAccessLoading}
|
||||
title="Plan Permissions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CustomModal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={closeDelete}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete subscription plan?"
|
||||
description={
|
||||
deleteError || "This plan will be permanently removed."
|
||||
}
|
||||
confirmText="Delete Plan"
|
||||
variant="danger"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllSubscriptions;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import AllSubscriptions from "./components/AllSubscriptions";
|
||||
import AddSubscriptions from "./components/AddSubscriptions";
|
||||
|
||||
const SubscriptionsRoutes: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<AllSubscriptions />} />
|
||||
<Route path="add" element={<AddSubscriptions />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
export default SubscriptionsRoutes;
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/features/Tenants/TenantsApi.ts
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import { buildQueryString } from "../../lib/queryParams";
|
||||
import type {
|
||||
ApiMessage,
|
||||
Tenant,
|
||||
@@ -8,18 +9,6 @@ import type {
|
||||
TenantUpdateRequest,
|
||||
} from "./TenantsTypes";
|
||||
|
||||
// Helper to build URL with query params
|
||||
const buildQueryString = (params: Record<string, any>): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const query = searchParams.toString();
|
||||
return query ? `?${query}` : "";
|
||||
};
|
||||
|
||||
export const tenantsApi = {
|
||||
getAll: () => apiClient.get<Tenant[]>("/api/tenant/get"),
|
||||
|
||||
@@ -43,12 +32,24 @@ export const tenantsApi = {
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
is_active?: boolean | null;
|
||||
filter_tenant_names?: string[];
|
||||
filter_tenant_domains?: string[];
|
||||
filter_plan_ids?: string[];
|
||||
statuses?: boolean[];
|
||||
sort_by?: "name" | "domain" | "status" | "plan";
|
||||
sort_order?: "asc" | "desc";
|
||||
}) => {
|
||||
const queryString = buildQueryString({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
search: params.search,
|
||||
is_active: params.is_active,
|
||||
filter_tenant_names: params.filter_tenant_names ?? undefined,
|
||||
filter_tenant_domains: params.filter_tenant_domains ?? undefined,
|
||||
filter_plan_ids: params.filter_plan_ids ?? undefined,
|
||||
statuses: params.statuses ?? undefined,
|
||||
sort_by: params.sort_by ?? undefined,
|
||||
sort_order: params.sort_order ?? undefined,
|
||||
});
|
||||
return apiClient.get<TenantPaginatedResponse>(
|
||||
`/api/tenant/list${queryString}`
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
export type TenantStatus = "ACTIVE" | "INACTIVE" | "EXPIRED";
|
||||
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url?: string | null;
|
||||
is_active: boolean;
|
||||
plan_id?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
status: TenantStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ModuleEnvironmentAssignment = {
|
||||
module_id: string;
|
||||
environment_slug: string;
|
||||
};
|
||||
|
||||
export type TenantCreateRequest = {
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url?: string;
|
||||
is_active?: boolean;
|
||||
plan_id: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: TenantStatus;
|
||||
module_environments?: ModuleEnvironmentAssignment[];
|
||||
};
|
||||
|
||||
export type TenantUpdateRequest = {
|
||||
@@ -20,6 +36,10 @@ export type TenantUpdateRequest = {
|
||||
tenant_domain?: string;
|
||||
tenant_logo_url?: string | null;
|
||||
is_active?: boolean;
|
||||
plan_id?: string;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
status?: TenantStatus;
|
||||
};
|
||||
|
||||
export type TenantPaginatedResponse = {
|
||||
@@ -32,4 +52,4 @@ export type TenantPaginatedResponse = {
|
||||
|
||||
export type ApiMessage = {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
@@ -1,27 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CustomButton, CustomInput } from "../../../components/custom";
|
||||
import { CustomButton, CustomDatePicker, CustomInput, CustomDropdown } from "../../../components/custom";
|
||||
import CustomBackButton from "../../../components/custom/CustomBackButton";
|
||||
import { CustomLoader } from "../../../components/custom";
|
||||
import { tenantsApi } from "../TenantsApi";
|
||||
import type { Tenant, TenantCreateRequest } from "../TenantsTypes";
|
||||
import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
||||
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
||||
import type { RoleAccess } from "../../roles/RolesTypes";
|
||||
import { adminModuleApi } from "../../modules/admin/AdminModuleApi";
|
||||
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
const toIsoDate = (value: Date) => value.toISOString().slice(0, 10);
|
||||
|
||||
const addDays = (dateValue: string, days?: number | null) => {
|
||||
if (!dateValue || !days) return "";
|
||||
const nextDate = new Date(`${dateValue}T00:00:00`);
|
||||
nextDate.setDate(nextDate.getDate() + days);
|
||||
return toIsoDate(nextDate);
|
||||
};
|
||||
|
||||
const AddTenants = () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
const canCreateTenant = hasAccess("superadmin.tenant.create");
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState<TenantCreateRequest>({
|
||||
tenant_name: "",
|
||||
tenant_domain: "",
|
||||
tenant_logo_url: "",
|
||||
});
|
||||
|
||||
const [tenantName, setTenantName] = useState("");
|
||||
const [tenantDomain, setTenantDomain] = useState("");
|
||||
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
|
||||
const [selectedPlanId, setSelectedPlanId] = useState("");
|
||||
const [startDate, setStartDate] = useState(today);
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [tenantStatus, setTenantStatus] = useState<TenantStatus>("ACTIVE");
|
||||
const [moduleEnvAssignments, setModuleEnvAssignments] = useState<ModuleEnvironmentAssignment[]>([]);
|
||||
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [isPlansLoading, setIsPlansLoading] = useState(false);
|
||||
|
||||
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
|
||||
|
||||
const [allModules, setAllModules] = useState<Module[]>([]);
|
||||
|
||||
const [moduleEnvironments, setModuleEnvironments] = useState<Record<string, ModuleEnvironment[]>>({});
|
||||
const [loadingEnvironments, setLoadingEnvironments] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [planModules, setPlanModules] = useState<{ module_id: string; module_name: string }[]>([]);
|
||||
const [isPlanModulesLoading, setIsPlanModulesLoading] = useState(false);
|
||||
|
||||
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
|
||||
const [isTenantLoading, setIsTenantLoading] = useState(true);
|
||||
const [tenantError, setTenantError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPlanId) return;
|
||||
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
|
||||
if (!selectedPlan) return;
|
||||
|
||||
setStartDate((prev) => prev || today);
|
||||
setEndDate(addDays(startDate || today, selectedPlan.duration_days));
|
||||
}, [plans, selectedPlanId, startDate, today]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading || canCreateTenant) {
|
||||
setIsTenantLoading(false);
|
||||
@@ -34,34 +77,133 @@ const AddTenants = () => {
|
||||
setIsTenantLoading(true);
|
||||
try {
|
||||
const data = await tenantsApi.getMine();
|
||||
if (isMounted) {
|
||||
setCurrentTenant(data);
|
||||
}
|
||||
if (isMounted) setCurrentTenant(data);
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load tenant.";
|
||||
setTenantError(message);
|
||||
setTenantError(
|
||||
error instanceof Error ? error.message : "Unable to load tenant."
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsTenantLoading(false);
|
||||
}
|
||||
if (isMounted) setIsTenantLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTenant();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
return () => { isMounted = false; };
|
||||
}, [canCreateTenant, isAuthLoading]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
useEffect(() => {
|
||||
if (!canCreateTenant) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
setIsPlansLoading(true);
|
||||
try {
|
||||
const [plansData, accessesData, modulesData] = await Promise.all([
|
||||
subscriptionsApi.getAll({ status: "active" }),
|
||||
subscriptionsApi.getAccesses(),
|
||||
adminModuleApi.listModules(),
|
||||
]);
|
||||
if (isMounted) {
|
||||
setPlans(plansData);
|
||||
setAllAccesses(accessesData);
|
||||
setAllModules(modulesData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load data:", error);
|
||||
} finally {
|
||||
if (isMounted) setIsPlansLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
return () => { isMounted = false; };
|
||||
}, [canCreateTenant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPlanId || allAccesses.length === 0 || allModules.length === 0) {
|
||||
setPlanModules([]);
|
||||
setModuleEnvAssignments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const resolvePlanModules = async () => {
|
||||
setIsPlanModulesLoading(true);
|
||||
try {
|
||||
const planDetail = await subscriptionsApi.getById(selectedPlanId);
|
||||
|
||||
const moduleAccessIdSet = new Set(planDetail.module_access_ids);
|
||||
const moduleIdsFromPlan = new Set<string>();
|
||||
|
||||
allAccesses.forEach((access) => {
|
||||
if (access.module_id && moduleAccessIdSet.has(access.id)) {
|
||||
moduleIdsFromPlan.add(access.module_id);
|
||||
}
|
||||
});
|
||||
|
||||
const moduleMap = new Map(allModules.map((m) => [m.id, m]));
|
||||
const resolvedModules: { module_id: string; module_name: string }[] = [];
|
||||
|
||||
moduleIdsFromPlan.forEach((modId) => {
|
||||
const mod = moduleMap.get(modId);
|
||||
if (mod) {
|
||||
resolvedModules.push({ module_id: mod.id, module_name: mod.module_name });
|
||||
}
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setPlanModules(resolvedModules);
|
||||
|
||||
const newAssignments: ModuleEnvironmentAssignment[] = [];
|
||||
|
||||
await Promise.all(
|
||||
resolvedModules.map(async (mod) => {
|
||||
if (!moduleEnvironments[mod.module_id]) {
|
||||
setLoadingEnvironments((prev) => ({ ...prev, [mod.module_id]: true }));
|
||||
try {
|
||||
const envs = await adminModuleApi.listEnvironments(mod.module_id);
|
||||
if (isMounted) {
|
||||
setModuleEnvironments((prev) => ({ ...prev, [mod.module_id]: envs }));
|
||||
const defaultEnv = envs.find((e) => e.is_default)?.slug || envs[0]?.slug || "";
|
||||
newAssignments.push({ module_id: mod.module_id, environment_slug: defaultEnv });
|
||||
}
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setModuleEnvironments((prev) => ({ ...prev, [mod.module_id]: [] }));
|
||||
newAssignments.push({ module_id: mod.module_id, environment_slug: "" });
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) setLoadingEnvironments((prev) => ({ ...prev, [mod.module_id]: false }));
|
||||
}
|
||||
} else {
|
||||
const envs = moduleEnvironments[mod.module_id];
|
||||
const defaultEnv = envs.find((e) => e.is_default)?.slug || envs[0]?.slug || "";
|
||||
newAssignments.push({ module_id: mod.module_id, environment_slug: defaultEnv });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (isMounted) setModuleEnvAssignments(newAssignments);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve plan modules:", error);
|
||||
} finally {
|
||||
if (isMounted) setIsPlanModulesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
resolvePlanModules();
|
||||
return () => { isMounted = false; };
|
||||
}, [selectedPlanId, allAccesses, allModules]);
|
||||
|
||||
const handleEnvironmentChange = (moduleId: string, slug: string) => {
|
||||
setModuleEnvAssignments((prev) =>
|
||||
prev.map((a) => (a.module_id === moduleId ? { ...a, environment_slug: slug } : a))
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
@@ -71,9 +213,14 @@ const AddTenants = () => {
|
||||
|
||||
try {
|
||||
const payload: TenantCreateRequest = {
|
||||
tenant_name: formData.tenant_name.trim(),
|
||||
tenant_domain: formData.tenant_domain.trim(),
|
||||
tenant_logo_url: formData.tenant_logo_url?.trim() || undefined,
|
||||
tenant_name: tenantName.trim(),
|
||||
tenant_domain: tenantDomain.trim(),
|
||||
tenant_logo_url: tenantLogoUrl.trim() || undefined,
|
||||
plan_id: selectedPlanId,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
status: tenantStatus,
|
||||
module_environments: moduleEnvAssignments.filter((a) => a.environment_slug),
|
||||
};
|
||||
|
||||
await tenantsApi.create(payload);
|
||||
@@ -92,16 +239,16 @@ const AddTenants = () => {
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomBackButton to="/tenants" tooltip="Back to Tenants" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
{canCreateTenant ? "Add Tenant" : "Tenant Details"}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{canCreateTenant
|
||||
? "Create a new tenant with domain and logo details."
|
||||
: "Your account is assigned to this tenant."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
{canCreateTenant ? "Add Tenant" : "Tenant Details"}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{canCreateTenant
|
||||
? "Create a new tenant with domain and subscription plan."
|
||||
: "Your account is assigned to this tenant."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,16 +299,16 @@ const AddTenants = () => {
|
||||
label="Tenant Name"
|
||||
name="tenant_name"
|
||||
placeholder="Enter tenant name"
|
||||
value={formData.tenant_name}
|
||||
onChange={handleChange}
|
||||
value={tenantName}
|
||||
onChange={(e) => setTenantName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<CustomInput
|
||||
label="Tenant Domain"
|
||||
name="tenant_domain"
|
||||
placeholder="example.com"
|
||||
value={formData.tenant_domain}
|
||||
onChange={handleChange}
|
||||
value={tenantDomain}
|
||||
onChange={(e) => setTenantDomain(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -170,19 +317,123 @@ const AddTenants = () => {
|
||||
label="Tenant Logo URL"
|
||||
name="tenant_logo_url"
|
||||
placeholder="https://"
|
||||
value={formData.tenant_logo_url}
|
||||
onChange={handleChange}
|
||||
value={tenantLogoUrl}
|
||||
onChange={(e) => setTenantLogoUrl(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
|
||||
<CustomDropdown
|
||||
label="Select Plan"
|
||||
name="plan_id"
|
||||
value={selectedPlanId}
|
||||
onChange={(e) => setSelectedPlanId(e.target.value)}
|
||||
options={plans.map((plan) => ({
|
||||
label: `${plan.name}${plan.price != null ? ` — ${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(plan.price)}` : ""}`,
|
||||
value: plan.id,
|
||||
}))}
|
||||
placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"}
|
||||
disabled={isPlansLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 border-t border-gray-200 pt-6 md:grid-cols-3">
|
||||
<CustomDatePicker
|
||||
label="Start Date"
|
||||
value={startDate}
|
||||
onChange={(e) => {
|
||||
const nextStartDate = e.target.value;
|
||||
setStartDate(nextStartDate);
|
||||
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
|
||||
setEndDate(addDays(nextStartDate, selectedPlan?.duration_days));
|
||||
}}
|
||||
/>
|
||||
<CustomDatePicker
|
||||
label="End Date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
min={startDate || undefined}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
value={tenantStatus}
|
||||
onChange={(e) => setTenantStatus(e.target.value as TenantStatus)}
|
||||
options={[
|
||||
{ label: "Active", value: "ACTIVE" },
|
||||
{ label: "Inactive", value: "INACTIVE" },
|
||||
{ label: "Expired", value: "EXPIRED" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Module Environment Assignment — appears after plan selection */}
|
||||
{selectedPlanId && (
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">Module Environments</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Select the environment for each module included in this plan.
|
||||
</p>
|
||||
|
||||
{isPlanModulesLoading ? (
|
||||
<div className="text-sm text-gray-500">Resolving plan modules...</div>
|
||||
) : planModules.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<AlertCircle size={16} />
|
||||
This plan has no module-level accesses configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{planModules.map((mod) => {
|
||||
const envs = moduleEnvironments[mod.module_id] || [];
|
||||
const isLoadingEnv = loadingEnvironments[mod.module_id];
|
||||
const assignment = moduleEnvAssignments.find((a) => a.module_id === mod.module_id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mod.module_id}
|
||||
className="p-4 rounded-lg border border-primary-200 bg-primary-50"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{mod.module_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<CustomDropdown
|
||||
label=""
|
||||
value={assignment?.environment_slug || ""}
|
||||
onChange={(e) => handleEnvironmentChange(mod.module_id, e.target.value)}
|
||||
options={envs.map((env) => ({
|
||||
label: `${env.slug}${env.is_default ? " (default)" : ""}`,
|
||||
value: env.slug,
|
||||
}))}
|
||||
placeholder={isLoadingEnv ? "Loading..." : "Select Environment"}
|
||||
disabled={isLoadingEnv || envs.length === 0}
|
||||
/>
|
||||
{envs.length === 0 && !isLoadingEnv && (
|
||||
<div className="text-xs text-red-500 mt-1">No environments found</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4">
|
||||
<CustomButton type="submit" variant="primary" disabled={isLoading} loading={isLoading}>
|
||||
Create Tenant
|
||||
Create & Provision Tenant
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
@@ -191,4 +442,4 @@ const AddTenants = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTenants;
|
||||
export default AddTenants;
|
||||
@@ -4,21 +4,44 @@ import { Edit2, Eye, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomColumnFilter,
|
||||
CustomConfirmationModal,
|
||||
CustomDatePicker,
|
||||
CustomDropdown,
|
||||
CustomInput,
|
||||
CustomModal,
|
||||
CustomTable,
|
||||
CustomStatus,
|
||||
CustomLoader,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
} from "../../../components/custom";
|
||||
import type { ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import type { Tenant, TenantUpdateRequest } from "../TenantsTypes";
|
||||
import {
|
||||
buildColumnFilterOptions,
|
||||
resolveColumnSortState,
|
||||
} from "../../../components/custom/CustomColumnFilter.utils";
|
||||
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
||||
import { formatDate } from "../../../lib/dateFormat";
|
||||
import type { Tenant, TenantStatus, TenantUpdateRequest } from "../TenantsTypes";
|
||||
import { tenantsApi } from "../TenantsApi";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
||||
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
resolveStoredTablePageSize,
|
||||
} from "../../../lib/tablePageSize";
|
||||
|
||||
const toIsoDate = (value: Date) => value.toISOString().slice(0, 10);
|
||||
|
||||
const addDays = (dateValue: string, days?: number | null) => {
|
||||
if (!dateValue || !days) return "";
|
||||
const nextDate = new Date(`${dateValue}T00:00:00`);
|
||||
nextDate.setDate(nextDate.getDate() + days);
|
||||
return toIsoDate(nextDate);
|
||||
};
|
||||
|
||||
// Local implementation of useDebounce
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
@@ -32,7 +55,6 @@ function useDebounce<T>(value: T, delay: number): T {
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// Local implementation of ProtectedComponent
|
||||
const ProtectedComponent: React.FC<{
|
||||
requiredAccess: string;
|
||||
children: React.ReactNode;
|
||||
@@ -44,32 +66,16 @@ const ProtectedComponent: React.FC<{
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string | null) => {
|
||||
if (!dateString) return "";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString;
|
||||
|
||||
// Check if the input likely contains specific time (ISO with T or explicit time chars)
|
||||
const hasTime = dateString.includes("T") || dateString.includes(":");
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
if (hasTime) {
|
||||
options.hour = "2-digit",
|
||||
options.minute = "2-digit",
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString("en-GB", options);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
interface EditFormState {
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url: string;
|
||||
is_active: boolean;
|
||||
plan_id: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
status: TenantStatus;
|
||||
}
|
||||
|
||||
const AllTenants = () => {
|
||||
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
@@ -81,34 +87,52 @@ const AllTenants = () => {
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
|
||||
const [editForm, setEditForm] = useState<EditFormState>({
|
||||
tenant_name: "",
|
||||
tenant_domain: "",
|
||||
tenant_logo_url: "",
|
||||
is_active: true,
|
||||
plan_id: "",
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
status: "ACTIVE",
|
||||
});
|
||||
|
||||
// Subscription plans for dropdown
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
|
||||
const [editError, setEditError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(() =>
|
||||
resolveStoredTablePageSize({
|
||||
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
defaultPageSize: 10,
|
||||
})
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tenantNameFilter, setTenantNameFilter] = useState<string[]>([]);
|
||||
const [tenantDomainFilter, setTenantDomainFilter] = useState<string[]>([]);
|
||||
const [planFilter, setPlanFilter] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<boolean[]>([]);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [, setTotalPages] = useState(0);
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: "tenant_name" | "tenant_domain" | "plan_id" | "is_active" | null;
|
||||
direction: ColumnSortDirection;
|
||||
}>({ column: null, direction: null });
|
||||
const [allTenantsForCounts, setAllTenantsForCounts] = useState<Tenant[]>([]);
|
||||
|
||||
// Status filter state
|
||||
const [statusFilter, setStatusFilter] = useState<boolean | null>(null);
|
||||
|
||||
// Ref for the search input to restore focus
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track previous loading state to detect transition from loading → idle
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
const latestTenantsRequestRef = useRef(0);
|
||||
|
||||
// Debounce search to avoid excessive API calls
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -124,21 +148,34 @@ const AllTenants = () => {
|
||||
|
||||
try {
|
||||
if (canReadAll) {
|
||||
// Use paginated API for server-side pagination
|
||||
const requestId = ++latestTenantsRequestRef.current;
|
||||
const response = await tenantsApi.getPaginated({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: debouncedSearch || undefined,
|
||||
is_active: statusFilter,
|
||||
filter_tenant_names: tenantNameFilter,
|
||||
filter_tenant_domains: tenantDomainFilter,
|
||||
filter_plan_ids: planFilter,
|
||||
statuses: statusFilter,
|
||||
sort_by:
|
||||
activeSort.column === "tenant_name"
|
||||
? "name"
|
||||
: activeSort.column === "tenant_domain"
|
||||
? "domain"
|
||||
: activeSort.column === "plan_id"
|
||||
? "plan"
|
||||
: activeSort.column === "is_active"
|
||||
? "status"
|
||||
: undefined,
|
||||
sort_order: activeSort.direction ?? undefined,
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
if (isMounted && requestId === latestTenantsRequestRef.current) {
|
||||
setTenants(response.items);
|
||||
setTotalRows(response.total);
|
||||
setTotalPages(response.total_pages);
|
||||
}
|
||||
} else {
|
||||
// For non-superadmin, show only their tenant
|
||||
const data = await tenantsApi.getMine();
|
||||
if (isMounted) {
|
||||
setTenants([data]);
|
||||
@@ -166,9 +203,19 @@ const AllTenants = () => {
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [canReadAll, isAuthLoading, page, pageSize, debouncedSearch, statusFilter]);
|
||||
}, [
|
||||
activeSort,
|
||||
canReadAll,
|
||||
debouncedSearch,
|
||||
isAuthLoading,
|
||||
page,
|
||||
pageSize,
|
||||
planFilter,
|
||||
statusFilter,
|
||||
tenantDomainFilter,
|
||||
tenantNameFilter,
|
||||
]);
|
||||
|
||||
// Restore focus to search input after loading completes (only if user was searching)
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim() !== "") {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
@@ -176,10 +223,86 @@ const AllTenants = () => {
|
||||
prevLoadingRef.current = isLoading;
|
||||
}, [isLoading, search]);
|
||||
|
||||
// Reset to page 1 when search or status filter changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, statusFilter]);
|
||||
}, [debouncedSearch, tenantNameFilter, tenantDomainFilter, planFilter, statusFilter, activeSort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReadAll) return;
|
||||
|
||||
let isMounted = true;
|
||||
const loadAllTenants = async () => {
|
||||
try {
|
||||
const data = await tenantsApi.getAll();
|
||||
if (isMounted) setAllTenantsForCounts(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load tenants for filters:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllTenants();
|
||||
return () => { isMounted = false; };
|
||||
}, [canReadAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReadAll && !hasAccess("superadmin.tenant.update")) return;
|
||||
|
||||
let isMounted = true;
|
||||
const loadPlans = async () => {
|
||||
try {
|
||||
const data = await subscriptionsApi.getAll();
|
||||
if (isMounted) setPlans(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load subscription plans:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadPlans();
|
||||
return () => { isMounted = false; };
|
||||
}, [canReadAll, hasAccess]);
|
||||
|
||||
const getPlanName = useCallback(
|
||||
(planId?: string | null) => {
|
||||
if (!planId) return "-";
|
||||
const found = plans.find((p) => p.id === planId);
|
||||
return found ? found.name : "-";
|
||||
},
|
||||
[plans]
|
||||
);
|
||||
|
||||
const tenantNameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allTenantsForCounts.forEach((tenant) => {
|
||||
counts.set(tenant.tenant_name, (counts.get(tenant.tenant_name) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allTenantsForCounts]);
|
||||
|
||||
const tenantDomainCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allTenantsForCounts.forEach((tenant) => {
|
||||
counts.set(tenant.tenant_domain, (counts.get(tenant.tenant_domain) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allTenantsForCounts]);
|
||||
|
||||
const planCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allTenantsForCounts.forEach((tenant) => {
|
||||
if (!tenant.plan_id) return;
|
||||
counts.set(tenant.plan_id, (counts.get(tenant.plan_id) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allTenantsForCounts]);
|
||||
|
||||
const tenantStatusCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
allTenantsForCounts.forEach((tenant) => {
|
||||
const key = tenant.is_active ? "true" : "false";
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
});
|
||||
return counts;
|
||||
}, [allTenantsForCounts]);
|
||||
|
||||
const openView = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
@@ -188,14 +311,19 @@ const AllTenants = () => {
|
||||
|
||||
const openEdit = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setIsEditOpen(true);
|
||||
setEditError("");
|
||||
|
||||
setEditForm({
|
||||
tenant_name: tenant.tenant_name,
|
||||
tenant_domain: tenant.tenant_domain,
|
||||
tenant_logo_url: tenant.tenant_logo_url ?? "",
|
||||
is_active: tenant.is_active,
|
||||
plan_id: tenant.plan_id ?? "",
|
||||
start_date: tenant.start_date ?? "",
|
||||
end_date: tenant.end_date ?? "",
|
||||
status: tenant.status,
|
||||
});
|
||||
setEditError("");
|
||||
setIsEditOpen(true);
|
||||
}, []);
|
||||
|
||||
const openDelete = useCallback((tenant: Tenant) => {
|
||||
@@ -240,6 +368,8 @@ const AllTenants = () => {
|
||||
event.preventDefault();
|
||||
if (!selectedTenant) return;
|
||||
|
||||
const tenantId = selectedTenant.id;
|
||||
|
||||
setEditError("");
|
||||
setIsSaving(true);
|
||||
|
||||
@@ -249,17 +379,27 @@ const AllTenants = () => {
|
||||
tenant_domain: editForm.tenant_domain.trim(),
|
||||
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
|
||||
is_active: editForm.is_active,
|
||||
plan_id: editForm.plan_id || undefined,
|
||||
start_date: editForm.start_date || null,
|
||||
end_date: editForm.end_date || null,
|
||||
status: editForm.status,
|
||||
};
|
||||
|
||||
const updatedTenant = await tenantsApi.update(selectedTenant.id, payload);
|
||||
const updatedTenant = await tenantsApi.update(tenantId, payload);
|
||||
|
||||
if (!updatedTenant || !updatedTenant.id) {
|
||||
throw new Error("Invalid response from server");
|
||||
}
|
||||
|
||||
setTenants((prev) =>
|
||||
prev.map((tenant) =>
|
||||
tenant.id === updatedTenant.id ? updatedTenant : tenant
|
||||
tenant.id === tenantId ? updatedTenant : tenant
|
||||
)
|
||||
);
|
||||
setIsEditOpen(false);
|
||||
setSelectedTenant(null);
|
||||
} catch (error) {
|
||||
console.error("Update failed:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to update tenant.";
|
||||
setEditError(message);
|
||||
@@ -271,17 +411,20 @@ const AllTenants = () => {
|
||||
const handleDelete = async () => {
|
||||
if (!selectedTenant) return;
|
||||
|
||||
const tenantId = selectedTenant.id;
|
||||
|
||||
setDeleteError("");
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await tenantsApi.remove(selectedTenant.id);
|
||||
await tenantsApi.remove(tenantId);
|
||||
setTenants((prev) =>
|
||||
prev.filter((tenant) => tenant.id !== selectedTenant.id)
|
||||
prev.filter((tenant) => tenant.id !== tenantId)
|
||||
);
|
||||
setIsDeleteOpen(false);
|
||||
setSelectedTenant(null);
|
||||
} catch (error) {
|
||||
console.error("Delete failed:", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to delete tenant.";
|
||||
setDeleteError(message);
|
||||
@@ -292,8 +435,50 @@ const AllTenants = () => {
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<Tenant>>>(
|
||||
() => [
|
||||
{ key: "tenant_name", header: "Tenant Name" },
|
||||
{ key: "tenant_domain", header: "Domain" },
|
||||
{
|
||||
key: "tenant_name",
|
||||
visibilityLabel: "Tenant Name",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Tenant Name
|
||||
{canReadAll ? (
|
||||
<CustomColumnFilter
|
||||
title="Tenant Name"
|
||||
options={buildColumnFilterOptions(tenantNameCounts.entries())}
|
||||
selectedValues={tenantNameFilter}
|
||||
sortDirection={activeSort.column === "tenant_name" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setTenantNameFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "tenant_name", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tenant_domain",
|
||||
visibilityLabel: "Domain",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Domain
|
||||
{canReadAll ? (
|
||||
<CustomColumnFilter
|
||||
title="Domain"
|
||||
options={buildColumnFilterOptions(tenantDomainCounts.entries())}
|
||||
selectedValues={tenantDomainFilter}
|
||||
sortDirection={activeSort.column === "tenant_domain" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setTenantDomainFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "tenant_domain", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tenant_logo_url",
|
||||
header: "Logo",
|
||||
@@ -310,11 +495,84 @@ const AllTenants = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
header: "Status",
|
||||
key: "plan_id",
|
||||
visibilityLabel: "Plan",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Plan
|
||||
{canReadAll && plans.length > 0 ? (
|
||||
<CustomColumnFilter
|
||||
title="Plan"
|
||||
options={plans.map((plan) => ({
|
||||
label: `${plan.name} (${planCounts.get(plan.id) ?? 0})`,
|
||||
value: plan.id,
|
||||
}))}
|
||||
selectedValues={planFilter}
|
||||
sortDirection={activeSort.column === "plan_id" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setPlanFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "plan_id", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomStatus status={row.is_active ? "Active" : "Inactive"} />
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{getPlanName(row.plan_id)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
visibilityLabel: "Status",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
{canReadAll ? (
|
||||
<CustomColumnFilter
|
||||
title="Status"
|
||||
options={[
|
||||
{ label: `Active (${tenantStatusCounts.get("true") ?? 0})`, value: "true" },
|
||||
{ label: `Inactive (${tenantStatusCounts.get("false") ?? 0})`, value: "false" },
|
||||
]}
|
||||
selectedValues={statusFilter.map((value) => String(value))}
|
||||
sortDirection={activeSort.column === "is_active" ? activeSort.direction : null}
|
||||
enableSearch={false}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setStatusFilter(values.map((value) => value === "true"));
|
||||
setActiveSort(resolveColumnSortState(activeSort, "is_active", direction));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomStatus status={row.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "start_date",
|
||||
header: "Start Date",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{row.start_date ? formatDate(row.start_date) : "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "end_date",
|
||||
header: "End Date",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{row.end_date ? formatDate(row.end_date) : "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -362,35 +620,31 @@ const AllTenants = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[openDelete, openEdit, openView]
|
||||
[
|
||||
activeSort,
|
||||
canReadAll,
|
||||
getPlanName,
|
||||
openDelete,
|
||||
openEdit,
|
||||
openView,
|
||||
planCounts,
|
||||
planFilter,
|
||||
plans,
|
||||
statusFilter,
|
||||
tenantDomainCounts,
|
||||
tenantDomainFilter,
|
||||
tenantNameCounts,
|
||||
tenantNameFilter,
|
||||
tenantStatusCounts,
|
||||
]
|
||||
);
|
||||
|
||||
// Status filter control
|
||||
const filterControls = canReadAll ? (
|
||||
<select
|
||||
value={statusFilter === null ? "all" : statusFilter ? "active" : "inactive"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setStatusFilter(
|
||||
value === "all" ? null : value === "active" ? true : false
|
||||
);
|
||||
}}
|
||||
className="rounded-md border border-(--card-border) bg-(--card-bg) text-(--text-primary) text-sm py-1.5 px-2 focus:outline-none focus:ring focus:ring-blue-600"
|
||||
>
|
||||
<option value="all" className="bg-(--card-bg) text-(--text-primary)">All Statuses</option>
|
||||
<option value="active" className="bg-(--card-bg) text-(--text-primary)">Active</option>
|
||||
<option value="inactive" className="bg-(--card-bg) text-(--text-primary)">Inactive</option>
|
||||
</select>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">Tenants</h1>
|
||||
{/* <p className="text-sm text-[var(--text-secondary)]">
|
||||
{totalRows} Tenant{totalRows === 1 ? "" : "s"} in total
|
||||
</p> */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">Tenants</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Manage and monitor all tenant accounts in the system.</p>
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="superadmin.tenant.create">
|
||||
<Link to="/tenants/add">
|
||||
@@ -399,18 +653,19 @@ const AllTenants = () => {
|
||||
</ProtectedComponent>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
|
||||
<CustomLoader />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<CustomTable
|
||||
isLoading={isLoading}
|
||||
data={tenants}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="tenants-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination={canReadAll}
|
||||
manualFiltering={canReadAll}
|
||||
@@ -422,7 +677,6 @@ const AllTenants = () => {
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
filterControls={filterControls}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -469,12 +723,42 @@ const AllTenants = () => {
|
||||
<p className="text-sm text-[var(--text-secondary)]"></p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Tenant ID
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] break-all">
|
||||
{selectedTenant.tenant_id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Status
|
||||
</p>
|
||||
<CustomStatus status={selectedTenant.status} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Subscription Plan
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.is_active ? "Active" : "Inactive"}
|
||||
{getPlanName(selectedTenant.plan_id)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Start Date
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.start_date ? formatDate(selectedTenant.start_date) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
End Date
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.end_date ? formatDate(selectedTenant.end_date) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -558,12 +842,6 @@ const AllTenants = () => {
|
||||
/>
|
||||
|
||||
<div className="flex mt-6 items-center">
|
||||
{/* Checkbox component not found in standard custom, using explicit input or different component if needed,
|
||||
but keeping CustomSwitch or similar logic is safer. User had CustomCheckBox.
|
||||
I'll assume I need to replace it with simple input or use CustomSwitch if I saw it.
|
||||
I saw CustomSwitch in my files but not necessarily in the user's previous code unless I missed it.
|
||||
Actually, CustomCheckBox was imported from "Custom". I have "CustomSwitch".
|
||||
Let's use CustomSwitch for "is_active" as it's cleaner. */}
|
||||
<CustomCheckBox
|
||||
label="Active"
|
||||
name="is_active"
|
||||
@@ -572,6 +850,73 @@ const AllTenants = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
|
||||
<CustomDropdown
|
||||
label="Select Plan"
|
||||
name="plan_id"
|
||||
value={editForm.plan_id}
|
||||
onChange={(e) => {
|
||||
const nextPlanId = e.target.value;
|
||||
const selectedPlan = plans.find((plan) => plan.id === nextPlanId);
|
||||
const nextStartDate = editForm.start_date || toIsoDate(new Date());
|
||||
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
plan_id: nextPlanId,
|
||||
start_date: nextStartDate,
|
||||
end_date: addDays(nextStartDate, selectedPlan?.duration_days),
|
||||
}));
|
||||
}}
|
||||
options={plans.map((plan) => ({
|
||||
label: plan.duration_days
|
||||
? `${plan.name} (${plan.duration_days} days)`
|
||||
: plan.name,
|
||||
value: plan.id,
|
||||
}))}
|
||||
placeholder="Select a subscription plan"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 border-t border-gray-200 pt-6 md:grid-cols-3">
|
||||
<CustomDatePicker
|
||||
label="Start Date"
|
||||
value={editForm.start_date}
|
||||
onChange={(e) => {
|
||||
const nextStartDate = e.target.value;
|
||||
const selectedPlan = plans.find((plan) => plan.id === editForm.plan_id);
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
start_date: nextStartDate,
|
||||
end_date: addDays(nextStartDate, selectedPlan?.duration_days),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<CustomDatePicker
|
||||
label="End Date"
|
||||
value={editForm.end_date}
|
||||
min={editForm.start_date || undefined}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, end_date: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
value={editForm.status}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
status: e.target.value as TenantStatus,
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ label: "Active", value: "ACTIVE" },
|
||||
{ label: "Inactive", value: "INACTIVE" },
|
||||
{ label: "Expired", value: "EXPIRED" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{editError}
|
||||
@@ -596,4 +941,4 @@ const AllTenants = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AllTenants;
|
||||
export default AllTenants;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import type { ColorPalette } from "./ThemeTypes";
|
||||
import type { ColorPalette, ColorPalettePayload } from "./ThemeTypes";
|
||||
|
||||
export const paletteApi = {
|
||||
getAllPalettes: async () => {
|
||||
@@ -10,11 +10,11 @@ export const paletteApi = {
|
||||
return await apiClient.get<ColorPalette>(`/api/theme/get/${id}`);
|
||||
},
|
||||
|
||||
createPalette: async (paletteData: any) => {
|
||||
createPalette: async (paletteData: ColorPalettePayload) => {
|
||||
return await apiClient.post<ColorPalette>("/api/theme/create", paletteData);
|
||||
},
|
||||
|
||||
updatePalette: async (id: string, paletteData: any) => {
|
||||
updatePalette: async (id: string, paletteData: ColorPalettePayload) => {
|
||||
return await apiClient.put<ColorPalette>(`/api/theme/update/${id}`, paletteData);
|
||||
},
|
||||
|
||||
|
||||
@@ -42,5 +42,12 @@ export interface ColorPalette {
|
||||
tenant_id?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
[key: string]: any; // Allow for other properties and CustomTable compatibility
|
||||
[key: string]: unknown; // Allow for other properties and CustomTable compatibility
|
||||
}
|
||||
|
||||
export interface ColorPalettePayload {
|
||||
name: string;
|
||||
description?: string;
|
||||
is_default: boolean;
|
||||
colors: ColorSet;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { Plus, Edit2, Trash2 } from "lucide-react";
|
||||
import { toast } from "react-toastify";
|
||||
import {CustomButton} from "../../../components/custom";
|
||||
import { CustomButton } from "../../../components/custom";
|
||||
import DataTable, { type ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import { CustomActionMenu, CustomActionItem, CustomStatus } from "../../../components/custom";
|
||||
import { Loader } from "../../../components/custom/CustomLoader";
|
||||
import { CustomActionMenu, CustomActionItem, CustomStatus, CustomConfirmationModal } from "../../../components/custom";
|
||||
|
||||
import { paletteApi } from "../PaletteApi";
|
||||
import type { ColorPalette } from "../ThemeTypes";
|
||||
import type { ColorPalette, ColorPalettePayload } from "../ThemeTypes";
|
||||
import PaletteForm from "./PaletteForm";
|
||||
import { useTheme } from "../../../context/ThemeContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const AllPalettes: React.FC = () => {
|
||||
const { t } = useTranslation(['theme', 'common']);
|
||||
const [palettes, setPalettes] = useState<ColorPalette[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingPalette, setEditingPalette] = useState<ColorPalette | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const { refreshTheme } = useTheme();
|
||||
|
||||
const fetchPalettes = async () => {
|
||||
const fetchPalettes = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const data = await paletteApi.getAllPalettes();
|
||||
setPalettes(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to fetch palettes";
|
||||
const message = error instanceof Error ? error.message : t('theme:errors.fetchFailed');
|
||||
setErrorMessage(message);
|
||||
console.error("Failed to fetch palettes", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPalettes();
|
||||
}, []);
|
||||
}, [fetchPalettes]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingPalette(null);
|
||||
@@ -47,26 +50,32 @@ const AllPalettes: React.FC = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Are you sure you want to delete this palette?")) return;
|
||||
const handleDelete = (id: string, isDefault: boolean) => {
|
||||
if (isDefault) return;
|
||||
setDeleteId(id);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
try {
|
||||
await paletteApi.deletePalette(id);
|
||||
toast.success("Palette deleted successfully");
|
||||
await paletteApi.deletePalette(deleteId);
|
||||
fetchPalettes();
|
||||
} catch (error) {
|
||||
// toast handled by api client
|
||||
console.error("Failed to delete palette", error);
|
||||
} finally {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
const handleSubmit = async (data: ColorPalettePayload) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (editingPalette) {
|
||||
await paletteApi.updatePalette(editingPalette.id, data);
|
||||
toast.success("Palette updated successfully");
|
||||
} else {
|
||||
await paletteApi.createPalette(data);
|
||||
toast.success("Palette created successfully");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchPalettes();
|
||||
@@ -81,7 +90,7 @@ const AllPalettes: React.FC = () => {
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
header: "Name",
|
||||
header: t('theme:table.name'),
|
||||
searchable: true,
|
||||
render: (row) => (
|
||||
<div>
|
||||
@@ -94,47 +103,47 @@ const AllPalettes: React.FC = () => {
|
||||
},
|
||||
{
|
||||
key: "is_default",
|
||||
header: "Default",
|
||||
header: t('theme:table.default'),
|
||||
render: (row) => (
|
||||
row.is_default ? (
|
||||
<CustomStatus status="Default" variant="success" />
|
||||
<CustomStatus status={t('common:status.default')} variant="success" />
|
||||
) : null
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "colors",
|
||||
header: "Preview",
|
||||
header: t('theme:table.preview'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-(--card-border) shadow-sm"
|
||||
style={{ backgroundColor: row.colors.primary }}
|
||||
title="Primary"
|
||||
title={t('theme:colors.primary')}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-(--card-border) shadow-sm"
|
||||
style={{ backgroundColor: row.colors.sidebar_bg }}
|
||||
title="Sidebar"
|
||||
title={t('theme:colors.sidebar')}
|
||||
/>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border border-(--card-border) shadow-sm"
|
||||
style={{ backgroundColor: row.colors.header_bg }}
|
||||
title="Header"
|
||||
title={t('theme:colors.header')}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Actions",
|
||||
header: t('common:columns.actions'),
|
||||
render: (row) => (
|
||||
<CustomActionMenu>
|
||||
<CustomActionItem onClick={() => handleEdit(row)}>
|
||||
<Edit2 size={16} className="mr-2" /> Edit Palette
|
||||
<Edit2 size={16} className="mr-2" /> {t('theme:actions.edit')}
|
||||
</CustomActionItem>
|
||||
|
||||
<CustomActionItem
|
||||
onClick={() => handleDelete(row.id)}
|
||||
onClick={() => handleDelete(row.id, row.is_default)}
|
||||
className={`${row.is_default ? "opacity-50 cursor-not-allowed" : "text-red-600 hover:text-red-700 hover:bg-red-50"}`}
|
||||
>
|
||||
<div
|
||||
@@ -146,57 +155,70 @@ const AllPalettes: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} className="mr-2" /> Delete Palette
|
||||
<Trash2 size={16} className="mr-2" /> {t('theme:actions.delete')}
|
||||
</div>
|
||||
</CustomActionItem>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-(--text-primary)">Theme Palettes</h1>
|
||||
{/* <p className="text-sm text-(--text-secondary) mt-1">
|
||||
Manage system color themes and default palettes.
|
||||
</p> */}
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">{t('theme:title')}</h1>
|
||||
<p className="text-sm text-(--text-secondary)">
|
||||
Manage system color themes and default palettes.
|
||||
</p>
|
||||
</div>
|
||||
<CustomButton onClick={handleCreate} leftIcon={<Plus size={18} />}>
|
||||
{t('theme:actions.create')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
<CustomButton onClick={handleCreate} leftIcon={<Plus size={18} />}>
|
||||
Create Palette
|
||||
</CustomButton>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
isLoading={isLoading}
|
||||
data={palettes}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="palettes-table-columns"
|
||||
exportEnabled={true}
|
||||
enableSearchDropdown={true}
|
||||
search="name"
|
||||
buildSuggestionLabel={(row) => row.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PaletteForm
|
||||
isOpen={isModalOpen}
|
||||
initialData={editingPalette}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
|
||||
<Loader />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={palettes}
|
||||
columns={columns}
|
||||
enableSearchDropdown={true}
|
||||
search="name"
|
||||
/>
|
||||
)}
|
||||
|
||||
<PaletteForm
|
||||
isOpen={isModalOpen}
|
||||
initialData={editingPalette}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
<CustomConfirmationModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
title={t('theme:deleteModal.title')}
|
||||
description={t('theme:deleteModal.message')}
|
||||
confirmText={t('theme:deleteModal.confirm')}
|
||||
cancelText={t('theme:deleteModal.cancel')}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllPalettes;
|
||||
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CustomModal from "../../../components/custom/CustomModal";
|
||||
import CustomInput from "../../../components/custom/CustomInput";
|
||||
import {CustomButton} from "../../../components/custom";
|
||||
import type { ColorPalette } from "../ThemeTypes";
|
||||
import type { ColorPalette, ColorPalettePayload, ColorSet } from "../ThemeTypes";
|
||||
|
||||
interface PaletteFormProps {
|
||||
initialData?: ColorPalette | null;
|
||||
onSubmit: (data: any) => Promise<void>;
|
||||
onSubmit: (data: ColorPalettePayload) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const colorFields = [
|
||||
{ label: "Primary Color", key: "primary" },
|
||||
{ label: "Primary Hover", key: "primary_hover" },
|
||||
{ label: "Sidebar Background", key: "sidebar_bg" },
|
||||
{ label: "Sidebar Text", key: "sidebar_text" },
|
||||
{ label: "Sidebar Text Muted", key: "sidebar_text_muted" },
|
||||
{ label: "Sidebar Active BG", key: "sidebar_active_bg" },
|
||||
{ label: "Sidebar Active Text", key: "sidebar_active_text" },
|
||||
{ label: "Sidebar Hover BG", key: "sidebar_hover_bg" },
|
||||
{ label: "Sidebar Border", key: "sidebar_border" },
|
||||
{ label: "Header Background", key: "header_bg" },
|
||||
{ label: "Header Text", key: "header_text" },
|
||||
{ label: "Header Border", key: "header_border" },
|
||||
{ label: "App Background", key: "background" },
|
||||
{ label: "App Secondary BG", key: "background_secondary" },
|
||||
{ label: "Text Primary", key: "text_primary" },
|
||||
{ label: "Text Secondary", key: "text_secondary" },
|
||||
{ label: "Card Background", key: "card_bg" },
|
||||
{ label: "Card Border", key: "card_border" },
|
||||
{ label: "Table Header BG", key: "table_header_bg" },
|
||||
{ label: "Table Row Hover", key: "table_row_hover" },
|
||||
{ label: "Table Border", key: "table_border" },
|
||||
];
|
||||
{ key: "primary" },
|
||||
{ key: "primary_hover" },
|
||||
{ key: "sidebar_bg" },
|
||||
{ key: "sidebar_text" },
|
||||
{ key: "sidebar_text_muted" },
|
||||
{ key: "sidebar_active_bg" },
|
||||
{ key: "sidebar_active_text" },
|
||||
{ key: "sidebar_hover_bg" },
|
||||
{ key: "sidebar_border" },
|
||||
{ key: "header_bg" },
|
||||
{ key: "header_text" },
|
||||
{ key: "header_border" },
|
||||
{ key: "background" },
|
||||
{ key: "background_secondary" },
|
||||
{ key: "text_primary" },
|
||||
{ key: "text_secondary" },
|
||||
{ key: "card_bg" },
|
||||
{ key: "card_border" },
|
||||
{ key: "table_header_bg" },
|
||||
{ key: "table_row_hover" },
|
||||
{ key: "table_border" },
|
||||
] as const satisfies Array<{ key: keyof ColorSet }>;
|
||||
|
||||
const emptyColors = colorFields.reduce((acc, field) => {
|
||||
acc[field.key] = "#000000";
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}, {} as ColorSet);
|
||||
|
||||
const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
initialData,
|
||||
@@ -50,6 +51,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
isLoading,
|
||||
isOpen,
|
||||
}) => {
|
||||
const { t } = useTranslation(['theme', 'common']);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@@ -57,7 +59,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
} = useForm<ColorPalettePayload>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -66,6 +68,8 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const watchedColors = watch("colors");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
reset({
|
||||
@@ -84,7 +88,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
}
|
||||
}, [initialData, isOpen, reset]);
|
||||
|
||||
const handleFormSubmit = async (data: any) => {
|
||||
const handleFormSubmit = async (data: ColorPalettePayload) => {
|
||||
await onSubmit(data);
|
||||
};
|
||||
|
||||
@@ -96,7 +100,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
{t('theme:actions.cancel')}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="submit"
|
||||
@@ -105,7 +109,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
loading={isLoading}
|
||||
leftIcon={<Check size={16} />}
|
||||
>
|
||||
{initialData ? "Update Palette" : "Create Palette"}
|
||||
{initialData ? t('theme:actions.update') : t('theme:actions.create')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
);
|
||||
@@ -114,7 +118,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
<CustomModal
|
||||
isOpen={isOpen}
|
||||
onClose={onCancel}
|
||||
title={initialData ? "Edit Palette" : "Create New Palette"}
|
||||
title={initialData ? t('theme:form.title.edit') : t('theme:form.title.create')}
|
||||
size="lg"
|
||||
footer={footer}
|
||||
>
|
||||
@@ -126,15 +130,15 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<CustomInput
|
||||
label="Palette Name"
|
||||
{...register("name", { required: "Name is required" })}
|
||||
label={t('theme:form.fields.name.label')}
|
||||
{...register("name", { required: t('theme:form.fields.name.required') })}
|
||||
error={errors.name?.message as string}
|
||||
placeholder="e.g. Dark Mode"
|
||||
placeholder={t('theme:form.fields.name.placeholder')}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Description"
|
||||
label={t('theme:form.fields.description.label')}
|
||||
{...register("description")}
|
||||
placeholder="Optional description"
|
||||
placeholder={t('theme:form.fields.description.placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -146,18 +150,18 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="is_default" className="text-sm font-medium text-gray-700">
|
||||
Set as Default Palette
|
||||
{t('theme:form.fields.isDefault')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Color Configuration
|
||||
{t('theme:form.fields.sectionTitle')}
|
||||
</h3>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto pr-2 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{colorFields.map((field) => {
|
||||
const currentColor = watch(`colors.${field.key}` as any);
|
||||
const currentColor = watchedColors[field.key];
|
||||
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-2">
|
||||
@@ -167,7 +171,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
value={currentColor}
|
||||
onChange={(e) => {
|
||||
setValue(
|
||||
`colors.${field.key}` as any,
|
||||
`colors.${field.key}`,
|
||||
e.target.value,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
@@ -175,9 +179,9 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
/>
|
||||
|
||||
<CustomInput
|
||||
label={field.label}
|
||||
{...register(`colors.${field.key}` as any, {
|
||||
required: "Required",
|
||||
label={t(`theme:colors.${field.key}`)}
|
||||
{...register(`colors.${field.key}`, {
|
||||
required: t('theme:form.fields.required'),
|
||||
})}
|
||||
placeholder="#000000"
|
||||
containerClassName="flex-1 mb-0"
|
||||
@@ -192,4 +196,4 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default PaletteForm;
|
||||
export default PaletteForm;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { apiClient } from "../../lib/apiClient";
|
||||
import { buildQueryString } from "../../lib/queryParams";
|
||||
import type {
|
||||
ApiMessage,
|
||||
User,
|
||||
UserApiResponse,
|
||||
UserCreateRequest,
|
||||
UserStatus,
|
||||
UserUpdateRequest,
|
||||
UserPaginatedResponse,
|
||||
} from "./UserTypes";
|
||||
|
||||
const normalizeStatus = (status?: string): UserStatus =>
|
||||
status?.toLowerCase() === "inactive" ? "inactive" : "active";
|
||||
|
||||
const normalizeId = (value?: string | null) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const toUser = (user: UserApiResponse): User => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
password: "",
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name ?? null,
|
||||
phone_number: user.phone_number ?? null,
|
||||
status: normalizeStatus(user.status),
|
||||
tenant_id: user.tenant_id ?? null,
|
||||
tenant_name: null,
|
||||
role_id: user.role_id ?? null,
|
||||
role_name: null,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
password_updated_at: user.password_updated_at ?? user.updated_at,
|
||||
});
|
||||
|
||||
const toCreatePayload = (payload: UserCreateRequest) => ({
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
first_name: payload.first_name,
|
||||
last_name: payload.last_name || undefined,
|
||||
phone_number: payload.phone_number || undefined,
|
||||
status: normalizeStatus(payload.status),
|
||||
tenant_id: normalizeId(payload.tenant_id),
|
||||
role_id: normalizeId(payload.role_id),
|
||||
});
|
||||
|
||||
const toUpdatePayload = (payload: UserUpdateRequest) => ({
|
||||
email: payload.email?.trim() || undefined,
|
||||
password: payload.password,
|
||||
first_name: payload.first_name?.trim() || undefined,
|
||||
last_name:
|
||||
payload.last_name !== undefined ? payload.last_name?.trim() || null : undefined,
|
||||
phone_number:
|
||||
payload.phone_number !== undefined
|
||||
? payload.phone_number?.trim() || null
|
||||
: undefined,
|
||||
status: payload.status ? normalizeStatus(payload.status) : undefined,
|
||||
tenant_id:
|
||||
payload.tenant_id !== undefined ? normalizeId(payload.tenant_id) : undefined,
|
||||
role_id:
|
||||
payload.role_id !== undefined ? normalizeId(payload.role_id) : undefined,
|
||||
});
|
||||
|
||||
export const usersApi = {
|
||||
getAll: async () => {
|
||||
const data = await apiClient.get<UserApiResponse[]>("/api/user/get");
|
||||
return data.map(toUser);
|
||||
},
|
||||
|
||||
getById: async (userId: string) => {
|
||||
const data = await apiClient.get<UserApiResponse>(`/api/user/get/${userId}`);
|
||||
return toUser(data);
|
||||
},
|
||||
|
||||
create: async (payload: UserCreateRequest) => {
|
||||
const data = await apiClient.post<UserApiResponse>(
|
||||
"/api/user/create",
|
||||
toCreatePayload(payload),
|
||||
{ successMessage: "User created successfully", errorMessage: "Failed to create user" }
|
||||
);
|
||||
return toUser(data);
|
||||
},
|
||||
|
||||
update: async (userId: string, payload: UserUpdateRequest) => {
|
||||
const data = await apiClient.put<UserApiResponse>(
|
||||
`/api/user/update/${userId}`,
|
||||
toUpdatePayload(payload),
|
||||
{ successMessage: "User updated successfully", errorMessage: "Failed to update user" }
|
||||
);
|
||||
return toUser(data);
|
||||
},
|
||||
|
||||
remove: async (userId: string): Promise<ApiMessage> =>
|
||||
apiClient.delete<ApiMessage>(`/api/user/delete/${userId}`, { successMessage: "User deleted", errorMessage: "Failed to delete user" }),
|
||||
|
||||
getPaginated: (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
filter_names?: string[];
|
||||
filter_emails?: string[];
|
||||
statuses?: UserStatus[];
|
||||
filter_tenant_ids?: string[];
|
||||
filter_role_ids?: string[];
|
||||
sort_by?: "name" | "email" | "status" | "tenant" | "role";
|
||||
sort_order?: "asc" | "desc";
|
||||
}) => {
|
||||
const queryString = buildQueryString({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
search: params.search,
|
||||
filter_names: params.filter_names ?? undefined,
|
||||
filter_emails: params.filter_emails ?? undefined,
|
||||
statuses: params.statuses ?? undefined,
|
||||
filter_tenant_ids: params.filter_tenant_ids ?? undefined,
|
||||
filter_role_ids: params.filter_role_ids ?? undefined,
|
||||
sort_by: params.sort_by ?? undefined,
|
||||
sort_order: params.sort_order ?? undefined,
|
||||
});
|
||||
return apiClient.get<UserPaginatedResponse>(`/api/user/list${queryString}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
export type UserStatus = "active" | "inactive";
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name?: string | null;
|
||||
phone_number?: string | null;
|
||||
status: UserStatus;
|
||||
tenant_id?: string | null;
|
||||
tenant_name?: string | null;
|
||||
role_id?: string | null;
|
||||
role_name?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
password_updated_at: string;
|
||||
};
|
||||
|
||||
export type UserCreateRequest = {
|
||||
email: string;
|
||||
password: string;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
phone_number?: string;
|
||||
status?: UserStatus;
|
||||
tenant_id?: string;
|
||||
role_id?: string;
|
||||
};
|
||||
|
||||
export type UserUpdateRequest = {
|
||||
email?: string;
|
||||
password?: string;
|
||||
first_name?: string;
|
||||
last_name?: string | null;
|
||||
phone_number?: string | null;
|
||||
status?: UserStatus;
|
||||
tenant_id?: string | null;
|
||||
role_id?: string | null;
|
||||
};
|
||||
|
||||
export type ApiMessage = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type UserApiResponse = {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name?: string | null;
|
||||
phone_number?: string | null;
|
||||
status?: string;
|
||||
tenant_id?: string | null;
|
||||
role_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
password_updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type UserPaginatedResponse = {
|
||||
items: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomBackButton,
|
||||
CustomCheckBox,
|
||||
CustomDropdown,
|
||||
CustomInput,
|
||||
} from "../../../components/custom";
|
||||
import { CustomPhoneInput } from "../../../components/custom/CustomPhoneInput";
|
||||
import { usersApi } from "../UserApi";
|
||||
import type { UserCreateRequest, UserStatus } from "../UserTypes";
|
||||
import { tenantsApi } from "../../tenants/TenantsApi";
|
||||
import type { Tenant } from "../../tenants/TenantsTypes";
|
||||
import { rolesApi } from "../../roles/RolesApi";
|
||||
import type { Role } from "../../roles/RolesTypes";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
|
||||
const normalizeStatus = (value: string): UserStatus =>
|
||||
value.toLowerCase() === "inactive" ? "inactive" : "active";
|
||||
|
||||
const AddUsers = () => {
|
||||
const { t } = useTranslation(['users', 'common']);
|
||||
const { user, hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
const canReadAllTenants = hasAccess("superadmin.tenant.read");
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState<UserCreateRequest>({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
phone_number: "",
|
||||
tenant_id: "",
|
||||
role_id: "",
|
||||
status: "active",
|
||||
});
|
||||
const [isSuperAdminUser, setIsSuperAdminUser] = useState(false);
|
||||
const [lastTenantId, setLastTenantId] = useState("");
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [currentTenantName, setCurrentTenantName] = useState("");
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [isTenantLoading, setIsTenantLoading] = useState(false);
|
||||
const [isRoleLoading, setIsRoleLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadTenants = async () => {
|
||||
if (!canReadAllTenants) {
|
||||
setIsTenantLoading(true);
|
||||
try {
|
||||
if (user?.tenant_id && user?.tenant_name) {
|
||||
if (isMounted) {
|
||||
setCurrentTenantName(user.tenant_name);
|
||||
setTenants([]);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tenant_id: user.tenant_id ?? "",
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await tenantsApi.getMine();
|
||||
if (isMounted) {
|
||||
setCurrentTenantName(data.tenant_name);
|
||||
setTenants([]);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tenant_id: data.id,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load tenant.";
|
||||
setLoadError((prev) => prev || message);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tenant_id: user?.tenant_id ?? "",
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsTenantLoading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTenantLoading(true);
|
||||
try {
|
||||
const data = await tenantsApi.getAll();
|
||||
if (isMounted) {
|
||||
setTenants(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load tenants.";
|
||||
setLoadError((prev) => prev || message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsTenantLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
setIsRoleLoading(true);
|
||||
try {
|
||||
const data = await rolesApi.getAll();
|
||||
if (isMounted) {
|
||||
setRoles(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to load roles.";
|
||||
setLoadError((prev) => prev || message);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsRoleLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadTenants();
|
||||
loadRoles();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [canReadAllTenants, isAuthLoading, user?.tenant_id, user?.tenant_name]);
|
||||
|
||||
const isRoleAllowedForTenant = useCallback(
|
||||
(role: Role, tenantId?: string, forceSuperAdmin?: boolean) => {
|
||||
const isSuperAdminMode = forceSuperAdmin ?? isSuperAdminUser;
|
||||
if (isSuperAdminMode) {
|
||||
return !role.tenant_id;
|
||||
}
|
||||
if (canReadAllTenants) {
|
||||
if (!tenantId) {
|
||||
return false;
|
||||
}
|
||||
return role.tenant_id === tenantId;
|
||||
}
|
||||
if (tenantId) {
|
||||
return !role.tenant_id || role.tenant_id === tenantId;
|
||||
}
|
||||
return !role.tenant_id;
|
||||
},
|
||||
[canReadAllTenants, isSuperAdminUser]
|
||||
);
|
||||
|
||||
const filteredRoles = useMemo(
|
||||
() =>
|
||||
roles.filter((role) =>
|
||||
isRoleAllowedForTenant(role, formData.tenant_id)
|
||||
),
|
||||
[isRoleAllowedForTenant, roles, formData.tenant_id]
|
||||
);
|
||||
|
||||
const handleChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = event.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handlePhoneChange = (value: string | undefined) => {
|
||||
setFormData((prev) => ({ ...prev, phone_number: value || "" }));
|
||||
};
|
||||
|
||||
const handleRoleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const { value } = event.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
role_id: value || "",
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTenantChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const tenantId = event.target.value;
|
||||
setLastTenantId(tenantId);
|
||||
setFormData((prev) => {
|
||||
const roleStillValid = roles.some(
|
||||
(role) =>
|
||||
role.id === prev.role_id && isRoleAllowedForTenant(role, tenantId)
|
||||
);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
tenant_id: tenantId || "",
|
||||
role_id: roleStillValid ? prev.role_id : "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSuperAdminToggle = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const checked = event.target.checked;
|
||||
setIsSuperAdminUser(checked);
|
||||
if (checked) {
|
||||
setLastTenantId(formData.tenant_id || "");
|
||||
}
|
||||
setFormData((prev) => {
|
||||
const nextTenantId = checked ? "" : lastTenantId || "";
|
||||
const roleStillValid = roles.some(
|
||||
(role) =>
|
||||
role.id === prev.role_id &&
|
||||
isRoleAllowedForTenant(role, nextTenantId, checked)
|
||||
);
|
||||
return {
|
||||
...prev,
|
||||
tenant_id: nextTenantId,
|
||||
role_id: roleStillValid ? prev.role_id : "",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const statusValue = formData.status?.trim() || "active";
|
||||
const payload: UserCreateRequest = {
|
||||
first_name: formData.first_name.trim(),
|
||||
last_name: formData.last_name?.trim() || undefined,
|
||||
email: formData.email.trim(),
|
||||
password: formData.password,
|
||||
phone_number: formData.phone_number?.trim() || undefined,
|
||||
tenant_id: formData.tenant_id?.trim() || undefined,
|
||||
role_id: formData.role_id?.trim() || undefined,
|
||||
status: normalizeStatus(statusValue),
|
||||
};
|
||||
|
||||
await usersApi.create(payload);
|
||||
navigate("/users");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unable to add user.";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CustomBackButton to="/users" tooltip={t('backToUsers')} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-(--text-primary)">{t('add')}</h1>
|
||||
<p className="text-sm text-(--text-secondary)">
|
||||
{t('subTitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6 rounded-lg border border-(--card-border) bg-(--card-bg) p-6 shadow-sm"
|
||||
>
|
||||
{canReadAllTenants && (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<CustomCheckBox
|
||||
label={t('fields.superAdminUser')}
|
||||
checked={isSuperAdminUser}
|
||||
onChange={handleSuperAdminToggle}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput
|
||||
label={t('fields.firstName')}
|
||||
name="first_name"
|
||||
placeholder={t('fields.firstNamePlaceholder')}
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('fields.lastName')}
|
||||
name="last_name"
|
||||
placeholder={t('fields.lastNamePlaceholder')}
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('fields.email')}
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder={t('fields.emailPlaceholder')}
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
<CustomPhoneInput
|
||||
label={t('fields.phone')}
|
||||
name="phone_number"
|
||||
placeholder={t('fields.phonePlaceholder')}
|
||||
value={formData.phone_number}
|
||||
onChange={handlePhoneChange}
|
||||
required
|
||||
defaultCountry="IN"
|
||||
/>
|
||||
{canReadAllTenants ? (
|
||||
isSuperAdminUser ? (
|
||||
<CustomInput
|
||||
label={t('fields.tenant')}
|
||||
name="tenant_name"
|
||||
value={t('fields.superAdminNoTenant')}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
) : (
|
||||
<CustomDropdown
|
||||
label={t('fields.tenant')}
|
||||
name="tenant_id"
|
||||
placeholder={
|
||||
isTenantLoading ? t('fields.tenantLoading') : t('fields.tenantPlaceholder')
|
||||
}
|
||||
value={formData.tenant_id ?? ""}
|
||||
onChange={handleTenantChange}
|
||||
options={tenants.map((tenant) => ({
|
||||
label: tenant.tenant_name,
|
||||
value: tenant.id,
|
||||
}))}
|
||||
disabled={isTenantLoading}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<CustomInput
|
||||
label={t('fields.tenant')}
|
||||
name="tenant_name"
|
||||
value={currentTenantName || user?.tenant_name || user?.tenant_id || ""}
|
||||
placeholder={
|
||||
isTenantLoading ? t('fields.tenantLoading') : t('fields.assignedTenant')
|
||||
}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
<CustomDropdown
|
||||
label={t('fields.role')}
|
||||
name="role_id"
|
||||
placeholder={
|
||||
isSuperAdminUser
|
||||
? isRoleLoading
|
||||
? t('fields.roleLoading')
|
||||
: t('fields.roleSuperAdminPlaceholder')
|
||||
: formData.tenant_id
|
||||
? isRoleLoading
|
||||
? t('fields.roleLoading')
|
||||
: t('fields.rolePlaceholder')
|
||||
: t('fields.roleFilterPlaceholder')
|
||||
}
|
||||
value={formData.role_id ?? ""}
|
||||
onChange={handleRoleChange}
|
||||
options={filteredRoles.map((role) => ({
|
||||
label: role.role_name,
|
||||
value: role.id,
|
||||
}))}
|
||||
disabled={isRoleLoading || (!isRoleLoading && filteredRoles.length === 0)}
|
||||
/>
|
||||
<CustomInput
|
||||
label={t('fields.password')}
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={t('fields.passwordPlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{(errorMessage || loadError) && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 dark:bg-red-900/20 px-4 py-2 text-sm text-red-600 dark:text-red-400">
|
||||
{errorMessage || loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<CustomButton type="submit" variant="primary" loading={isLoading}>
|
||||
{t('actions.create')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddUsers;
|
||||
@@ -0,0 +1,718 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Edit2, Eye, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
CustomColumnFilter,
|
||||
CustomConfirmationModal,
|
||||
CustomDropdown,
|
||||
CustomInput,
|
||||
CustomModal,
|
||||
CustomTable,
|
||||
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomStatus,
|
||||
CustomPhoneInput,
|
||||
} from "../../../components/custom";
|
||||
import type { ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import {
|
||||
buildColumnFilterOptions,
|
||||
resolveColumnSortState,
|
||||
} from "../../../components/custom/CustomColumnFilter.utils";
|
||||
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
||||
import { formatDate } from "../../../lib/dateFormat";
|
||||
import type { User, UserStatus, UserUpdateRequest, UserPaginatedResponse } from "../UserTypes";
|
||||
import { usersApi } from "../UserApi";
|
||||
import { rolesApi } from "../../roles/RolesApi";
|
||||
import type { Role } from "../../roles/RolesTypes";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { tenantsApi } from "../../tenants/TenantsApi";
|
||||
import type { Tenant } from "../../tenants/TenantsTypes";
|
||||
import { useDebounce } from "../../../components/hooks/useDebounce";
|
||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
||||
import {
|
||||
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
resolveStoredTablePageSize,
|
||||
} from "../../../lib/tablePageSize";
|
||||
|
||||
const formatName = (firstName: string, lastName?: string | null) =>
|
||||
[firstName, lastName].filter(Boolean).join(" ");
|
||||
|
||||
type EditFormState = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone_number: string;
|
||||
tenant_id: string;
|
||||
role_id: string;
|
||||
status: UserStatus;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const emptyEditForm: EditFormState = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
tenant_id: "",
|
||||
role_id: "",
|
||||
status: "active",
|
||||
password: "",
|
||||
};
|
||||
|
||||
const AllUsers = () => {
|
||||
const { t, i18n } = useTranslation(['users', 'common']);
|
||||
const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
|
||||
// State
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
|
||||
// Pagination & filters
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(() =>
|
||||
resolveStoredTablePageSize({
|
||||
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
||||
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
||||
defaultPageSize: 10,
|
||||
})
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [nameFilter, setNameFilter] = useState<string[]>([]);
|
||||
const [emailFilter, setEmailFilter] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<UserStatus[]>([]);
|
||||
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
|
||||
const [roleFilter, setRoleFilter] = useState<string[]>([]);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: "first_name" | "email" | "status" | "tenant_id" | "role_id" | null;
|
||||
direction: ColumnSortDirection;
|
||||
}>({ column: null, direction: null });
|
||||
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
const latestUsersRequestRef = useRef(0);
|
||||
|
||||
// Modals
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<EditFormState>(emptyEditForm);
|
||||
const [editError, setEditError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Supporting data
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [allUsersForCounts, setAllUsersForCounts] = useState<User[]>([]);
|
||||
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
|
||||
const [isRoleLoading, setIsRoleLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadSupportingData = async () => {
|
||||
setIsRoleLoading(true);
|
||||
try {
|
||||
const usersData = await usersApi.getAll();
|
||||
if (isMounted) setAllUsersForCounts(usersData);
|
||||
|
||||
const rolesData = await rolesApi.getAll();
|
||||
if (isMounted) setRoles(rolesData);
|
||||
|
||||
if (hasAccess("superadmin.tenant.read")) {
|
||||
const tenantsData = await tenantsApi.getAll();
|
||||
if (isMounted) setTenants(tenantsData);
|
||||
} else if (currentUser?.tenant_id) {
|
||||
const tenant = await tenantsApi.getMine();
|
||||
if (isMounted) setCurrentTenant(tenant);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load roles/tenants:", err);
|
||||
} finally {
|
||||
if (isMounted) setIsRoleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
const requestId = ++latestUsersRequestRef.current;
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const response: UserPaginatedResponse = await usersApi.getPaginated({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
search: debouncedSearch || undefined,
|
||||
filter_names: nameFilter,
|
||||
filter_emails: emailFilter,
|
||||
statuses: statusFilter,
|
||||
filter_tenant_ids: tenantFilter,
|
||||
filter_role_ids: roleFilter,
|
||||
sort_by:
|
||||
activeSort.column === "first_name"
|
||||
? "name"
|
||||
: activeSort.column === "email"
|
||||
? "email"
|
||||
: activeSort.column === "status"
|
||||
? "status"
|
||||
: activeSort.column === "tenant_id"
|
||||
? "tenant"
|
||||
: activeSort.column === "role_id"
|
||||
? "role"
|
||||
: undefined,
|
||||
sort_order: activeSort.direction ?? undefined,
|
||||
});
|
||||
|
||||
if (isMounted && requestId === latestUsersRequestRef.current) {
|
||||
setUsers(response.items);
|
||||
setTotalRows(response.total);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted && requestId === latestUsersRequestRef.current) {
|
||||
const msg = err instanceof Error ? err.message : "Unable to load users.";
|
||||
setErrorMessage(msg);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted && requestId === latestUsersRequestRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthLoading) {
|
||||
loadSupportingData();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [
|
||||
isAuthLoading,
|
||||
hasAccess,
|
||||
currentUser?.tenant_id,
|
||||
page,
|
||||
pageSize,
|
||||
debouncedSearch,
|
||||
nameFilter,
|
||||
emailFilter,
|
||||
statusFilter,
|
||||
tenantFilter,
|
||||
roleFilter,
|
||||
activeSort,
|
||||
]);
|
||||
|
||||
// Reset page on search/filter change
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, nameFilter, emailFilter, statusFilter, tenantFilter, roleFilter, activeSort]);
|
||||
|
||||
// Restore search focus
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim()) {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
prevLoadingRef.current = isLoading;
|
||||
}, [isLoading, search]);
|
||||
|
||||
const getTenantName = useCallback(
|
||||
(tenantId?: string | null) => {
|
||||
if (!tenantId) return "";
|
||||
const found = tenants.find((t) => t.id === tenantId);
|
||||
if (found) return found.tenant_name;
|
||||
if (currentTenant?.id === tenantId) return currentTenant.tenant_name;
|
||||
return tenantId;
|
||||
},
|
||||
[tenants, currentTenant]
|
||||
);
|
||||
|
||||
const getRoleName = useCallback(
|
||||
(roleId?: string | null) => {
|
||||
if (!roleId) return "";
|
||||
return roles.find((r) => r.id === roleId)?.role_name || roleId;
|
||||
},
|
||||
[roles]
|
||||
);
|
||||
|
||||
const openView = useCallback((user: User) => {
|
||||
setSelectedUser(user);
|
||||
setIsViewOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback(
|
||||
(user: User) => {
|
||||
const resolvedTenantId = currentUser?.tenant_id ?? user.tenant_id ?? "";
|
||||
setSelectedUser(user);
|
||||
setEditForm({
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name ?? "",
|
||||
email: user.email,
|
||||
phone_number: user.phone_number ?? "",
|
||||
tenant_id: resolvedTenantId,
|
||||
role_id: user.role_id ?? "",
|
||||
status: user.status,
|
||||
password: "",
|
||||
});
|
||||
setEditError("");
|
||||
setIsEditOpen(true);
|
||||
},
|
||||
[currentUser?.tenant_id]
|
||||
);
|
||||
|
||||
const openDelete = useCallback((user: User) => {
|
||||
setSelectedUser(user);
|
||||
setDeleteError("");
|
||||
setIsDeleteOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeView = useCallback(() => {
|
||||
setIsViewOpen(false);
|
||||
setSelectedUser(null);
|
||||
}, []);
|
||||
|
||||
const closeEdit = useCallback(() => {
|
||||
setIsEditOpen(false);
|
||||
setSelectedUser(null);
|
||||
setEditError("");
|
||||
}, []);
|
||||
|
||||
const closeDelete = useCallback(() => {
|
||||
setIsDeleteOpen(false);
|
||||
setSelectedUser(null);
|
||||
setDeleteError("");
|
||||
}, []);
|
||||
|
||||
const handleEditChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setEditForm((prev) => ({ ...prev, [name]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleEditPhoneChange = useCallback((val: string | undefined) => {
|
||||
setEditForm((prev) => ({ ...prev, phone_number: val || "" }));
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
status: e.target.checked ? "active" : "inactive",
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const filteredRoles = useMemo(
|
||||
() =>
|
||||
roles.filter((role) => {
|
||||
if (editForm.tenant_id) {
|
||||
return !role.tenant_id || role.tenant_id === editForm.tenant_id;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[roles, editForm.tenant_id]
|
||||
);
|
||||
|
||||
const nameCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
allUsersForCounts.forEach((user) => {
|
||||
const fullName = formatName(user.first_name, user.last_name);
|
||||
if (!fullName) return;
|
||||
counts.set(fullName, (counts.get(fullName) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [allUsersForCounts]);
|
||||
|
||||
const emailCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
allUsersForCounts.forEach((user) => {
|
||||
if (!user.email) return;
|
||||
counts.set(user.email, (counts.get(user.email) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [allUsersForCounts]);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<UserStatus, number> = {
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
};
|
||||
|
||||
allUsersForCounts.forEach((user) => {
|
||||
counts[user.status] += 1;
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [allUsersForCounts]);
|
||||
|
||||
const tenantCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
allUsersForCounts.forEach((user) => {
|
||||
if (!user.tenant_id) return;
|
||||
counts.set(user.tenant_id, (counts.get(user.tenant_id) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [allUsersForCounts]);
|
||||
|
||||
const roleCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
allUsersForCounts.forEach((user) => {
|
||||
if (!user.role_id) return;
|
||||
counts.set(user.role_id, (counts.get(user.role_id) ?? 0) + 1);
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [allUsersForCounts]);
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedUser) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setEditError("");
|
||||
|
||||
try {
|
||||
const payload: UserUpdateRequest = {
|
||||
first_name: editForm.first_name.trim(),
|
||||
last_name: editForm.last_name.trim() || null,
|
||||
email: editForm.email.trim(),
|
||||
phone_number: editForm.phone_number.trim() || null,
|
||||
status: editForm.status,
|
||||
role_id: editForm.role_id || null,
|
||||
};
|
||||
|
||||
if (editForm.password.trim()) {
|
||||
payload.password = editForm.password.trim();
|
||||
}
|
||||
|
||||
const updated = await usersApi.update(selectedUser.id, payload);
|
||||
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
|
||||
closeEdit();
|
||||
} catch (err) {
|
||||
setEditError(err instanceof Error ? err.message : "Failed to update user");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedUser) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await usersApi.remove(selectedUser.id);
|
||||
setUsers((prev) => prev.filter((u) => u.id !== selectedUser.id));
|
||||
closeDelete();
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : "Failed to delete user");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<User>>>(
|
||||
() => [
|
||||
{
|
||||
key: "first_name",
|
||||
visibilityLabel: t('columns.name'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.name')}
|
||||
<CustomColumnFilter
|
||||
title={t('columns.name')}
|
||||
selectedValues={nameFilter}
|
||||
sortDirection={activeSort.column === "first_name" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setNameFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "first_name", direction));
|
||||
}}
|
||||
options={buildColumnFilterOptions(nameCounts.entries())}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
render: (row) => formatName(row.first_name, row.last_name),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
visibilityLabel: t('columns.email'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.email')}
|
||||
<CustomColumnFilter
|
||||
title={t('columns.email')}
|
||||
selectedValues={emailFilter}
|
||||
sortDirection={activeSort.column === "email" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setEmailFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "email", direction));
|
||||
}}
|
||||
options={buildColumnFilterOptions(emailCounts.entries())}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "phone_number",
|
||||
header: t('columns.phone'),
|
||||
render: (row) => row.phone_number || "",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
visibilityLabel: t('columns.status'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.status')}
|
||||
<CustomColumnFilter
|
||||
title={t('columns.status')}
|
||||
selectedValues={statusFilter}
|
||||
sortDirection={activeSort.column === "status" ? activeSort.direction : null}
|
||||
enableSearch={false}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setStatusFilter(values as UserStatus[]);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "status", direction));
|
||||
}}
|
||||
options={[
|
||||
{ label: `${t('common:status.active')} (${statusCounts.active})`, value: "active" },
|
||||
{ label: `${t('common:status.inactive')} (${statusCounts.inactive})`, value: "inactive"},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) => <CustomStatus status={row.status} />,
|
||||
},
|
||||
{
|
||||
key: "tenant_id",
|
||||
visibilityLabel: t('columns.tenant'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.tenant')}
|
||||
{hasAccess("superadmin.tenant.read") && tenants.length > 0 && (
|
||||
<CustomColumnFilter
|
||||
title={t('columns.tenant')}
|
||||
selectedValues={tenantFilter}
|
||||
sortDirection={activeSort.column === "tenant_id" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setTenantFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "tenant_id", direction));
|
||||
}}
|
||||
options={tenants.map((tenant) => ({
|
||||
label: `${tenant.tenant_name} (${tenantCounts.get(tenant.id) ?? 0})`,
|
||||
value: tenant.id,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
render: (row) => getTenantName(row.tenant_id),
|
||||
},
|
||||
{
|
||||
key: "role_id",
|
||||
visibilityLabel: t('columns.role'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.role')}
|
||||
<CustomColumnFilter
|
||||
title={t('columns.role')}
|
||||
selectedValues={roleFilter}
|
||||
sortDirection={activeSort.column === "role_id" ? activeSort.direction : null}
|
||||
onApply={(values, direction) => {
|
||||
setPage(1);
|
||||
setRoleFilter(values);
|
||||
setActiveSort(resolveColumnSortState(activeSort, "role_id", direction));
|
||||
}}
|
||||
options={roles.map((role) => ({
|
||||
label: `${role.role_name} (${roleCounts.get(role.id) ?? 0})`,
|
||||
value: role.id,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
render: (row) => getRoleName(row.role_id),
|
||||
},
|
||||
|
||||
{
|
||||
key: "id",
|
||||
header: t('columns.actions'),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomActionMenu>
|
||||
<ProtectedComponent requiredAccess="admin.user.read">
|
||||
<CustomActionItem onClick={() => openView(row)}>
|
||||
<Eye size={16} className="mr-2" /> {t('actions.view')}
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="admin.user.update">
|
||||
<CustomActionItem onClick={() => openEdit(row)}>
|
||||
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
|
||||
<ProtectedComponent requiredAccess="admin.user.delete">
|
||||
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
||||
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
|
||||
</CustomActionItem>
|
||||
</ProtectedComponent>
|
||||
</CustomActionMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeSort, emailCounts, emailFilter, getTenantName, getRoleName, hasAccess, nameCounts, nameFilter, openDelete, openEdit, openView, roleCounts, roleFilter, roles, statusCounts, statusFilter, t, tenantCounts, tenantFilter, tenants]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">{t('title')}</h1>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Manage system users, their roles, and account status.</p>
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="admin.user.create">
|
||||
<Link to="/users/add">
|
||||
<CustomButton variant="primary">+ {t('add')}</CustomButton>
|
||||
</Link>
|
||||
</ProtectedComponent>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : (
|
||||
<CustomTable
|
||||
isLoading={isLoading}
|
||||
data={users}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="users-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
totalRows={totalRows}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
search={search}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onSearchChange={setSearch}
|
||||
searchInputRef={searchInputRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* View Modal */}
|
||||
<CustomModal
|
||||
isOpen={isViewOpen}
|
||||
onClose={closeView}
|
||||
title={t('details')}
|
||||
size="lg"
|
||||
footer={<CustomButton variant="outlined" onClick={closeView}>{t('actions.close')}</CustomButton>}
|
||||
>
|
||||
{selectedUser ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.name')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{formatName(selectedUser.first_name, selectedUser.last_name)}</p></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.email')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{selectedUser.email}</p></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.phone')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{selectedUser.phone_number || "--"}</p></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.status')}</p><CustomStatus status={selectedUser.status} /></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.tenant')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{getTenantName(selectedUser.tenant_id)}</p></div>
|
||||
<div><p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.role')}</p><p className="text-sm font-medium text-[var(--text-primary)]">{getRoleName(selectedUser.role_id)}</p></div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.created')}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(selectedUser.created_at, i18n.language)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.updated')}</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{formatDate(selectedUser.updated_at, i18n.language)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--text-secondary)]">{t('common:common.noData')}</p>
|
||||
)}
|
||||
</CustomModal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<CustomModal
|
||||
isOpen={isEditOpen}
|
||||
onClose={closeEdit}
|
||||
title={t('edit')}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<CustomButton variant="outlined" onClick={closeEdit} disabled={isSaving}>{t('actions.cancel')}</CustomButton>
|
||||
<CustomButton type="submit" form="edit-user-form" variant="primary" loading={isSaving}>{t('actions.update')}</CustomButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-user-form" onSubmit={handleUpdate} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<CustomInput label={t('fields.firstName')} name="first_name" value={editForm.first_name} onChange={handleEditChange} required />
|
||||
<CustomInput label={t('fields.lastName')} name="last_name" value={editForm.last_name} onChange={handleEditChange} />
|
||||
<CustomInput label={t('fields.email')} name="email" type="email" value={editForm.email} onChange={handleEditChange} required />
|
||||
<CustomPhoneInput label={t('fields.phone')} name="phone_number" value={editForm.phone_number} onChange={handleEditPhoneChange} defaultCountry="IN" />
|
||||
<CustomInput label={t('fields.tenant')} value={getTenantName(currentUser?.tenant_id ?? editForm.tenant_id) || "--"} disabled readOnly />
|
||||
<CustomDropdown
|
||||
label={t('fields.role')}
|
||||
name="role_id"
|
||||
value={editForm.role_id}
|
||||
onChange={handleEditChange}
|
||||
options={filteredRoles.map((r) => ({ label: r.role_name, value: r.id }))}
|
||||
placeholder={isRoleLoading ? t('fields.roleLoading') : t('fields.rolePlaceholder')}
|
||||
disabled={isRoleLoading || filteredRoles.length === 0}
|
||||
/>
|
||||
<CustomInput label={t('fields.password')} name="password" type="password" placeholder={t('fields.passwordEditPlaceholder')} value={editForm.password} onChange={handleEditChange} />
|
||||
<div className="flex items-center">
|
||||
<CustomCheckBox label={t('fields.active')} checked={editForm.status === "active"} onChange={handleStatusChange} />
|
||||
</div>
|
||||
</div>
|
||||
{editError && <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">{editError}</div>}
|
||||
</form>
|
||||
</CustomModal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<CustomConfirmationModal
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={closeDelete}
|
||||
onConfirm={handleDelete}
|
||||
title={t('messages.deleteTitle')}
|
||||
description={deleteError || t('messages.deleteConfirm')}
|
||||
confirmText={t('actions.delete')}
|
||||
variant="danger"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllUsers;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import AllUsers from "./components/AllUsers";
|
||||
import AddUsers from "./components/AddUsers";
|
||||
|
||||
const Users: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<AllUsers />} />
|
||||
<Route path="add" element={<AddUsers />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import CustomButton from "./CustomButton";
|
||||
|
||||
export type ColumnFilterOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ColumnSortDirection = "asc" | "desc" | null;
|
||||
|
||||
export type ColumnSortState<T extends string = string> = {
|
||||
column: T | null;
|
||||
direction: ColumnSortDirection;
|
||||
};
|
||||
|
||||
export type ColumnFilterConfig = {
|
||||
title: string;
|
||||
options: ColumnFilterOption[];
|
||||
selectedValues: string[];
|
||||
sortDirection: ColumnSortDirection;
|
||||
enableSearch?: boolean;
|
||||
enableSelectAll?: boolean;
|
||||
};
|
||||
|
||||
const getAllOptionValues = (options: ColumnFilterOption[]) =>
|
||||
options.map((option) => option.value);
|
||||
|
||||
type CustomColumnFilterProps = ColumnFilterConfig & {
|
||||
onApply: (nextValues: string[], nextSort: ColumnSortDirection) => void;
|
||||
};
|
||||
|
||||
const CustomColumnFilter = ({
|
||||
title,
|
||||
options,
|
||||
selectedValues,
|
||||
sortDirection,
|
||||
onApply,
|
||||
enableSearch = true,
|
||||
enableSelectAll = true,
|
||||
}: CustomColumnFilterProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [draftValues, setDraftValues] = useState<string[]>(selectedValues);
|
||||
const [draftSort, setDraftSort] = useState<ColumnSortDirection>(sortDirection);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (buttonRef.current?.contains(event.target as Node)) return;
|
||||
if (menuRef.current?.contains(event.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const handleToggle = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const popoverWidth = Math.min(420, window.innerWidth - 32);
|
||||
const preferredLeft = rect.left - 12;
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(16, preferredLeft),
|
||||
Math.max(16, window.innerWidth - popoverWidth - 16)
|
||||
);
|
||||
setMenuPos({ top: rect.bottom + 8, left: clampedLeft });
|
||||
}
|
||||
|
||||
setDraftValues(selectedValues.length > 0 ? selectedValues : getAllOptionValues(options));
|
||||
setDraftSort(sortDirection);
|
||||
setSearchTerm("");
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const normalizedQuery = searchTerm.trim().toLowerCase();
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
enableSearch
|
||||
? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
|
||||
: options,
|
||||
[enableSearch, normalizedQuery, options]
|
||||
);
|
||||
const shouldRenderOptionsSection = enableSelectAll || filteredOptions.length > 0 || options.length > 0;
|
||||
|
||||
const allOptionValues = useMemo(() => getAllOptionValues(options), [options]);
|
||||
const selectableValues = filteredOptions.map((option) => option.value);
|
||||
const isAllSelected =
|
||||
filteredOptions.length > 0 &&
|
||||
selectableValues.every((value) => draftValues.includes(value));
|
||||
|
||||
const toggleValue = (value: string) => {
|
||||
setDraftValues((prev) =>
|
||||
prev.includes(value)
|
||||
? prev.filter((item) => item !== value)
|
||||
: [...prev, value]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
setDraftValues((prev) => prev.filter((value) => !selectableValues.includes(value)));
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftValues((prev) => Array.from(new Set([...prev, ...selectableValues])));
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setDraftValues(allOptionValues);
|
||||
setDraftSort(null);
|
||||
setSearchTerm("");
|
||||
onApply([], null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
const filteredOptionValues = filteredOptions.map((option) => option.value);
|
||||
const isDraftAllSelected =
|
||||
draftValues.length === allOptionValues.length &&
|
||||
allOptionValues.every((value) => draftValues.includes(value));
|
||||
|
||||
const normalizedValues =
|
||||
normalizedQuery && filteredOptionValues.length > 0 && isDraftAllSelected
|
||||
? filteredOptionValues
|
||||
: isDraftAllSelected
|
||||
? []
|
||||
: draftValues;
|
||||
|
||||
onApply(normalizedValues, draftSort);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const isActive = selectedValues.length > 0 || sortDirection !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={handleToggle}
|
||||
className={`ml-2 inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
|
||||
isActive
|
||||
? "border-blue-200 bg-blue-50 text-blue-600"
|
||||
: "border-transparent text-slate-400 hover:border-slate-200 hover:bg-slate-100"
|
||||
}`}
|
||||
aria-label={`${title} filter`}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M4 6h16M7 12h10M10 18h4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{open && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ position: "fixed", top: menuPos.top, left: menuPos.left, zIndex: 9999 }}
|
||||
className="w-[420px] max-w-[calc(100vw-32px)] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_24px_60px_rgba(15,23,42,0.18)]"
|
||||
>
|
||||
<div className="border-b border-slate-200 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftSort("asc")}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-slate-50 ${
|
||||
draftSort === "asc" ? "bg-blue-50 text-blue-600" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded border border-slate-200 bg-white text-slate-500">
|
||||
↑
|
||||
</span>
|
||||
<span>Sort Ascending</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftSort("desc")}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-slate-50 ${
|
||||
draftSort === "desc" ? "bg-blue-50 text-blue-600" : "text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded border border-slate-200 bg-white text-slate-500">
|
||||
↓
|
||||
</span>
|
||||
<span>Sort Descending</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{enableSearch ? (
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Filter
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
/>
|
||||
<input
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="Search values..."
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-white pl-9 pr-3 text-sm text-slate-700 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{shouldRenderOptionsSection ? (
|
||||
<div className="max-h-[260px] overflow-y-auto py-2">
|
||||
{enableSelectAll ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAll}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<span
|
||||
className={`flex h-[18px] w-[18px] items-center justify-center rounded border transition-colors ${
|
||||
isAllSelected
|
||||
? "border-blue-500 bg-blue-500 text-white"
|
||||
: "border-slate-300 bg-white text-transparent"
|
||||
}`}
|
||||
>
|
||||
<Check size={12} strokeWidth={3} />
|
||||
</span>
|
||||
<span className="truncate font-medium text-slate-900">Select All</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{filteredOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => toggleValue(option.value)}
|
||||
className="flex w-full items-start gap-3 px-4 py-2 text-left text-sm text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
draftValues.includes(option.value)
|
||||
? "border-blue-500 bg-blue-500 text-white"
|
||||
: "border-slate-300 bg-white text-transparent"
|
||||
}`}
|
||||
>
|
||||
<Check size={12} strokeWidth={3} />
|
||||
</span>
|
||||
<span className="whitespace-normal break-words leading-5">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filteredOptions.length === 0 && options.length > 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-slate-400">
|
||||
No matching options
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-sm font-medium text-slate-500 transition hover:text-slate-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={handleApply}
|
||||
className="!h-9 !rounded-xl !px-4 !text-sm"
|
||||
>
|
||||
Apply
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomColumnFilter;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
ColumnFilterOption,
|
||||
ColumnSortDirection,
|
||||
ColumnSortState,
|
||||
} from "./CustomColumnFilter";
|
||||
|
||||
export const buildColumnFilterOptions = (
|
||||
entries: Iterable<[string, number]>
|
||||
): ColumnFilterOption[] =>
|
||||
Array.from(entries).map(([value, count]) => ({
|
||||
label: `${value} (${count})`,
|
||||
value,
|
||||
}));
|
||||
|
||||
export function resolveColumnSortState<T extends string>(
|
||||
currentSort: ColumnSortState<T>,
|
||||
column: T,
|
||||
direction: ColumnSortDirection
|
||||
): ColumnSortState<T> {
|
||||
return {
|
||||
column: direction ? column : currentSort.column === column ? null : currentSort.column,
|
||||
direction: direction ?? (currentSort.column === column ? null : currentSort.direction),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, forwardRef } from "react";
|
||||
import { Phone, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
type InputType = "text" | "password" | "number" | "email" | "tel" | "date";
|
||||
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "url";
|
||||
|
||||
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
@@ -129,4 +129,4 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
}
|
||||
);
|
||||
|
||||
export default CustomInput;
|
||||
export default CustomInput;
|
||||
@@ -1,10 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import CustomButton from "./CustomButton";
|
||||
import CustomInput from "./CustomInput";
|
||||
import { Upload, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Upload, ChevronLeft, ChevronRight, Columns3, Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
persistTablePageSize,
|
||||
resolveStoredTablePageSize,
|
||||
} from "../../lib/tablePageSize";
|
||||
import Loader from "./CustomLoader";
|
||||
|
||||
export function cn(...parts: Array<string | false | null | undefined>) {
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
@@ -18,8 +24,10 @@ export type Primitive =
|
||||
| Record<string, unknown>;
|
||||
|
||||
export type ColumnDef<T> = {
|
||||
id?: string;
|
||||
key: keyof T | string;
|
||||
header: React.ReactNode;
|
||||
visibilityLabel?: string;
|
||||
exportHeader?: string;
|
||||
render?: (row: T) => React.ReactNode;
|
||||
searchable?: boolean;
|
||||
@@ -31,10 +39,14 @@ export type DataTableProps<T extends Record<string, unknown>> = {
|
||||
columns: Array<ColumnDef<T>>;
|
||||
defaultPageSize?: number;
|
||||
pageSizeOptions?: number[];
|
||||
exportEnabled?: boolean;
|
||||
exportFileName?: string;
|
||||
className?: string;
|
||||
getRowId?: (row: T, index: number) => string | number;
|
||||
filterControls?: React.ReactNode;
|
||||
columnVisibilityEnabled?: boolean;
|
||||
columnVisibilityStorageKey?: string;
|
||||
pageSizeStorageKey?: string;
|
||||
|
||||
enableSearchDropdown?: boolean;
|
||||
buildSuggestionLabel?: (row: T) => string;
|
||||
@@ -79,6 +91,19 @@ function downloadCSV(fileName: string, rows: string[]) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function getColumnId<T>(column: ColumnDef<T>) {
|
||||
return column.id ?? String(column.key);
|
||||
}
|
||||
|
||||
function getColumnVisibilityLabel<T>(
|
||||
column: ColumnDef<T>,
|
||||
fallbackLabel: string
|
||||
) {
|
||||
if (column.visibilityLabel) return column.visibilityLabel;
|
||||
if (typeof column.header === "string") return column.header;
|
||||
return fallbackLabel;
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, unknown>>(
|
||||
props: DataTableProps<T>
|
||||
) {
|
||||
@@ -88,10 +113,14 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
columns,
|
||||
defaultPageSize = 10,
|
||||
pageSizeOptions = [5, 10, 20, 50],
|
||||
exportEnabled = false,
|
||||
exportFileName = "export.csv",
|
||||
className,
|
||||
getRowId,
|
||||
filterControls,
|
||||
columnVisibilityEnabled = false,
|
||||
columnVisibilityStorageKey,
|
||||
pageSizeStorageKey,
|
||||
enableSearchDropdown = false,
|
||||
buildSuggestionLabel,
|
||||
onSuggestionSelect,
|
||||
@@ -112,22 +141,158 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
} = props;
|
||||
|
||||
const [internalSearch, setInternalSearch] = React.useState("");
|
||||
const [internalPageSize, setInternalPageSize] = React.useState(defaultPageSize);
|
||||
const [internalPageSize, setInternalPageSize] = React.useState(() =>
|
||||
resolveStoredTablePageSize({
|
||||
storageKey: pageSizeStorageKey,
|
||||
pageSizeOptions,
|
||||
defaultPageSize,
|
||||
})
|
||||
);
|
||||
const [internalPage, setInternalPage] = React.useState(1);
|
||||
const [visibleColumnIds, setVisibleColumnIds] = React.useState<string[] | null>(
|
||||
null
|
||||
);
|
||||
const [isColumnsMenuOpen, setIsColumnsMenuOpen] = React.useState(false);
|
||||
const [columnsMenuPos, setColumnsMenuPos] = React.useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
const [draftVisibleColumnIds, setDraftVisibleColumnIds] = React.useState<string[]>([]);
|
||||
const columnsMenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const columnsButtonRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const search = manualFiltering && controlledSearch !== undefined ? controlledSearch : internalSearch;
|
||||
const pageSize = manualPagination && controlledPageSize !== undefined ? controlledPageSize : internalPageSize;
|
||||
const page = manualPagination && controlledPage !== undefined ? controlledPage : internalPage;
|
||||
|
||||
const normalizedColumns = React.useMemo(
|
||||
() =>
|
||||
columns.map((column, index) => ({
|
||||
...column,
|
||||
_columnId: getColumnId(column),
|
||||
_fallbackLabel: `${t("actions.columns", { defaultValue: "Columns" })} ${index + 1}`,
|
||||
})),
|
||||
[columns, t]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!columnVisibilityEnabled) {
|
||||
setVisibleColumnIds(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const allColumnIds = normalizedColumns.map((column) => column._columnId);
|
||||
|
||||
if (typeof window === "undefined" || !columnVisibilityStorageKey) {
|
||||
setVisibleColumnIds((current) => current ?? allColumnIds);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(columnVisibilityStorageKey);
|
||||
if (!rawValue) {
|
||||
setVisibleColumnIds(allColumnIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedValue = JSON.parse(rawValue);
|
||||
if (!Array.isArray(parsedValue)) {
|
||||
setVisibleColumnIds(allColumnIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedIds = parsedValue.filter(
|
||||
(value): value is string =>
|
||||
typeof value === "string" && allColumnIds.includes(value)
|
||||
);
|
||||
|
||||
setVisibleColumnIds(sanitizedIds.length > 0 ? sanitizedIds : allColumnIds);
|
||||
} catch {
|
||||
setVisibleColumnIds(allColumnIds);
|
||||
}
|
||||
}, [columnVisibilityEnabled, columnVisibilityStorageKey, normalizedColumns]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!columnVisibilityEnabled || !columnVisibilityStorageKey || !visibleColumnIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
window.localStorage.setItem(
|
||||
columnVisibilityStorageKey,
|
||||
JSON.stringify(visibleColumnIds)
|
||||
);
|
||||
}, [columnVisibilityEnabled, columnVisibilityStorageKey, visibleColumnIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!columnVisibilityEnabled || !isColumnsMenuOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
columnsMenuRef.current?.contains(target) ||
|
||||
columnsButtonRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsColumnsMenuOpen(false);
|
||||
setColumnsMenuPos(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [columnVisibilityEnabled, isColumnsMenuOpen]);
|
||||
|
||||
const resolvedVisibleColumnIds = React.useMemo(() => {
|
||||
if (!columnVisibilityEnabled) {
|
||||
return normalizedColumns.map((column) => column._columnId);
|
||||
}
|
||||
|
||||
const allColumnIds = normalizedColumns.map((column) => column._columnId);
|
||||
const safeVisibleIds =
|
||||
visibleColumnIds?.filter((id) => allColumnIds.includes(id)) ?? allColumnIds;
|
||||
|
||||
return safeVisibleIds.length > 0 ? safeVisibleIds : allColumnIds;
|
||||
}, [columnVisibilityEnabled, normalizedColumns, visibleColumnIds]);
|
||||
|
||||
const visibleColumns = React.useMemo(
|
||||
() =>
|
||||
normalizedColumns.filter((column) =>
|
||||
resolvedVisibleColumnIds.includes(column._columnId)
|
||||
),
|
||||
[normalizedColumns, resolvedVisibleColumnIds]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!manualPagination) {
|
||||
setInternalPage(1);
|
||||
}
|
||||
}, [internalSearch, internalPageSize, manualPagination]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (manualPagination || controlledPageSize !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInternalPageSize(
|
||||
resolveStoredTablePageSize({
|
||||
storageKey: pageSizeStorageKey,
|
||||
pageSizeOptions,
|
||||
defaultPageSize,
|
||||
})
|
||||
);
|
||||
}, [
|
||||
controlledPageSize,
|
||||
defaultPageSize,
|
||||
manualPagination,
|
||||
pageSizeOptions,
|
||||
pageSizeStorageKey,
|
||||
]);
|
||||
|
||||
const searchableColumns = React.useMemo(
|
||||
() => columns.filter((c) => c.searchable !== false),
|
||||
[columns]
|
||||
() => visibleColumns.filter((c) => c.searchable !== false),
|
||||
[visibleColumns]
|
||||
);
|
||||
|
||||
const getCellValue = React.useCallback(
|
||||
@@ -162,6 +327,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
|
||||
const exportCurrentView = () => {
|
||||
const header = columns
|
||||
.filter((c) => resolvedVisibleColumnIds.includes(getColumnId(c)))
|
||||
.map((c) =>
|
||||
toCSVValue(
|
||||
c.exportHeader ??
|
||||
@@ -170,7 +336,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
)
|
||||
.join(",");
|
||||
const lines = pageRows.map((row) =>
|
||||
columns
|
||||
visibleColumns
|
||||
.map((c) => {
|
||||
const value = c.render ? c.render(row) : getCellValue(row, c.key);
|
||||
if (typeof value === "string" || typeof value === "number")
|
||||
@@ -282,6 +448,74 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColumnVisibility = (columnId: string) => {
|
||||
if (!columnVisibilityEnabled) return;
|
||||
|
||||
setDraftVisibleColumnIds((current) => {
|
||||
const allColumnIds = normalizedColumns.map((column) => column._columnId);
|
||||
const currentIds = current.length > 0 ? current : allColumnIds;
|
||||
const isVisible = currentIds.includes(columnId);
|
||||
|
||||
if (isVisible) {
|
||||
if (currentIds.length === 1) {
|
||||
return currentIds;
|
||||
}
|
||||
return currentIds.filter((id) => id !== columnId);
|
||||
}
|
||||
|
||||
return allColumnIds.filter(
|
||||
(id) => id === columnId || currentIds.includes(id)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const openColumnsMenu = () => {
|
||||
if (!columnVisibilityEnabled || !columnsButtonRef.current) return;
|
||||
|
||||
const rect = columnsButtonRef.current.getBoundingClientRect();
|
||||
const popoverWidth = Math.min(420, window.innerWidth - 32);
|
||||
const preferredLeft = rect.right - popoverWidth;
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(16, preferredLeft),
|
||||
Math.max(16, window.innerWidth - popoverWidth - 16)
|
||||
);
|
||||
|
||||
setColumnsMenuPos({ top: rect.bottom + 8, left: clampedLeft });
|
||||
setDraftVisibleColumnIds(resolvedVisibleColumnIds);
|
||||
setIsColumnsMenuOpen(true);
|
||||
};
|
||||
|
||||
const closeColumnsMenu = () => {
|
||||
setIsColumnsMenuOpen(false);
|
||||
setColumnsMenuPos(null);
|
||||
};
|
||||
|
||||
const handleColumnsMenuToggle = () => {
|
||||
if (isColumnsMenuOpen) {
|
||||
closeColumnsMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
openColumnsMenu();
|
||||
};
|
||||
|
||||
const handleClearColumns = () => {
|
||||
const allColumnIds = normalizedColumns.map((column) => column._columnId);
|
||||
setDraftVisibleColumnIds(allColumnIds);
|
||||
setVisibleColumnIds(allColumnIds);
|
||||
closeColumnsMenu();
|
||||
};
|
||||
|
||||
const handleApplyColumns = () => {
|
||||
const nextVisibleIds =
|
||||
draftVisibleColumnIds.length > 0
|
||||
? draftVisibleColumnIds
|
||||
: normalizedColumns.map((column) => column._columnId);
|
||||
|
||||
setVisibleColumnIds(nextVisibleIds);
|
||||
closeColumnsMenu();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -343,19 +577,104 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex items-center gap-2">
|
||||
{filterControls}
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={exportCurrentView}
|
||||
leftIcon={<Upload size={16} />}
|
||||
>
|
||||
{t('actions.export')}
|
||||
</CustomButton>
|
||||
{columnVisibilityEnabled ? (
|
||||
<div className="relative" ref={columnsButtonRef}>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={handleColumnsMenuToggle}
|
||||
leftIcon={<Columns3 size={16} />}
|
||||
>
|
||||
{t("actions.columns", { defaultValue: "Columns" })}
|
||||
</CustomButton>
|
||||
</div>
|
||||
) : null}
|
||||
{exportEnabled ? (
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={exportCurrentView}
|
||||
leftIcon={<Upload size={16} />}
|
||||
>
|
||||
{t('actions.export')}
|
||||
</CustomButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isColumnsMenuOpen && columnsMenuPos
|
||||
? createPortal(
|
||||
<div
|
||||
ref={columnsMenuRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: columnsMenuPos.top,
|
||||
left: columnsMenuPos.left,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="w-[420px] max-w-[calc(100vw-32px)] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_24px_60px_rgba(15,23,42,0.18)]"
|
||||
>
|
||||
<div className="border-b border-slate-200 px-4 py-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{t("actions.columns", { defaultValue: "Columns" })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[260px] overflow-y-auto py-2">
|
||||
{normalizedColumns.map((column) => {
|
||||
const columnId = column._columnId;
|
||||
const isChecked = draftVisibleColumnIds.includes(columnId);
|
||||
const isLastVisible =
|
||||
isChecked && draftVisibleColumnIds.length === 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={columnId}
|
||||
type="button"
|
||||
onClick={() => toggleColumnVisibility(columnId)}
|
||||
disabled={isLastVisible}
|
||||
className="flex w-full items-start gap-3 px-4 py-2 text-left text-sm text-slate-700 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent"
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
isChecked
|
||||
? "border-blue-500 bg-blue-500 text-white"
|
||||
: "border-slate-300 bg-white text-transparent"
|
||||
}`}
|
||||
>
|
||||
<Check size={12} strokeWidth={3} />
|
||||
</span>
|
||||
<span className="whitespace-normal break-words leading-5 text-slate-900">
|
||||
{getColumnVisibilityLabel(column, column._fallbackLabel)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearColumns}
|
||||
className="text-sm font-medium text-slate-500 transition hover:text-slate-700"
|
||||
>
|
||||
{t("actions.clear", { defaultValue: "Clear" })}
|
||||
</button>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={handleApplyColumns}
|
||||
className="!h-9 !rounded-xl !px-4 !text-sm"
|
||||
>
|
||||
{t("actions.apply", { defaultValue: "Apply" })}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
|
||||
<div
|
||||
className="max-w-full overflow-x-auto overflow-y-auto relative [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-[#1D2A4B] [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-[#1D2A4B]/80 [&::-webkit-scrollbar:vertical]:hidden"
|
||||
style={{ maxHeight }}
|
||||
@@ -363,7 +682,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
<table className="min-w-max w-full text-sm">
|
||||
<thead className="bg-[var(--table-header-bg)] sticky top-0 z-10 shadow-sm">
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
{columns.map((c, idx) => (
|
||||
{visibleColumns.map((c, idx) => (
|
||||
<th
|
||||
key={String(c.key) + idx}
|
||||
className={cn(
|
||||
@@ -380,15 +699,11 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
colSpan={visibleColumns.length}
|
||||
className="px-6 py-24 text-center text-[var(--text-secondary)]"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg className="animate-spin h-8 w-8 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p>{t('common.loading')}</p>
|
||||
<div className="flex flex-col items-center justify-center min-h-[100px]">
|
||||
<Loader />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -405,7 +720,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
isHighlighted && highlightClassName
|
||||
)}
|
||||
>
|
||||
{columns.map((c, ci) => {
|
||||
{visibleColumns.map((c, ci) => {
|
||||
const content = c.render
|
||||
? c.render(row)
|
||||
: (getCellValue(row, c.key) as React.ReactNode);
|
||||
@@ -427,7 +742,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
colSpan={visibleColumns.length}
|
||||
className="px-6 py-12 text-center text-[var(--text-secondary)]"
|
||||
>
|
||||
{t('common.noData')}
|
||||
@@ -452,6 +767,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
const newSize = Number(e.target.value);
|
||||
persistTablePageSize(pageSizeStorageKey, newSize, pageSizeOptions);
|
||||
if (manualPagination && onPageSizeChange) {
|
||||
onPageSizeChange(newSize);
|
||||
} else {
|
||||
@@ -507,4 +823,4 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
);
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
export default DataTable;
|
||||
|
||||
@@ -20,6 +20,8 @@ import CustomIncrement from "./CustomIncrement";
|
||||
import CustomLoader from "./CustomLoader";
|
||||
import CustomActionMenu, { CustomActionItem } from "./CustomActionMenu";
|
||||
import CustomStatus from "./CustomStatus";
|
||||
import { CustomPhoneInput } from "./CustomPhoneInput";
|
||||
import CustomColumnFilter from "./CustomColumnFilter";
|
||||
|
||||
export {
|
||||
CustomInput,
|
||||
@@ -44,5 +46,6 @@ export {
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomStatus,
|
||||
|
||||
CustomPhoneInput,
|
||||
CustomColumnFilter,
|
||||
};
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import {
|
||||
Menu,
|
||||
ChevronDown,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useSidebar } from "../../context/SidebarContext";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { authApi } from "../../application/authentication/AuthApi";
|
||||
import { clearAuthCookies } from "../../lib/authCookies";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
const AppHeader: React.FC = () => {
|
||||
const { t } = useTranslation(['profile', 'common']);
|
||||
const navigate = useNavigate();
|
||||
const { user, } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const { toggleMobileSidebar } = useSidebar();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -50,12 +52,12 @@ const AppHeader: React.FC = () => {
|
||||
: "U";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex w-full bg-[var(--header-bg)] border-b border-[var(--header-border)] transition-colors duration-200">
|
||||
<header className="sticky top-0 z-40 flex w-full bg-white/80 backdrop-blur-md border-b border-gray-200 transition-all duration-200">
|
||||
<div className="flex flex-grow items-center gap-4 px-4 py-3 lg:px-6">
|
||||
{/* LEFT: Mobile Toggle & Brand (Mobile only) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="flex items-center justify-center p-2 text-[var(--header-text)] hover:bg-[var(--header-border)] rounded-lg lg:hidden transition-colors"
|
||||
className="flex items-center justify-center p-2 text-gray-500 hover:bg-gray-100 rounded-lg lg:hidden transition-colors"
|
||||
onClick={toggleMobileSidebar}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
@@ -63,67 +65,12 @@ const AppHeader: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<Link to="/" className="lg:hidden flex items-center gap-2">
|
||||
<span className="font-bold text-[var(--header-text)] text-lg">F&L</span>
|
||||
<span className="font-bold text-gray-900 text-lg">SaaS</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Logistics Network Pattern - Multi-layer Design */}
|
||||
<div className="hidden lg:flex items-center overflow-hidden">
|
||||
{/* ... existing SVG content ... */}
|
||||
<svg className="w-80 h-10" viewBox="0 0 320 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Background grid pattern */}
|
||||
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="currentColor" strokeOpacity="0.1" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||
</pattern>
|
||||
<rect width="320" height="40" fill="url(#grid)" />
|
||||
|
||||
{/* Multiple route paths */}
|
||||
<path d="M0 30 C40 30, 60 10, 100 10 S160 30, 200 20 S260 10, 320 15" stroke="currentColor" strokeOpacity="0.2" strokeWidth="2" fill="none" className="text-[var(--header-text)]" />
|
||||
<path d="M0 20 C50 15, 80 25, 120 20 S180 10, 220 15 S280 25, 320 20" stroke="currentColor" strokeOpacity="0.3" strokeWidth="1.5" fill="none" className="text-[var(--header-text)]" />
|
||||
<path d="M0 10 C30 15, 70 5, 110 12 S170 20, 210 12 S270 8, 320 12" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1" strokeDasharray="4 2" fill="none" className="text-[var(--header-text)]" />
|
||||
|
||||
{/* Hub nodes with glow effect */}
|
||||
<circle cx="40" cy="25" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.5" className="text-[var(--header-text)]" />
|
||||
<circle cx="40" cy="25" r="3" fill="currentColor" className="text-[var(--primary)]" />
|
||||
|
||||
<circle cx="120" cy="15" r="8" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.5" strokeWidth="2" className="text-[var(--header-text)]" />
|
||||
<circle cx="120" cy="15" r="4" fill="currentColor" className="text-[var(--primary)]" />
|
||||
|
||||
<circle cx="200" cy="18" r="6" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.4" strokeWidth="1.5" className="text-[var(--header-text)]" />
|
||||
<circle cx="200" cy="18" r="3" fill="currentColor" className="text-[var(--primary)]" />
|
||||
|
||||
<circle cx="280" cy="16" r="5" fill="currentColor" fillOpacity="0.1" stroke="currentColor" strokeOpacity="0.3" strokeWidth="1" className="text-[var(--header-text)]" />
|
||||
<circle cx="280" cy="16" r="2.5" fill="currentColor" className="text-[var(--primary)]" />
|
||||
|
||||
{/* Package icons */}
|
||||
<g transform="translate(70, 22)">
|
||||
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-[var(--header-text)]" />
|
||||
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-[var(--header-text)]" />
|
||||
</g>
|
||||
|
||||
<g transform="translate(160, 8)">
|
||||
<rect width="10" height="8" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
|
||||
<line x1="0" y1="3" x2="10" y2="3" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||
<line x1="5" y1="3" x2="5" y2="8" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||
</g>
|
||||
|
||||
<g transform="translate(240, 20)">
|
||||
<rect width="8" height="6" rx="1" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.8" className="text-(--header-text)" />
|
||||
<line x1="0" y1="2" x2="8" y2="2" stroke="currentColor" strokeWidth="0.5" className="text-(--header-text)" />
|
||||
</g>
|
||||
|
||||
{/* Connecting dots */}
|
||||
<circle cx="20" cy="22" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||
<circle cx="90" cy="12" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||
<circle cx="150" cy="22" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||
<circle cx="230" cy="14" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||
<circle cx="300" cy="18" r="1.5" fill="currentColor" fillOpacity="0.5" className="text-(--header-text)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Actions */}
|
||||
<div className="flex items-center gap-2 sm:gap-4 ml-auto">
|
||||
<div className="flex items-center gap-2 sm:gap-4 ltr:ml-auto rtl:mr-auto">
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
@@ -132,11 +79,10 @@ const AppHeader: React.FC = () => {
|
||||
onClick={() => setIsUserMenuOpen((prev) => !prev)}
|
||||
className="
|
||||
flex items-center gap-2
|
||||
pl-2 pr-1 py-1.5
|
||||
rounded-lg
|
||||
hover:bg-(--header-border)
|
||||
transition-colors
|
||||
border border-transparent hover:border-(--header-border)
|
||||
p-1 pr-3
|
||||
rounded-full border border-gray-200
|
||||
hover:border-gray-300 hover:bg-gray-50
|
||||
transition-all bg-white/50
|
||||
"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
>
|
||||
@@ -144,12 +90,12 @@ const AppHeader: React.FC = () => {
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block text-left mr-1">
|
||||
<p className="text-sm font-medium text-(--header-text) leading-none">{fullName}</p>
|
||||
<div className="hidden md:block ltr:text-left rtl:text-right ltr:mr-1 rtl:ml-1 leading-tight">
|
||||
<p className="text-sm font-semibold text-gray-700">{fullName}</p>
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
size={16}
|
||||
size={14}
|
||||
className={`text-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
@@ -159,33 +105,45 @@ const AppHeader: React.FC = () => {
|
||||
{isUserMenuOpen && (
|
||||
<div
|
||||
className="
|
||||
absolute right-0 mt-2 w-56
|
||||
rounded-xl border border-(--card-border)
|
||||
bg-(--card-bg)
|
||||
shadow-md
|
||||
absolute ltr:right-0 rtl:left-0 mt-2 w-64
|
||||
rounded-xl border border-gray-100
|
||||
bg-white
|
||||
shadow-xl
|
||||
overflow-hidden
|
||||
animate-in fade-in zoom-in-95 duration-150
|
||||
origin-top-right
|
||||
ltr:origin-top-right rtl:origin-top-left
|
||||
z-50
|
||||
"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-(--card-border) bg-(--background)">
|
||||
<p className="text-xs text-(--text-secondary) truncate mt-0.5">
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-gray-50/50">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate mt-0.5 uppercase tracking-wider">
|
||||
{user?.role?.role_name || "User"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
<Link
|
||||
to="/profile"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-(--text-primary) hover:bg-(--background-secondary) transition-colors"
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-500">
|
||||
<ChevronDown size={16} className="-rotate-90" />
|
||||
</div>
|
||||
{t('profile:header.profileSettings')}
|
||||
</Link>
|
||||
|
||||
<div className="h-px bg-gray-100 my-1 mx-2"></div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-red-50 flex items-center justify-center text-red-600">
|
||||
<LogOut size={16} />
|
||||
</div>
|
||||
{t('profile:header.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,15 @@ import { SidebarProvider, useSidebar } from "../../context/SidebarContext";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import AppHeader from "./AppHeader";
|
||||
import AppSidebar from "./AppSidebar";
|
||||
import Backdrop from "./Backdrop";
|
||||
|
||||
const LayoutContent: React.FC = () => {
|
||||
const { isExpanded } = useSidebar();
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] transition-colors duration-200 flex flex-col">
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col transition-all duration-300">
|
||||
<AppSidebar />
|
||||
<Backdrop />
|
||||
|
||||
<div
|
||||
className={`
|
||||
@@ -20,9 +20,10 @@ const LayoutContent: React.FC = () => {
|
||||
>
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-6 animate-fade-in w-full max-w-[1920px] mx-auto">
|
||||
{/* Outlet Container - Ensures content is constrained but responsive */}
|
||||
<Outlet />
|
||||
<main className="flex-1 p-4 sm:p-6 lg:p-8 animate-in fade-in duration-500">
|
||||
<div className="max-w-[1600px] mx-auto w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
|
||||
CreditCard,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useSidebar } from "../../context/SidebarContext";
|
||||
import { usePermission } from "../../lib/usePermission";
|
||||
@@ -52,10 +53,10 @@ const navItems: NavItem[] = [
|
||||
access: "superadmin.tenant.read",
|
||||
},
|
||||
{
|
||||
icon: <UsersRound size={22} />,
|
||||
name: "Users",
|
||||
path: "/users",
|
||||
access: "admin.user.read",
|
||||
icon: <CreditCard size={22} />,
|
||||
name: "Subscriptions",
|
||||
path: "/subscriptions",
|
||||
access: "superadmin.plan.read",
|
||||
},
|
||||
{
|
||||
icon: <UserCog size={22} />,
|
||||
@@ -63,12 +64,30 @@ const navItems: NavItem[] = [
|
||||
path: "/roles",
|
||||
access: "admin.role.read",
|
||||
},
|
||||
{
|
||||
icon: <UsersRound size={22} />,
|
||||
name: "Users",
|
||||
path: "/users",
|
||||
access: "admin.user.read",
|
||||
},
|
||||
{
|
||||
icon: <Palette size={22} />,
|
||||
name: "Themes",
|
||||
path: "/theme",
|
||||
access: "superadmin.palette.read",
|
||||
},
|
||||
{
|
||||
icon: <Package size={22} />,
|
||||
name: "Modules",
|
||||
path: "/admin/modules",
|
||||
access: "modules.view",
|
||||
},
|
||||
{
|
||||
icon: <FileText size={22} />,
|
||||
name: "Logs",
|
||||
path: "/logs",
|
||||
access: "admin.logs.read",
|
||||
},
|
||||
{
|
||||
icon: <Settings size={22} />,
|
||||
name: "Settings",
|
||||
@@ -77,7 +96,7 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
const AppSidebar: React.FC = () => {
|
||||
const { t } = useTranslation('common');
|
||||
const { t } = useTranslation(['common', 'modules', 'dashboard']);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
isExpanded,
|
||||
@@ -104,23 +123,24 @@ const AppSidebar: React.FC = () => {
|
||||
'Packaging': 'packaging',
|
||||
'Dropdowns': 'dropdowns',
|
||||
'Settings': 'settings',
|
||||
'Modules': 'modules',
|
||||
'Logs': 'logs',
|
||||
'Subscriptions': 'subscriptions',
|
||||
};
|
||||
|
||||
const key = keyMap[name];
|
||||
if (!key) {
|
||||
console.warn(`Missing translation key for: ${name}`);
|
||||
return name;
|
||||
if (key) {
|
||||
return t(`nav.${key}`, name);
|
||||
}
|
||||
|
||||
return t(`nav.${key}`, name);
|
||||
|
||||
return t([
|
||||
`modules:${name}`,
|
||||
`dashboard:${name}`,
|
||||
`common:nav.${name}`,
|
||||
name
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
const isActive = useCallback(
|
||||
(path: string) => location.pathname === path || location.pathname.startsWith(path + "/"),
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const visibleNavItems = useMemo(
|
||||
() => navItems.filter((item) => {
|
||||
if (!item.access) return true;
|
||||
@@ -134,37 +154,55 @@ const AppSidebar: React.FC = () => {
|
||||
[can]
|
||||
);
|
||||
|
||||
const isActive = useCallback(
|
||||
(path: string) => location.pathname === path || location.pathname.startsWith(path + "/"),
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const groupedNavItems = useMemo(() => {
|
||||
const groups: { label: string; items: NavItem[] }[] = [];
|
||||
const addedPaths = new Set<string>();
|
||||
|
||||
const getItems = (names: string[]) => {
|
||||
const filtered = visibleNavItems.filter(item =>
|
||||
names.includes(item.name) && !addedPaths.has(item.path)
|
||||
);
|
||||
filtered.forEach(item => addedPaths.add(item.path));
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// 1. Overview
|
||||
const dashboardItems = getItems(["Dashboard"]);
|
||||
if (dashboardItems.length > 0) {
|
||||
groups.push({ label: t('nav.groups.overview', 'Overview'), items: dashboardItems });
|
||||
}
|
||||
|
||||
// 2. Management
|
||||
const managementItems = getItems(["Tenants", "Subscriptions", "Roles", "Users"]);
|
||||
if (managementItems.length > 0) {
|
||||
groups.push({ label: t('nav.groups.management', 'Management'), items: managementItems });
|
||||
}
|
||||
|
||||
// 3. System
|
||||
const systemItems = getItems(["Themes", "Modules", "Logs", "Settings"]);
|
||||
if (systemItems.length > 0) {
|
||||
groups.push({ label: t('nav.groups.system', 'System'), items: systemItems });
|
||||
}
|
||||
|
||||
// Catch-all
|
||||
const remaining = visibleNavItems.filter(item => !addedPaths.has(item.path));
|
||||
if (remaining.length > 0) {
|
||||
groups.push({ label: t('nav.groups.other', 'Other'), items: remaining });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [visibleNavItems, t]);
|
||||
|
||||
const [openSubmenus, setOpenSubmenus] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
|
||||
setOpenSubmenus(prev => {
|
||||
const newState = { ...prev };
|
||||
|
||||
Object.keys(newState).forEach(menuName => {
|
||||
if (newState[menuName]) {
|
||||
const navItem = navItems.find(item => item.name === menuName);
|
||||
|
||||
if (navItem && navItem.submenu) {
|
||||
const isMatchingSubmenu = navItem.submenu.some(subItem =>
|
||||
currentPath === subItem.path || currentPath.startsWith(subItem.path + "/")
|
||||
);
|
||||
|
||||
if (!isMatchingSubmenu) {
|
||||
newState[menuName] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newState;
|
||||
});
|
||||
}, [location.pathname]);
|
||||
|
||||
const toggleSubmenu = (name: string) => {
|
||||
if (!isExpanded && !isMobile) {
|
||||
toggleSidebar(); // Auto expand when clicking submenu in collapsed state
|
||||
toggleSidebar();
|
||||
}
|
||||
setOpenSubmenus(prev => ({ ...prev, [name]: !prev[name] }));
|
||||
};
|
||||
@@ -183,25 +221,15 @@ const AppSidebar: React.FC = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sidebar width logic
|
||||
const sidebarWidth = isExpanded ? "w-[260px]" : "w-[80px]";
|
||||
const mobileClasses = isMobileOpen ? "translate-x-0" : "-translate-x-full";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden backdrop-blur-sm transition-opacity duration-300 animate-fade-in"
|
||||
onClick={closeMobileSidebar}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`
|
||||
fixed top-0 left-0 z-50 h-screen bg-[var(--sidebar-bg)] text-[var(--sidebar-text)] flex flex-col
|
||||
transition-all duration-300 ease-in-out border-r border-[var(--sidebar-border)]
|
||||
transition-all duration-300 ease-in-out border-r border-[var(--sidebar-border)]
|
||||
lg:translate-x-0 overflow-x-hidden
|
||||
${isMobile ? "w-[280px]" : sidebarWidth}
|
||||
${isMobile ? mobileClasses : ""}
|
||||
@@ -209,8 +237,7 @@ const AppSidebar: React.FC = () => {
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`h-[70px] flex items-center px-4 border-b border-(--sidebar-border)/50 ${!isExpanded && !isMobile ? 'justify-center' : 'justify-between'}`}>
|
||||
{/* Logo - Only show when expanded or mobile */}
|
||||
<div className={`h-[70px] flex items-center px-4 border-b border-white/5 ${!isExpanded && !isMobile ? 'justify-center' : 'justify-between'}`}>
|
||||
{(isExpanded || isMobile) && (
|
||||
<Link to="/dashboard" className="flex items-center gap-3 overflow-hidden ml-1" onClick={isMobile ? closeMobileSidebar : undefined}>
|
||||
{user?.role?.accesses?.includes("superadmin.tenant.read") ? (
|
||||
@@ -226,163 +253,166 @@ const AppSidebar: React.FC = () => {
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="shrink-0 text-blue-400">
|
||||
<Package size={32} />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-(--sidebar-active-text) whitespace-nowrap">
|
||||
F&L
|
||||
</span>
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0 w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold">S</div>
|
||||
<span className="text-xl font-bold text-white whitespace-nowrap">SaaS</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Desktop Toggle Button */}
|
||||
{!isMobile && (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className={`p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 transition-colors ${!isExpanded ? "" : ""}`}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
<PanelLeft size={20} className={!isExpanded ? "rotate-180" : ""} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile Close Button */}
|
||||
{isMobile && (
|
||||
<button onClick={closeMobileSidebar} className="p-1 rounded-md text-slate-400 hover:text-white hover:bg-white/10">
|
||||
<button
|
||||
onClick={closeMobileSidebar}
|
||||
className="p-1 rounded-md text-slate-400 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-6 px-3 custom-scrollbar">
|
||||
<ul className="space-y-2">
|
||||
{visibleNavItems.map((nav) => {
|
||||
const active = isActive(nav.path);
|
||||
const hasSubmenu = nav.submenu && nav.submenu.length > 0;
|
||||
const isMenuOpen = openSubmenus[nav.name];
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-4 px-3 custom-scrollbar">
|
||||
<div className="flex flex-col gap-6">
|
||||
{groupedNavItems.map((group, idx) => (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
{(isExpanded || isMobile) && (
|
||||
<p className="px-3 text-[10px] font-bold text-slate-500 uppercase tracking-[0.15em] mb-2 mt-2">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
{group.items.map((nav) => {
|
||||
const active = isActive(nav.path);
|
||||
const hasSubmenu = nav.submenu && nav.submenu.length > 0;
|
||||
const isMenuOpen = openSubmenus[nav.name];
|
||||
|
||||
if (hasSubmenu) {
|
||||
return (
|
||||
<li key={nav.name}>
|
||||
<button
|
||||
onClick={() => toggleSubmenu(nav.name)}
|
||||
className={`
|
||||
w-full relative flex items-center gap-3 px-3 py-3 rounded-lg transition-all duration-200 group
|
||||
${isMenuOpen
|
||||
? "bg-white/5 text-(--sidebar-active-text)"
|
||||
: "text-(--sidebar-text) hover:bg-(--sidebar-hover-bg)"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{nav.icon}
|
||||
</div>
|
||||
if (hasSubmenu) {
|
||||
return (
|
||||
<li key={nav.name}>
|
||||
<button
|
||||
onClick={() => toggleSubmenu(nav.name)}
|
||||
className={`
|
||||
w-full relative flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 group
|
||||
${isMenuOpen
|
||||
? "bg-white/5 text-white"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{nav.icon}
|
||||
</div>
|
||||
|
||||
{(isExpanded || isMobile) && (
|
||||
<>
|
||||
<span className="flex-1 text-left whitespace-nowrap font-medium">
|
||||
{(isExpanded || isMobile) && (
|
||||
<>
|
||||
<span className="flex-1 text-left whitespace-nowrap font-medium text-sm">
|
||||
{getNavTranslation(nav.name)}
|
||||
</span>
|
||||
<span className="flex-shrink-0 transition-transform duration-200">
|
||||
{isMenuOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className={`overflow-hidden transition-all duration-300 ${isMenuOpen && (isExpanded || isMobile) ? "max-h-[800px] opacity-100 mt-1" : "max-h-0 opacity-0"}`}>
|
||||
<ul className="space-y-1 pl-4 mt-1 border-l border-white/5 ml-4">
|
||||
{nav.submenu?.filter(subItem => !subItem.access || can(subItem.access)).map((subItem) => {
|
||||
const subActive = isActive(subItem.path || "");
|
||||
return (
|
||||
<li key={subItem.name}>
|
||||
<Link
|
||||
to={subItem.path || "#"}
|
||||
onClick={isMobile ? closeMobileSidebar : undefined}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors block
|
||||
${subActive
|
||||
? "text-white font-bold"
|
||||
: "text-slate-500 hover:text-slate-300 hover:bg-white/5"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{subItem.icon && (
|
||||
<div className="flex-shrink-0 scale-75">
|
||||
{subItem.icon}
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate">{getNavTranslation(subItem.name)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={nav.name}>
|
||||
<Link
|
||||
to={nav.path}
|
||||
onClick={isMobile ? closeMobileSidebar : undefined}
|
||||
className={`
|
||||
relative flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 group
|
||||
${active
|
||||
? "bg-white text-slate-900"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{nav.icon}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`
|
||||
whitespace-nowrap font-semibold text-sm transition-all duration-300
|
||||
${(isExpanded || isMobile) ? "opacity-100 w-auto" : "opacity-0 w-0 hidden"}
|
||||
`}
|
||||
>
|
||||
{getNavTranslation(nav.name)}
|
||||
</span>
|
||||
<span className="flex-shrink-0 transition-transform duration-200">
|
||||
{isMenuOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!isExpanded && !isMobile && (
|
||||
<div className="absolute left-full ml-4 px-2 py-1 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 whitespace-nowrap pointer-events-none">
|
||||
{getNavTranslation(nav.name)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Submenu Items */}
|
||||
<div className={`overflow-hidden transition-all duration-300 ${isMenuOpen && (isExpanded || isMobile) ? "max-h-96 opacity-100 mt-1" : "max-h-0 opacity-0"}`}>
|
||||
<ul className="space-y-1 pl-9">
|
||||
{nav.submenu?.filter(subItem => !subItem.access || can(subItem.access)).map((subItem) => {
|
||||
const subActive = isActive(subItem.path || "");
|
||||
return (
|
||||
<li key={subItem.name}>
|
||||
<Link
|
||||
to={subItem.path || "#"}
|
||||
onClick={isMobile ? closeMobileSidebar : undefined}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors block
|
||||
${subActive
|
||||
? "bg-(--sidebar-active-bg) text-(--sidebar-active-text) font-medium"
|
||||
: "text-(--sidebar-text-muted) hover:text-(--sidebar-text) hover:bg-(--sidebar-hover-bg)"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{subItem.icon && (
|
||||
<div className="flex-shrink-0 scale-90">
|
||||
{subItem.icon}
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate">{getNavTranslation(subItem.name)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={nav.name}>
|
||||
<Link
|
||||
to={nav.path}
|
||||
onClick={isMobile ? closeMobileSidebar : undefined}
|
||||
className={`
|
||||
relative flex items-center gap-3 px-3 py-3 rounded-lg transition-all duration-200 group
|
||||
${active
|
||||
? "bg-(--sidebar-active-bg) text-(--sidebar-active-text)"
|
||||
: "text-(--sidebar-text) hover:bg-(--sidebar-hover-bg)"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{nav.icon}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`
|
||||
whitespace-nowrap font-medium transition-all duration-300
|
||||
${(isExpanded || isMobile) ? "opacity-100 w-auto" : "opacity-0 w-0 hidden"}
|
||||
`}
|
||||
>
|
||||
{getNavTranslation(nav.name)}
|
||||
</span>
|
||||
|
||||
{/* Tooltip for collapsed mode */}
|
||||
{!isExpanded && !isMobile && (
|
||||
<div className="absolute left-full ml-4 px-2 py-1 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 whitespace-nowrap pointer-events-none">
|
||||
{getNavTranslation(nav.name)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{!isExpanded && !isMobile && (
|
||||
<div className="absolute left-full ml-4 px-2 py-1 bg-slate-800 text-white text-xs rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50 whitespace-nowrap pointer-events-none">
|
||||
{getNavTranslation(nav.name)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-slate-700/50">
|
||||
{/* Logout */}
|
||||
<div className="p-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`flex items-center gap-3 w-full px-3 py-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors overflow-hidden ${(isExpanded || isMobile) ? "justify-start" : "justify-center"}`}
|
||||
className={`group flex items-center gap-3 w-full px-3 py-2.5 text-slate-400 hover:text-white hover:bg-white/5 rounded-xl transition-all overflow-hidden ${(isExpanded || isMobile) ? "justify-start" : "justify-center"}`}
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<div className="flex-shrink-0 transition-colors group-hover:text-white">
|
||||
<LogOut size={20} />
|
||||
</div>
|
||||
{(isExpanded || isMobile) && (
|
||||
<span className="font-medium">{t('actions.signout')}</span>
|
||||
<span className="font-medium text-sm">Sign Out</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -391,4 +421,4 @@ const AppSidebar: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
export default AppSidebar;
|
||||
@@ -19,22 +19,9 @@ export default function AuthLayout({
|
||||
>
|
||||
<div className="absolute inset-0 " />
|
||||
<div className="relative flex items-center justify-center z-10">
|
||||
{/* <div className="flex flex-col items-center max-w-xs">
|
||||
<Link to="/" className="block mb-4">
|
||||
<img
|
||||
width={231}
|
||||
height={48}
|
||||
src="/images/logo/auth-logo.svg"
|
||||
alt="Logo"
|
||||
/>
|
||||
</Link>
|
||||
<p className="text-center text-gray-200 mt-2 text-sm font-medium">
|
||||
Streamline your global fulfillment and logistics operations.
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const Backdrop: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden animate-in fade-in duration-300"
|
||||
onClick={toggleMobileSidebar}
|
||||
/>
|
||||
);
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
// Use VITE_API_BASE_URL to match your backend host when working with cookies.
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||
@@ -61,7 +61,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const login = async (credentials: SigninRequest, rememberMe = false) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authApi.signin(credentials);
|
||||
const response = await authApi.signin({ ...credentials, remember_me: rememberMe });
|
||||
setAuthCookies(response, rememberMe);
|
||||
setUser(response.user);
|
||||
} finally {
|
||||
@@ -70,6 +70,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
} catch {
|
||||
}
|
||||
clearAuthCookies();
|
||||
setUser(null);
|
||||
window.location.href = "/signin";
|
||||
|
||||
+14
-17
@@ -4,22 +4,21 @@ import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import enCommon from './locales/en/common.json';
|
||||
import arCommon from './locales/ar/common.json';
|
||||
import enOrders from './locales/en/orders.json';
|
||||
import arOrders from './locales/ar/orders.json';
|
||||
import enUsers from './locales/en/users.json';
|
||||
import arUsers from './locales/ar/users.json';
|
||||
import enRoles from './locales/en/roles.json';
|
||||
import arRoles from './locales/ar/roles.json';
|
||||
import enPackaging from './locales/en/packaging.json';
|
||||
import arPackaging from './locales/ar/packaging.json';
|
||||
import enLocations from './locales/en/locations.json';
|
||||
import arLocations from './locales/ar/locations.json';
|
||||
import enProfile from './locales/en/profile.json';
|
||||
import arProfile from './locales/ar/profile.json';
|
||||
import enDashboard from './locales/en/dashboard.json';
|
||||
import arDashboard from './locales/ar/dashboard.json';
|
||||
import enDropdowns from './locales/en/dropdowns.json';
|
||||
import arDropdowns from './locales/ar/dropdowns.json';
|
||||
import enModules from './locales/en/modules.json';
|
||||
import arModules from './locales/ar/modules.json';
|
||||
import enLogs from './locales/en/logs.json';
|
||||
import arLogs from './locales/ar/logs.json';
|
||||
|
||||
import enTheme from './locales/en/theme.json';
|
||||
import arTheme from './locales/ar/theme.json';
|
||||
|
||||
export const languages = {
|
||||
en: { name: 'English', dir: 'ltr' },
|
||||
@@ -31,25 +30,23 @@ export type SupportedLanguage = keyof typeof languages;
|
||||
const resources = {
|
||||
en: {
|
||||
common: enCommon,
|
||||
orders: enOrders,
|
||||
users: enUsers,
|
||||
roles: enRoles,
|
||||
packaging: enPackaging,
|
||||
locations: enLocations,
|
||||
profile: enProfile,
|
||||
dashboard: enDashboard,
|
||||
dropdowns: enDropdowns,
|
||||
modules: enModules,
|
||||
logs: enLogs,
|
||||
theme: enTheme,
|
||||
},
|
||||
ar: {
|
||||
common: arCommon,
|
||||
orders: arOrders,
|
||||
users: arUsers,
|
||||
roles: arRoles,
|
||||
packaging: arPackaging,
|
||||
locations: arLocations,
|
||||
profile: arProfile,
|
||||
dashboard: arDashboard,
|
||||
dropdowns: arDropdowns,
|
||||
modules: arModules,
|
||||
logs: arLogs,
|
||||
theme: arTheme,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,4 +86,4 @@ if (i18next.language) {
|
||||
updateDirection(i18next.language);
|
||||
}
|
||||
|
||||
export default i18next;
|
||||
export default i18next;
|
||||
@@ -7,11 +7,13 @@
|
||||
"tenants": "المستأجرون",
|
||||
"users": "المستخدمون",
|
||||
"roles": "الأدوار",
|
||||
"logs": "السجلات",
|
||||
"themes": "المظاهر",
|
||||
"profile": "الملف الشخصي",
|
||||
"settings": "الإعدادات",
|
||||
"configuration": "التكوين",
|
||||
"dropdowns": "القوائم المنسدلة"
|
||||
"dropdowns": "القوائم المنسدلة",
|
||||
"modules": "الوحدات"
|
||||
},
|
||||
"actions": {
|
||||
"save": "حفظ",
|
||||
@@ -26,13 +28,16 @@
|
||||
"export": "تصدير",
|
||||
"import": "استيراد",
|
||||
"reset": "إعادة تعيين",
|
||||
"clear": "مسح",
|
||||
"submit": "إرسال",
|
||||
"apply": "تطبيق",
|
||||
"close": "إغلاق",
|
||||
"confirm": "تأكيد",
|
||||
"back": "رجوع",
|
||||
"next": "التالي",
|
||||
"previous": "السابق",
|
||||
"view": "عرض",
|
||||
"columns": "الأعمدة",
|
||||
"refresh": "تحديث",
|
||||
"signout": "تسجيل الخروج",
|
||||
"logout": "تسجيل الخروج"
|
||||
@@ -72,6 +77,7 @@
|
||||
"status": {
|
||||
"active": "نشط",
|
||||
"inactive": "غير نشط",
|
||||
"default": "افتراضي",
|
||||
"pending": "قيد الانتظار",
|
||||
"approved": "موافق عليه",
|
||||
"rejected": "مرفوض",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"title": "لوحة المعلومات قيد التطوير",
|
||||
"message": "نحن نعمل على إنشاء تجربة لوحة معلومات قوية لمنحك رؤى أفضل. يرجى التحقق مرة أخرى قريبا!"
|
||||
"title": "مرحباً بعودتك",
|
||||
"subtitle": "أدِر نظام SaaS الخاص بك من مركز واحد.",
|
||||
"allModules": "جميع الوحدات",
|
||||
"searchPlaceholder": "البحث في الوحدات المتاحة...",
|
||||
"actions": {
|
||||
"launch": "تشغيل الوحدة"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"title": "إعدادات القوائم المنسدلة",
|
||||
"noPermission": "ليس لديك صلاحية لعرض أي إعدادات قوائم منسدلة.",
|
||||
"tabs": {
|
||||
"locationTypes": "أنواع المواقع",
|
||||
"packageTypes": "أنواع الطرود",
|
||||
"packageMaterials": "مواد التغليف",
|
||||
"productCategories": "فئات المنتجات"
|
||||
},
|
||||
"locationTypes": {
|
||||
"title": "أنواع المواقع",
|
||||
"addButton": "إضافة نوع موقع",
|
||||
"editTitle": "تعديل نوع الموقع",
|
||||
"addTitle": "إضافة نوع موقع",
|
||||
"deleteTitle": "حذف نوع الموقع؟",
|
||||
"deleteMessage": "هل أنت متأكد أنك تريد حذف نوع الموقع هذا؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"name": "الاسم",
|
||||
"namePlaceholder": "أدخل اسم نوع الموقع"
|
||||
},
|
||||
"packageTypes": {
|
||||
"title": "أنواع الطرود",
|
||||
"addButton": "إضافة نوع طرد",
|
||||
"editTitle": "تعديل نوع الطرد",
|
||||
"addTitle": "إضافة نوع طرد",
|
||||
"deleteTitle": "حذف نوع الطرد؟",
|
||||
"deleteMessage": "هل أنت متأكد أنك تريد حذف نوع الطرد هذا؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"name": "الاسم",
|
||||
"namePlaceholder": "أدخل اسم نوع الطرد"
|
||||
},
|
||||
"packageMaterials": {
|
||||
"title": "مواد التغليف",
|
||||
"addButton": "إضافة مادة تغليف",
|
||||
"editTitle": "تعديل مادة التغليف",
|
||||
"addTitle": "إضافة مادة تغليف",
|
||||
"deleteTitle": "حذف مادة التغليف؟",
|
||||
"deleteMessage": "هل أنت متأكد أنك تريد حذف مادة التغليف هذه؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"name": "الاسم",
|
||||
"namePlaceholder": "أدخل اسم مادة التغليف"
|
||||
},
|
||||
"productCategories": {
|
||||
"title": "فئات المنتجات",
|
||||
"addButton": "إضافة فئة منتج",
|
||||
"editTitle": "تعديل فئة المنتج",
|
||||
"addTitle": "إضافة فئة منتج",
|
||||
"deleteTitle": "حذف فئة المنتج؟",
|
||||
"deleteMessage": "هل أنت متأكد أنك تريد حذف فئة المنتج هذه؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"name": "الاسم",
|
||||
"namePlaceholder": "أدخل اسم فئة المنتج"
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"title": "المواقع",
|
||||
"subTitle": "إدارة مواقع المستودعات والمراكز الخاصة بك.",
|
||||
"headers": {
|
||||
"addLocation": "إضافة موقع",
|
||||
"addNewLocation": "إضافة موقع جديد",
|
||||
"editLocation": "تعديل الموقع",
|
||||
"noLocationsFound": "لم يتم العثور على مواقع",
|
||||
"viewMap": "عرض الخريطة",
|
||||
"locationOnMap": "الموقع على الخريطة"
|
||||
},
|
||||
"messages": {
|
||||
"noLocationsDescription": "أضف مواقع المستودعات أو المراكز الخاصة بك لإدارة الاستلام بكفاءة.",
|
||||
"deleteConfirmation": "هل أنت متأكد أنك تريد حذف هذا الموقع؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"deleteTitle": "حذف الموقع؟",
|
||||
"saveSuccess": "تم حفظ الموقع بنجاح",
|
||||
"fetchError": "فشل جلب المواقع",
|
||||
"fetchDetailsError": "فشل جلب تفاصيل الموقع",
|
||||
"loading": "جارٍ التحميل..."
|
||||
},
|
||||
"fields": {
|
||||
"name": "اسم الموقع",
|
||||
"namePlaceholder": "مثال: المستودع الرئيسي، الوحدة المركزية 1",
|
||||
"type": "نوع الموقع",
|
||||
"locationType": "نوع الموقع",
|
||||
"selectType": "اختر النوع",
|
||||
"active": "نشط",
|
||||
"usedForRouting": "يستخدم للتوجيه",
|
||||
"warehouse": "مستودع",
|
||||
"hub": "مركز",
|
||||
"factory": "مصنع",
|
||||
"capabilities": "القدرات",
|
||||
"capabilitiesPlaceholder": "اختر القدرات...",
|
||||
"country": "الدولة",
|
||||
"zipCode": "الرمز البريدي",
|
||||
"state": "المنطقة / المحافظة",
|
||||
"city": "المدينة",
|
||||
"addressLine1": "العنوان السطر 1",
|
||||
"addressLine1Placeholder": "اسم الشارع، المبنى، إلخ.",
|
||||
"addressLine2": "العنوان السطر 2 (اختياري)",
|
||||
"addressLine2Placeholder": "شقة، جناح، وحدة، إلخ.",
|
||||
"latitude": "خط العرض",
|
||||
"longitude": "خط الطول",
|
||||
"selectState": "ختر المنطقة/المحافظة",
|
||||
"selectCity": "اختر المدينة",
|
||||
"autoFilled": "تعبئة تلقائية",
|
||||
"enterState": "أدخل المنطقة/المحافظة",
|
||||
"enterCity": "أدخل المدينة"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "تعديل",
|
||||
"delete": "حذف",
|
||||
"cancel": "إلغاء",
|
||||
"save": "حفظ الموقع",
|
||||
"saving": "جارٍ الحفظ...",
|
||||
"viewMap": "عرض الخريطة"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "الاسم مطلوب",
|
||||
"typeRequired": "النوع مطلوب",
|
||||
"addressRequired": "العنوان مطلوب",
|
||||
"zipCodeRequired": "الرمز البريدي مطلوب",
|
||||
"cityRequired": "المدينة مطلوبة",
|
||||
"stateRequired": "المنطقة/المحافظة مطلوبة",
|
||||
"countryRequired": "الدولة مطلوبة"
|
||||
},
|
||||
"capabilities": {
|
||||
"coldStorage": "تخزين بارد",
|
||||
"hazardousMaterials": "مواد خطرة",
|
||||
"access247": "وصول 24/7",
|
||||
"loadingDock": "رصيف تحميل",
|
||||
"forkliftAvailable": "رافعة شوكية متاحة",
|
||||
"cctvSurveillance": "مراقبة بالكاميرات",
|
||||
"climateControlled": "تحكم بالمناخ",
|
||||
"bondedWarehouse": "مستودع جمركي",
|
||||
"crossDocking": "توزيع مباشر (Cross Docking)",
|
||||
"orderFulfillment": "تلبية الطلبات",
|
||||
"inventoryManagement": "إدارة المخزون",
|
||||
"lastMileDelivery": "توصيل الميل الأخير",
|
||||
"returnsProcessing": "معالجة المرتجعات",
|
||||
"realTimeTracking": "تتبع في الوقت الفعلي",
|
||||
"fdaApproved": "معتمد من FDA",
|
||||
"customsClearance": "التخليص الجمركي"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "سجل النظام",
|
||||
"exportFileName": "audit_logs.csv",
|
||||
"columns": {
|
||||
"timestamp": "الطابع الزمني",
|
||||
"ipAddress": "عنوان IP",
|
||||
"performedBy": "تم التنفيذ بواسطة",
|
||||
"description": "الوصف",
|
||||
"action": "الإجراء",
|
||||
"module": "الوحدة"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "تعذر تحميل السجلات"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"registry": {
|
||||
"title": "سجل الوحدات",
|
||||
"subtitle": "إدارة وحدات المنصة وتكويناتها",
|
||||
"create_button": "إنشاء وحدة",
|
||||
"empty_state": {
|
||||
"title": "لا توجد وحدات بعد",
|
||||
"subtitle": "قم بإنشاء وحدتك الأولى للبدء"
|
||||
},
|
||||
"card": {
|
||||
"environments": "البيئات",
|
||||
"permissions": "الأذونات"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Create Module",
|
||||
"edit_title": "Edit Module",
|
||||
"fields": {
|
||||
"module_id": {
|
||||
"label": "Module ID",
|
||||
"placeholder": "e.g., inventory",
|
||||
"hint": "Unique identifier (lowercase, alphanumeric, hyphens)",
|
||||
"error": {
|
||||
"required": "Module ID is required",
|
||||
"pattern": "Only lowercase alphanumeric and hyphens allowed"
|
||||
}
|
||||
},
|
||||
"module_name": {
|
||||
"label": "Module Name",
|
||||
"placeholder": "e.g., Inventory Management",
|
||||
"error": {
|
||||
"required": "Module Name is required"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"label": "Description",
|
||||
"placeholder": "Brief description of the module"
|
||||
},
|
||||
"icon_url": {
|
||||
"label": "Icon URL",
|
||||
"placeholder": "https://example.com/icon.png"
|
||||
},
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"options": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
}
|
||||
},
|
||||
"display_order": {
|
||||
"label": "Display Order"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"create": "Create Module",
|
||||
"update": "Update Module",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving..."
|
||||
}
|
||||
},
|
||||
"delete_modal": {
|
||||
"title": "حذف الوحدة",
|
||||
"description": "هل أنت متأكد أنك تريد حذف \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء وسيتم إزالة جميع البيئات وتعيينات المستأجرين المرتبطة.",
|
||||
"confirm": "حذف الوحدة",
|
||||
"cancel": "إلغاء"
|
||||
},
|
||||
"Inventory": "المخزون",
|
||||
"Fulfillment & Logistics": "التنفيذ والخدمات اللوجستية",
|
||||
"User Management": "إدارة المستخدمين",
|
||||
"Tenant Management": "إدارة المستأجرين",
|
||||
"Role Management": "إدارة الأدوار"
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
{
|
||||
"tabs": {
|
||||
"all": "الكل",
|
||||
"new": "الطلبات",
|
||||
"picked": "تم التقاط",
|
||||
"packed": "تم التعبئة",
|
||||
"ready": "جاهز للشحن",
|
||||
"pickups": "الاستلام والبيانات",
|
||||
"transit": "قيد النقل",
|
||||
"delivered": "تم التسليم"
|
||||
},
|
||||
"columns": {
|
||||
"order": "الطلب",
|
||||
"manifestId": "رقم البيان",
|
||||
"expectedDelivery": "التسليم المتوقع",
|
||||
"customer": "العميل",
|
||||
"product": "المنتج",
|
||||
"payment": "الدفع",
|
||||
"pickupAddress": "عنوان الاستلام",
|
||||
"courier": "شركة الشحن",
|
||||
"status": "الحالة",
|
||||
"action": "إجراء",
|
||||
"orders": "الطلبات",
|
||||
"totalValue": "القيمة الإجمالية",
|
||||
"pickup": "الاستلام",
|
||||
"deliveryMethod": "طريقة التسليم"
|
||||
},
|
||||
"actions": {
|
||||
"addOrder": "إضافة طلب",
|
||||
"updateOrder": "تحديث الطلب",
|
||||
"cancelOrder": "إلغاء",
|
||||
"createOrder": "إنشاء طلب",
|
||||
"bulkSchedule": "جدولة الاستلام",
|
||||
"bulkShip": "شحن الآن",
|
||||
"schedulePickup": "جدولة الاستلام",
|
||||
"shipNow": "شحن الآن",
|
||||
"markDelivered": "وضع علامة تم التسليم",
|
||||
"viewDetails": "عرض التفاصيل",
|
||||
"viewProof": "عرض الإثبات",
|
||||
"edit": "تعديل",
|
||||
"delete": "حذف",
|
||||
"scheduleShipment": "جدولة الشحنة",
|
||||
"markInTransit": "وضع علامة في النقل",
|
||||
"backToOrders": "العودة للطلبات",
|
||||
"markPacked": "وضع علامة تم التعبئة",
|
||||
"markReady": "وضع علامة جاهز",
|
||||
"readyToPick": "جاهز للالتقاط",
|
||||
"readyToPack": "جاهز للتعبئة",
|
||||
"readyToShip": "جاهز للشحن",
|
||||
"label": "ملصق",
|
||||
"markLabelled": "وضع علامة تم التسمية"
|
||||
},
|
||||
"status": {
|
||||
"new": "جديد",
|
||||
"ready": "جاهز",
|
||||
"readyToShip": "جاهز للشحن",
|
||||
"readyForTransit": "جاهز للنقل",
|
||||
"inTransit": "قيد النقل",
|
||||
"shipped": "تم الشحن",
|
||||
"delivered": "تم التسليم",
|
||||
"pickupScheduled": "تم جدولة الاستلام"
|
||||
},
|
||||
"labels": {
|
||||
"orderId": "رقم الطلب",
|
||||
"orderTime": "وقت الطلب",
|
||||
"customerName": "اسم العميل",
|
||||
"customerPhone": "هاتف العميل",
|
||||
"quantity": "الكمية",
|
||||
"volumetricWeight": "الوزن الحجمي",
|
||||
"cod": "الدفع عند الاستلام",
|
||||
"prepaid": "مدفوع مسبقاً",
|
||||
"trackingNumber": "رقم التتبع",
|
||||
"carrierName": "اسم الناقل",
|
||||
"moreItems": "+ {{count}} عناصر أخرى",
|
||||
"receivedBy": "استلم من قبل",
|
||||
"pickupAddress": "عنوان الاستلام",
|
||||
"selectLocation": "اختر من المواقع المحفوظة",
|
||||
"chooseLocation": "اختر موقعًا...",
|
||||
"deliveryDetails": "تفاصيل التوصيل",
|
||||
"deliveryDetailsSub": "أدخل تفاصيل توصيل العميل الذي تقوم بإنشاء هذا الطلب له",
|
||||
"billingDetails": "تفاصيل الفاتورة",
|
||||
"billingDetailsSub": "أدخل تفاصيل الفاتورة إذا كانت مختلفة عن تفاصيل التوصيل",
|
||||
"mobileNumber": "رقم الجوال",
|
||||
"enterMobile": "أدخل رقم الجوال",
|
||||
"fullName": "الاسم الكامل",
|
||||
"enterFullName": "أدخل الاسم الكامل",
|
||||
"country": "الدولة",
|
||||
"selectCountry": "بحث واختيار الدولة",
|
||||
"postalCode": "الرمز البريدي",
|
||||
"enterPostalCode": "أدخل الرمز البريدي",
|
||||
"state": "المنطقة/المحافظة",
|
||||
"city": "المدينة",
|
||||
"address": "العنوان",
|
||||
"enterAddress": "أدخل العنوان التفصيلي، رقم الشقة، إلخ",
|
||||
"landmark": "علامة مميزة (اختياري)",
|
||||
"enterLandmark": "مثال: بجوار...",
|
||||
"sameAsDelivery": "نفس عنوان التوصيل",
|
||||
"orderSummary": "ملخص الطلب",
|
||||
"orderSummarySub": "أضف المنتجات وتفاصيل الدفع وراجع الإجماليات.",
|
||||
"productDetails": "تفاصيل المنتج",
|
||||
"addProduct": "إضافة منتج",
|
||||
"paymentDetails": "تفاصيل الدفع",
|
||||
"paymentMethod": "طريقة الدفع",
|
||||
"selectPaymentMethod": "اختر طريقة الدفع",
|
||||
"invoiceValue": "قيمة الفاتورة",
|
||||
"enterAmount": "أدخل المبلغ"
|
||||
},
|
||||
"fields": {
|
||||
"productName": "اسم المنتج",
|
||||
"productNamePlaceholder": "مثال: قميص، إلكترونيات",
|
||||
"quantity": "الكمية",
|
||||
"deadWeight": "الوزن الفعلي (كجم)",
|
||||
"volumetricWeight": "الوزن الحجمي (كجم)",
|
||||
"length": "الطول (سم)",
|
||||
"breadth": "العرض (سم)",
|
||||
"height": "الارتفاع (سم)",
|
||||
"category": "فئة العنصر",
|
||||
"categoryPlaceholder": "اختر الفئة"
|
||||
},
|
||||
"messages": {
|
||||
"ordersMarkedReady": "تم وضع علامة {{count}} طلبات كجاهزة للشحن",
|
||||
"manifestCreated": "تم إنشاء بيان لـ {{count}} طلبات",
|
||||
"confirmShipOrder": "هل أنت متأكد أنك تريد وضع علامة على هذا الطلب كجاهز للشحن؟",
|
||||
"confirmBulkShip": "وضع علامة على {{count}} طلبات محددة كجاهزة للشحن؟",
|
||||
"deleteOrderTitle": "حذف الطلب",
|
||||
"confirmDelete": "هل أنت متأكد أنك تريد حذف الطلب {{orderId}}؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"moveToReadyTitle": "نقل إلى جاهز للشحن؟",
|
||||
"moveToReadySingle": "سيتم تحديث الطلب {{orderId}} إلى جاهز للشحن.",
|
||||
"moveToReadyBulk": "سيتم تحديث الطلبات المحددة إلى جاهز للشحن.",
|
||||
"moveToReadyBulkCount": "سيتم تحديث {{count}} طلبات محددة إلى جاهز للشحن.",
|
||||
"selectLocationError": "يرجى اختيار أو إنشاء موقع استلام",
|
||||
"validationError": "يرجى التحقق من الأخطاء في النموذج",
|
||||
"moveToPickedTitle": "نقل إلى تم الالتقاط؟",
|
||||
"moveToPickedSingle": "سيتم تحديث الطلب {{orderId}} إلى تم الالتقاط.",
|
||||
"packageLabeled": "تم وضع علامة على الحزمة كـ 'تم التسمية'",
|
||||
"labelError": "فشل في وضع علامة على الحزمة كـ 'تم التسمية'",
|
||||
"packageReadyToShip": "تم وضع علامة على الحزمة كـ 'جاهزة للشحن'",
|
||||
"updateStatusError": "فشل في تحديث حالة الحزمة",
|
||||
"confirmReadyToShip": "هل أنت متأكد أنك تريد وضع علامة على هذه الحزمة كـ 'جاهزة للشحن'؟"
|
||||
},
|
||||
"modal": {
|
||||
"packOrder": "تعبئة الطلب {{orderId}}",
|
||||
"shipOrder": "شحن الطلب {{orderId}}",
|
||||
"scanPack": "امسح رمز الاستجابة السريعة هذا لتأكيد التعبئة ووضع علامة تم التعبئة.",
|
||||
"scanShip": "امسح رمز الاستجابة السريعة هذا لوضع علامة على الطلب كجاهز للشحن.",
|
||||
"suggestedPackaging": "التعبئة المقترحة",
|
||||
"dimensions": "الأبعاد",
|
||||
"capacity": "السعة",
|
||||
"noPackagingSuggestion": "لم يتم العثور على اقتراح تعبئة محدد."
|
||||
},
|
||||
"packingWizard": {
|
||||
"title": "جاهز للتعبئة ({{count}})",
|
||||
"recommendedPackaging": "التغليف الموصى به",
|
||||
"use": "استخدم",
|
||||
"verifyItems": "يرجى التحقق من العناصر للطلبات {{count}} التالية قبل التعبئة",
|
||||
"packOrders": "تعبئة الطلبات",
|
||||
"packageCreated": "تم إنشاء الحزمة!",
|
||||
"successMessage": "تم تعبئة الطلبات بنجاح. الآن قم بإنشاء وإرفاق ملصق الشحن.",
|
||||
"printLabel": "طباعة الملصق",
|
||||
"packSuccess": "تم تعبئة الطلبات بنجاح",
|
||||
"packError": "فشل في تعبئة الطلبات"
|
||||
},
|
||||
"schedulePickup": {
|
||||
"title": "جدولة الشحنة",
|
||||
"generateManifestMessage": "إنشاء بيان وجدولة استلام لـ {{count}} طلب(ات).",
|
||||
"selectedPackages": "الحزم المحددة ({{count}})",
|
||||
"selectedOrders": "الطلبات المحددة",
|
||||
"package": "حزمة: {{id}} ({{count}} طلب(ات))",
|
||||
"noOrdersSelected": "لم يتم تحديد أي طلبات. حدد طلبات جاهزة لإنشاء بيان.",
|
||||
"totalOrderValue": "إجمالي قيمة الطلب",
|
||||
"pickupDateTime": "تاريخ ووقت الاستلام",
|
||||
"deliveryMethod": "طريقة التسليم",
|
||||
"selectDeliveryMethod": "اختر طريقة التسليم",
|
||||
"pickupInstructions": "تعليمات الاستلام (اختياري)",
|
||||
"pickupInstructionsPlaceholder": "أضف أي تعليمات خاصة للاستلام...",
|
||||
"generateManifestButton": "إنشاء البيان والجدولة",
|
||||
"deliveryMethods": {
|
||||
"external_carrier": "ناقل خارجي (Delhivery, BlueDart, إلخ)",
|
||||
"self_delivery": "توصيل ذاتي",
|
||||
"courier_pickup": "استلام عبر شريك البريد السريع"
|
||||
}
|
||||
},
|
||||
"trackingDetails": {
|
||||
"title": "تتبع لـ {{id}}",
|
||||
"titleAdd": "إضافة تتبع",
|
||||
"awb": "رقم بوليصة الشحن/التتبع",
|
||||
"enterAwb": "أدخل رقم التتبع",
|
||||
"carrier": "اسم الناقل",
|
||||
"enterCarrier": "اسم شركة الشحن",
|
||||
"url": "رابط التتبع (اختياري)",
|
||||
"urlPlaceholder": "https://...",
|
||||
"notes": "ملاحظات التتبع (اختياري)",
|
||||
"notesPlaceholder": "أضف أي ملاحظات تتبع...",
|
||||
"expectedDelivery": "تاريخ التوصيل المتوقع",
|
||||
"markInTransit": "وضع علامة في النقل"
|
||||
},
|
||||
"deliveredDetails": {
|
||||
"title": "تم التوصيل - {{id}}",
|
||||
"titleAdd": "وضع علامة تم التوصيل",
|
||||
"deliveredAt": "تم التوصيل في",
|
||||
"receivedBy": "تم الاستلام بواسطة",
|
||||
"receivedByPlaceholder": "اسم المستلم",
|
||||
"proofUrl": "رابط الإثبات",
|
||||
"proofUrlPlaceholder": "https://...",
|
||||
"deliveryNotes": "ملاحظات التوصيل",
|
||||
"deliveryNotesPlaceholder": "أضف ملاحظات التوصيل...",
|
||||
"markDelivered": "وضع علامة تم التوصيل"
|
||||
},
|
||||
"packageLabel": {
|
||||
"title": "ملصق الحزمة",
|
||||
"printLabel": "طباعة الملصق",
|
||||
"scanHint": "امسح لتحديث الحالة أو عرض التفاصيل"
|
||||
},
|
||||
"orderDetails": {
|
||||
"title": "تفاصيل الطلب",
|
||||
"notFound": "الطلب غير موجود",
|
||||
"createdOn": "تم الإنشاء في",
|
||||
"customerDetails": "تفاصيل العميل",
|
||||
"paymentStatus": "الدفع والحالة",
|
||||
"billingDetails": "تفاصيل الفاتورة",
|
||||
"products": "المنتجات",
|
||||
"name": "الاسم",
|
||||
"phone": "الهاتف",
|
||||
"address": "العنوان",
|
||||
"landmark": "علامة مميزة",
|
||||
"totalAmount": "المبلغ الإجمالي",
|
||||
"paymentMethod": "طريقة الدفع",
|
||||
"pickupAddress": "عنوان الاستلام",
|
||||
"productName": "اسم المنتج",
|
||||
"qty": "الكمية",
|
||||
"dimensions": "الأبعاد (الطول × العرض × الارتفاع)",
|
||||
"weights": "الأوزان (الفعلي / الحجمي)"
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"title": "التغليف",
|
||||
"subTitle": "إدارة مخزون التغليف وأنواعه.",
|
||||
"actions": {
|
||||
"addPackage": "إضافة تغليف",
|
||||
"editPackage": "تعديل التغليف",
|
||||
"createPackage": "إنشاء تغليف",
|
||||
"updatePackage": "تحديث التغليف",
|
||||
"deletePackage": "حذف التغليف",
|
||||
"cancel": "إلغاء",
|
||||
"edit": "تعديل",
|
||||
"delete": "حذف",
|
||||
"backToPackaging": "العودة إلى التغليف"
|
||||
},
|
||||
"columns": {
|
||||
"name": "الاسم",
|
||||
"type": "النوع",
|
||||
"dimensions": "الأبعاد (طول×عرض×ارتفاع)",
|
||||
"material": "المادة",
|
||||
"cost": "التكلفة",
|
||||
"status": "الحالة",
|
||||
"actions": "إجراءات"
|
||||
},
|
||||
"fields": {
|
||||
"name": "الاسم",
|
||||
"namePlaceholder": "مثال: صندوق صغير",
|
||||
"type": "النوع",
|
||||
"typePlaceholder": "مثال: صندوق، ظرف",
|
||||
"sku": "SKU",
|
||||
"skuPlaceholder": "SKU اختياري",
|
||||
"material": "المادة",
|
||||
"materialPlaceholder": "مثال: الورق المقوى",
|
||||
"length": "الطول (سم)",
|
||||
"width": "العرض (سم)",
|
||||
"height": "الارتفاع (سم)",
|
||||
"weightCapacity": "سعة الوزن (كجم)",
|
||||
"costPerUnit": "التكلفة لكل وحدة",
|
||||
"stockQuantity": "كمية المخزون",
|
||||
"activeStatus": "حالة النشاط"
|
||||
},
|
||||
"status": {
|
||||
"active": "نشط",
|
||||
"inactive": "غير نشط"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirmation": "هل أنت متأكد أنك تريد حذف عنصر التغليف هذا؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"saveSuccess": "تم حفظ التغليف بنجاح",
|
||||
"deleteSuccess": "تم حذف التغليف بنجاح"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"title": "أنظمة الألوان",
|
||||
"errors": {
|
||||
"fetchFailed": "فشل جلب الأنظمة"
|
||||
},
|
||||
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذا النظام؟",
|
||||
"success": {
|
||||
"deleted": "تم حذف النظام بنجاح",
|
||||
"updated": "تم تحديث النظام بنجاح",
|
||||
"created": "تم إنشاء النظام بنجاح"
|
||||
},
|
||||
"table": {
|
||||
"name": "الاسم",
|
||||
"default": "الافتراضي",
|
||||
"preview": "المعاينة"
|
||||
},
|
||||
"colors": {
|
||||
"primary": "اللون الأساسي",
|
||||
"primary_hover": "تأثير مرور الماوس الأساسي",
|
||||
"sidebar_bg": "خلفية الشريط الجانبي",
|
||||
"sidebar_text": "نص الشريط الجانبي",
|
||||
"sidebar_text_muted": "نص الشريط الجانبي (باهت)",
|
||||
"sidebar_active_bg": "خلفية الشريط الجانبي (نشط)",
|
||||
"sidebar_active_text": "نص الشريط الجانبي (نشط)",
|
||||
"sidebar_hover_bg": "خلفية مرور الماوس للشريط الجانبي",
|
||||
"sidebar_border": "حدود الشريط الجانبي",
|
||||
"header_bg": "خلفية الرأس",
|
||||
"header_text": "نص الرأس",
|
||||
"header_border": "حدود الرأس",
|
||||
"background": "خلفية التطبيق",
|
||||
"background_secondary": "خلفية ثانوية للتطبيق",
|
||||
"text_primary": "النص الأساسي",
|
||||
"text_secondary": "النص الثانوي",
|
||||
"card_bg": "خلفية البطاقة",
|
||||
"card_border": "حدود البطاقة",
|
||||
"table_header_bg": "خلفية رأس الجدول",
|
||||
"table_row_hover": "تأثير مرور الوموس للجدول",
|
||||
"table_border": "حدود الجدول"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "تعديل النظام",
|
||||
"delete": "حذف النظام",
|
||||
"create": "إنشاء نظام جديد",
|
||||
"update": "تحديث النظام",
|
||||
"cancel": "إلغاء"
|
||||
},
|
||||
"form": {
|
||||
"title": {
|
||||
"create": "إنشاء نظام جديد",
|
||||
"edit": "تعديل النظام"
|
||||
},
|
||||
"fields": {
|
||||
"name": {
|
||||
"label": "اسم النظام",
|
||||
"placeholder": "مثال: الوضع الداكن",
|
||||
"required": "الاسم مطلوب"
|
||||
},
|
||||
"description": {
|
||||
"label": "الوصف",
|
||||
"placeholder": "وصف اختياري"
|
||||
},
|
||||
"isDefault": "تعيين كنظام افتراضي",
|
||||
"sectionTitle": "تكوين الألوان",
|
||||
"required": "مطلوب"
|
||||
}
|
||||
},
|
||||
"deleteModal": {
|
||||
"title": "حذف النظام",
|
||||
"message": "هل أنت متأكد أنك تريد حذف هذا النظام؟",
|
||||
"confirm": "حذف",
|
||||
"cancel": "إلغاء"
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,13 @@
|
||||
"tenants": "Tenants",
|
||||
"users": "Users",
|
||||
"roles": "Roles",
|
||||
"logs": "Logs",
|
||||
"themes": "Themes",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"configuration": "Configuration",
|
||||
"dropdowns": "Dropdowns"
|
||||
"dropdowns": "Dropdowns",
|
||||
"modules": "Modules"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
@@ -26,13 +28,16 @@
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"reset": "Reset",
|
||||
"clear": "Clear",
|
||||
"submit": "Submit",
|
||||
"apply": "Apply",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"view": "View",
|
||||
"columns": "Columns",
|
||||
"refresh": "Refresh",
|
||||
"signout": "Sign Out",
|
||||
"logout": "Logout"
|
||||
@@ -72,6 +77,7 @@
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"default": "Default",
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"title": "Dashboard in Progress",
|
||||
"message": "We're crafting a powerful dashboard experience to give you better insights. Please check back soon!"
|
||||
"title": "Welcome back",
|
||||
"subtitle": "Manage your SaaS ecosystem from one central hub.",
|
||||
"allModules": "All Modules",
|
||||
"searchPlaceholder": "Search available modules...",
|
||||
"actions": {
|
||||
"launch": "Launch Module"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"title": "Dropdowns Configuration",
|
||||
"noPermission": "You do not have permission to view any dropdown configurations.",
|
||||
"tabs": {
|
||||
"locationTypes": "Location Types",
|
||||
"packageTypes": "Package Types",
|
||||
"packageMaterials": "Package Materials",
|
||||
"productCategories": "Product Categories"
|
||||
},
|
||||
"locationTypes": {
|
||||
"title": "Location Types",
|
||||
"addButton": "Add Location Type",
|
||||
"editTitle": "Edit Location Type",
|
||||
"addTitle": "Add Location Type",
|
||||
"deleteTitle": "Delete Location Type?",
|
||||
"deleteMessage": "Are you sure you want to delete this location type? This action cannot be undone.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Enter location type name"
|
||||
},
|
||||
"packageTypes": {
|
||||
"title": "Package Types",
|
||||
"addButton": "Add Package Type",
|
||||
"editTitle": "Edit Package Type",
|
||||
"addTitle": "Add Package Type",
|
||||
"deleteTitle": "Delete Package Type?",
|
||||
"deleteMessage": "Are you sure you want to delete this package type? This action cannot be undone.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Enter package type name"
|
||||
},
|
||||
"packageMaterials": {
|
||||
"title": "Package Materials",
|
||||
"addButton": "Add Package Material",
|
||||
"editTitle": "Edit Package Material",
|
||||
"addTitle": "Add Package Material",
|
||||
"deleteTitle": "Delete Package Material?",
|
||||
"deleteMessage": "Are you sure you want to delete this package material? This action cannot be undone.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Enter package material name"
|
||||
},
|
||||
"productCategories": {
|
||||
"title": "Product Categories",
|
||||
"addButton": "Add Product Category",
|
||||
"editTitle": "Edit Product Category",
|
||||
"addTitle": "Add Product Category",
|
||||
"deleteTitle": "Delete Product Category?",
|
||||
"deleteMessage": "Are you sure you want to delete this product category? This action cannot be undone.",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Enter product category name"
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"title": "Locations",
|
||||
"subTitle": "Manage your warehouse and hub locations.",
|
||||
"headers": {
|
||||
"addLocation": "Add Location",
|
||||
"addNewLocation": "Add New Location",
|
||||
"editLocation": "Edit Location",
|
||||
"noLocationsFound": "No Locations Found",
|
||||
"viewMap": "View Map",
|
||||
"locationOnMap": "Location on Map"
|
||||
},
|
||||
"messages": {
|
||||
"noLocationsDescription": "Add your warehouse or hub locations to manage pickups efficiently.",
|
||||
"deleteConfirmation": "Are you sure you want to delete this location? This action cannot be undone.",
|
||||
"deleteTitle": "Delete Location?",
|
||||
"saveSuccess": "Location saved successfully",
|
||||
"fetchError": "Failed to fetch locations",
|
||||
"fetchDetailsError": "Failed to fetch location details",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Location Name",
|
||||
"namePlaceholder": "e.g. Main Warehouse, Hub Unit 1",
|
||||
"type": "Location Type",
|
||||
"locationType": "Location Type",
|
||||
"selectType": "Select Type",
|
||||
"active": "Active",
|
||||
"usedForRouting": "Used for Routing",
|
||||
"warehouse": "Warehouse",
|
||||
"hub": "Hub",
|
||||
"factory": "Factory",
|
||||
"capabilities": "Capabilities",
|
||||
"capabilitiesPlaceholder": "Select capabilities...",
|
||||
"country": "Country",
|
||||
"zipCode": "Pincode/Zip Code",
|
||||
"state": "State",
|
||||
"city": "City",
|
||||
"addressLine1": "Address Line 1",
|
||||
"addressLine1Placeholder": "Street address, building, etc.",
|
||||
"addressLine2": "Address Line 2 (Optional)",
|
||||
"addressLine2Placeholder": "Apartment, suite, unit, etc.",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"selectState": "Select State",
|
||||
"selectCity": "Select City",
|
||||
"autoFilled": "Auto-filled",
|
||||
"enterState": "Enter State",
|
||||
"enterCity": "Enter City"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Location",
|
||||
"saving": "Saving...",
|
||||
"viewMap": "View Map"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Name is required",
|
||||
"typeRequired": "Type is required",
|
||||
"addressRequired": "Address is required",
|
||||
"zipCodeRequired": "Pincode is required",
|
||||
"cityRequired": "City is required",
|
||||
"stateRequired": "State is required",
|
||||
"countryRequired": "Country is required"
|
||||
},
|
||||
"capabilities": {
|
||||
"coldStorage": "Cold Storage",
|
||||
"hazardousMaterials": "Hazardous Materials",
|
||||
"access247": "24/7 Access",
|
||||
"loadingDock": "Loading Dock",
|
||||
"forkliftAvailable": "Forklift Available",
|
||||
"cctvSurveillance": "CCTV Surveillance",
|
||||
"climateControlled": "Climate Controlled",
|
||||
"bondedWarehouse": "Bonded Warehouse",
|
||||
"crossDocking": "Cross Docking",
|
||||
"orderFulfillment": "Order Fulfillment",
|
||||
"inventoryManagement": "Inventory Management",
|
||||
"lastMileDelivery": "Last Mile Delivery",
|
||||
"returnsProcessing": "Returns Processing",
|
||||
"realTimeTracking": "Real-time Tracking",
|
||||
"fdaApproved": "FDA Approved",
|
||||
"customsClearance": "Customs Clearance"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "System Logs",
|
||||
"exportFileName": "audit_logs.csv",
|
||||
"columns": {
|
||||
"timestamp": "Timestamp",
|
||||
"ipAddress": "IP Address",
|
||||
"performedBy": "Performed By",
|
||||
"description": "Description",
|
||||
"action": "Action",
|
||||
"module": "Module"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "Failed to fetch logs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"registry": {
|
||||
"title": "Module Registry",
|
||||
"subtitle": "Manage platform modules and their configurations",
|
||||
"create_button": "Create Module",
|
||||
"empty_state": {
|
||||
"title": "No Modules Yet",
|
||||
"subtitle": "Create your first module to get started"
|
||||
},
|
||||
"card": {
|
||||
"environments": "Environments",
|
||||
"permissions": "Permissions"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Create Module",
|
||||
"edit_title": "Edit Module",
|
||||
"fields": {
|
||||
"module_id": {
|
||||
"label": "Module ID",
|
||||
"placeholder": "e.g., inventory",
|
||||
"hint": "Unique identifier (lowercase, alphanumeric, hyphens)",
|
||||
"error": {
|
||||
"required": "Module ID is required",
|
||||
"pattern": "Only lowercase alphanumeric and hyphens allowed"
|
||||
}
|
||||
},
|
||||
"module_name": {
|
||||
"label": "Module Name",
|
||||
"placeholder": "e.g., Inventory Management",
|
||||
"error": {
|
||||
"required": "Module Name is required"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"label": "Description",
|
||||
"placeholder": "Brief description of the module"
|
||||
},
|
||||
"icon_url": {
|
||||
"label": "Icon URL",
|
||||
"placeholder": "https://example.com/icon.png"
|
||||
},
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"options": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
}
|
||||
},
|
||||
"display_order": {
|
||||
"label": "Display Order"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"create": "Create Module",
|
||||
"update": "Update Module",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving..."
|
||||
}
|
||||
},
|
||||
"delete_modal": {
|
||||
"title": "Delete Module",
|
||||
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone and will remove all associated environments and tenant assignments.",
|
||||
"confirm": "Delete Module",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"Inventory": "Inventory",
|
||||
"Fulfillment & Logistics": "Fulfillment & Logistics",
|
||||
"User Management": "User Management",
|
||||
"Tenant Management": "Tenant Management",
|
||||
"Role Management": "Role Management"
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
{
|
||||
"tabs": {
|
||||
"all": "All",
|
||||
"new": "Orders",
|
||||
"picked": "Picked",
|
||||
"packed": "Packed",
|
||||
"ready": "Ready To Ship",
|
||||
"pickups": "Pickups & Manifests",
|
||||
"transit": "In Transit",
|
||||
"delivered": "Delivered"
|
||||
},
|
||||
"columns": {
|
||||
"order": "Order",
|
||||
"manifestId": "Manifest ID",
|
||||
"expectedDelivery": "Expected Delivery",
|
||||
"customer": "Customer",
|
||||
"product": "Product",
|
||||
"payment": "Payment",
|
||||
"pickupAddress": "Pickup Address",
|
||||
"courier": "Courier",
|
||||
"status": "Status",
|
||||
"action": "Action",
|
||||
"orders": "Orders",
|
||||
"totalValue": "Total Value",
|
||||
"pickup": "Pickup",
|
||||
"deliveryMethod": "Delivery Method"
|
||||
},
|
||||
"actions": {
|
||||
"addOrder": "Add Order",
|
||||
"updateOrder": "Update Order",
|
||||
"cancelOrder": "Cancel",
|
||||
"createOrder": "Create Order",
|
||||
"bulkSchedule": "Schedule Pickup",
|
||||
"bulkShip": "Ship Now",
|
||||
"schedulePickup": "Schedule Pickup",
|
||||
"shipNow": "Ship Now",
|
||||
"markDelivered": "Mark Delivered",
|
||||
"viewDetails": "View Details",
|
||||
"viewProof": "View Proof",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"scheduleShipment": "Schedule Shipment",
|
||||
"markInTransit": "Mark In Transit",
|
||||
"backToOrders": "Back to Orders",
|
||||
"markPacked": "Mark as Packed",
|
||||
"markReady": "Mark as Ready",
|
||||
"readyToPick": "Ready To Pick",
|
||||
"readyToPack": "Ready To Pack",
|
||||
"readyToShip": "Ready To Ship",
|
||||
"label": "Label",
|
||||
"markLabelled": "Mark as Labeled"
|
||||
},
|
||||
"status": {
|
||||
"new": "New",
|
||||
"ready": "Ready",
|
||||
"readyToShip": "Ready To Ship",
|
||||
"readyForTransit": "Ready For Transit",
|
||||
"inTransit": "In Transit",
|
||||
"shipped": "Shipped",
|
||||
"delivered": "Delivered",
|
||||
"pickupScheduled": "Pickup Scheduled"
|
||||
},
|
||||
"labels": {
|
||||
"orderId": "Order ID",
|
||||
"orderTime": "Order Time",
|
||||
"customerName": "Customer Name",
|
||||
"customerPhone": "Customer Phone",
|
||||
"quantity": "Quantity",
|
||||
"volumetricWeight": "Volumetric wt.",
|
||||
"cod": "COD",
|
||||
"prepaid": "Prepaid",
|
||||
"trackingNumber": "Tracking Number",
|
||||
"carrierName": "Carrier Name",
|
||||
"moreItems": "+ {{count}} more items",
|
||||
"receivedBy": "Received by",
|
||||
"pickupAddress": "Pickup Address",
|
||||
"selectLocation": "Select from Saved Locations",
|
||||
"chooseLocation": "Choose a location...",
|
||||
"deliveryDetails": "Delivery Details",
|
||||
"deliveryDetailsSub": "Enter the Delivery Details of your buyer for whom you are making this order",
|
||||
"billingDetails": "Billing Details",
|
||||
"billingDetailsSub": "Enter Billing Details if different from Delivery Details",
|
||||
"mobileNumber": "Mobile Number",
|
||||
"enterMobile": "Enter mobile number",
|
||||
"fullName": "Full Name",
|
||||
"enterFullName": "Enter Full Name",
|
||||
"country": "Country",
|
||||
"selectCountry": "Search and select country",
|
||||
"postalCode": "Postal/Zip Code",
|
||||
"enterPostalCode": "Enter postal code",
|
||||
"state": "State/Province",
|
||||
"city": "City",
|
||||
"address": "Address",
|
||||
"enterAddress": "Enter detailed address, apart. no, etc",
|
||||
"landmark": "Landmark (Optional)",
|
||||
"enterLandmark": "E.g. near ...",
|
||||
"sameAsDelivery": "Same as Delivery Address",
|
||||
"orderSummary": "Order Summary",
|
||||
"orderSummarySub": "Add products, payment details and review totals.",
|
||||
"productDetails": "Product Details",
|
||||
"addProduct": "Add Product",
|
||||
"paymentDetails": "Payment Details",
|
||||
"paymentMethod": "Payment Method",
|
||||
"selectPaymentMethod": "Select payment method",
|
||||
"invoiceValue": "Invoice Value",
|
||||
"enterAmount": "Enter amount"
|
||||
},
|
||||
"fields": {
|
||||
"productName": "Product Name",
|
||||
"productNamePlaceholder": "E.g. T-Shirt, Electronics",
|
||||
"quantity": "Quantity",
|
||||
"deadWeight": "Dead Weight (kg)",
|
||||
"volumetricWeight": "Volumetric Weight (kg)",
|
||||
"length": "Length (cm)",
|
||||
"breadth": "Breadth (cm)",
|
||||
"height": "Height (cm)",
|
||||
"category": "Item Category",
|
||||
"categoryPlaceholder": "Select category"
|
||||
},
|
||||
"messages": {
|
||||
"ordersMarkedReady": "{{count}} orders marked as Ready to Ship",
|
||||
"manifestCreated": "Manifest created for {{count}} orders",
|
||||
"confirmShipOrder": "Are you sure you want to mark this order as Ready to Ship?",
|
||||
"confirmBulkShip": "Mark {{count}} selected orders as Ready to Ship?",
|
||||
"deleteOrderTitle": "Delete Order",
|
||||
"confirmDelete": "Are you sure you want to delete order {{orderId}}? This action cannot be undone.",
|
||||
"moveToReadyTitle": "Move to Ready To Ship?",
|
||||
"moveToReadySingle": "This will update order {{orderId}} to Ready To Ship.",
|
||||
"moveToReadyBulk": "This will update the selected orders to Ready To Ship.",
|
||||
"moveToReadyBulkCount": "This will update {{count}} selected orders to Ready To Ship.",
|
||||
"selectLocationError": "Please select or create a pickup location",
|
||||
"validationError": "Please check the form for errors",
|
||||
"moveToPickedTitle": "Move to Picked?",
|
||||
"moveToPickedSingle": "This will update order {{orderId}} to Picked.",
|
||||
"packageLabeled": "Package marked as Labeled",
|
||||
"labelError": "Failed to mark package as labeled",
|
||||
"packageReadyToShip": "Package marked Ready To Ship",
|
||||
"updateStatusError": "Failed to update package status",
|
||||
"confirmReadyToShip": "Are you sure you want to mark this package as Ready To Ship?"
|
||||
},
|
||||
"modal": {
|
||||
"packOrder": "Pack Order {{orderId}}",
|
||||
"shipOrder": "Ship Order {{orderId}}",
|
||||
"scanPack": "Scan this QR code to confirm packaging and mark as Packed.",
|
||||
"scanShip": "Scan this QR code to mark the order as Ready to Ship.",
|
||||
"suggestedPackaging": "Suggested Packaging",
|
||||
"dimensions": "Dims",
|
||||
"capacity": "Capacity",
|
||||
"noPackagingSuggestion": "No specific packaging suggestion found."
|
||||
},
|
||||
"packingWizard": {
|
||||
"title": "Ready to Pack ({{count}})",
|
||||
"recommendedPackaging": "Recommended Packaging",
|
||||
"use": "Use",
|
||||
"verifyItems": "Please verify the items for the following {{count}} order(s) before packing",
|
||||
"packOrders": "Pack Orders",
|
||||
"packageCreated": "Package Created!",
|
||||
"successMessage": "The orders have been successfully packed. Now create and attach the shipping label.",
|
||||
"printLabel": "Print Label",
|
||||
"packSuccess": "Orders packed successfully",
|
||||
"packError": "Failed to pack orders"
|
||||
},
|
||||
"schedulePickup": {
|
||||
"title": "Schedule Shipment",
|
||||
"generateManifestMessage": "Generate manifest and schedule pickup for {{count}} order{{plural}}.",
|
||||
"selectedPackages": "Selected Packages ({{count}})",
|
||||
"selectedOrders": "Selected Orders",
|
||||
"package": "Package: {{id}} ({{count}} order{{plural}})",
|
||||
"noOrdersSelected": "No orders selected. Select ready orders to create a manifest.",
|
||||
"totalOrderValue": "Total Order Value",
|
||||
"pickupDateTime": "Pickup Date & Time",
|
||||
"deliveryMethod": "Delivery Method",
|
||||
"selectDeliveryMethod": "Select delivery method",
|
||||
"pickupInstructions": "Pickup Instructions (Optional)",
|
||||
"pickupInstructionsPlaceholder": "Add any special instructions for the pickup...",
|
||||
"generateManifestButton": "Generate Manifest & Schedule",
|
||||
"deliveryMethods": {
|
||||
"external_carrier": "External Carrier (Delhivery, BlueDart, etc.)",
|
||||
"self_delivery": "Self Delivery",
|
||||
"courier_pickup": "Courier Partner Pickup"
|
||||
}
|
||||
},
|
||||
"trackingDetails": {
|
||||
"title": "Tracking for {{id}}",
|
||||
"titleAdd": "Add Tracking",
|
||||
"awb": "AWB/Tracking Number",
|
||||
"enterAwb": "Enter tracking number",
|
||||
"carrier": "Carrier Name",
|
||||
"enterCarrier": "Courier name",
|
||||
"url": "Tracking URL (Optional)",
|
||||
"urlPlaceholder": "https://...",
|
||||
"notes": "Tracking Notes (Optional)",
|
||||
"notesPlaceholder": "Add any tracking notes...",
|
||||
"expectedDelivery": "Expected Delivery Date",
|
||||
"markInTransit": "Mark In Transit"
|
||||
},
|
||||
"deliveredDetails": {
|
||||
"title": "Delivered - {{id}}",
|
||||
"titleAdd": "Mark Delivered",
|
||||
"deliveredAt": "Delivered At",
|
||||
"receivedBy": "Received By",
|
||||
"receivedByPlaceholder": "Receiver name",
|
||||
"proofUrl": "Proof URL",
|
||||
"proofUrlPlaceholder": "https://...",
|
||||
"deliveryNotes": "Delivery Notes",
|
||||
"deliveryNotesPlaceholder": "Add delivery notes...",
|
||||
"markDelivered": "Mark Delivered"
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"title": "Packaging",
|
||||
"subTitle": "Manage your packaging inventory and types.",
|
||||
"actions": {
|
||||
"addPackage": "Add Package",
|
||||
"editPackage": "Edit Packaging",
|
||||
"createPackage": "Create Package",
|
||||
"updatePackage": "Update Package",
|
||||
"deletePackage": "Delete Package",
|
||||
"cancel": "Cancel",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"backToPackaging": "Back to Packaging"
|
||||
},
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"dimensions": "Dimensions (LxWxH)",
|
||||
"material": "Material",
|
||||
"cost": "Cost",
|
||||
"status": "Status",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. Small Box",
|
||||
"type": "Type",
|
||||
"typePlaceholder": "e.g. Box, Envelope",
|
||||
"sku": "SKU",
|
||||
"skuPlaceholder": "Optional SKU",
|
||||
"material": "Material",
|
||||
"materialPlaceholder": "e.g. Cardboard",
|
||||
"length": "Length (cm)",
|
||||
"width": "Width (cm)",
|
||||
"height": "Height (cm)",
|
||||
"weightCapacity": "Weight Capacity (kg)",
|
||||
"costPerUnit": "Cost Per Unit",
|
||||
"stockQuantity": "Stock Quantity",
|
||||
"activeStatus": "Active Status"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirmation": "Are you sure you want to delete this packaging item? This action cannot be undone.",
|
||||
"saveSuccess": "Packaging saved successfully",
|
||||
"deleteSuccess": "Packaging deleted successfully"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"title": "Theme Palettes",
|
||||
"errors": {
|
||||
"fetchFailed": "Failed to fetch palettes"
|
||||
},
|
||||
"confirmDelete": "Are you sure you want to delete this palette?",
|
||||
"success": {
|
||||
"deleted": "Palette deleted successfully",
|
||||
"updated": "Palette updated successfully",
|
||||
"created": "Palette created successfully"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"default": "Default",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"colors": {
|
||||
"primary": "Primary Color",
|
||||
"primary_hover": "Primary Hover",
|
||||
"sidebar_bg": "Sidebar Background",
|
||||
"sidebar_text": "Sidebar Text",
|
||||
"sidebar_text_muted": "Sidebar Text Muted",
|
||||
"sidebar_active_bg": "Sidebar Active BG",
|
||||
"sidebar_active_text": "Sidebar Active Text",
|
||||
"sidebar_hover_bg": "Sidebar Hover BG",
|
||||
"sidebar_border": "Sidebar Border",
|
||||
"header_bg": "Header Background",
|
||||
"header_text": "Header Text",
|
||||
"header_border": "Header Border",
|
||||
"background": "App Background",
|
||||
"background_secondary": "App Secondary BG",
|
||||
"text_primary": "Text Primary",
|
||||
"text_secondary": "Text Secondary",
|
||||
"card_bg": "Card Background",
|
||||
"card_border": "Card Border",
|
||||
"table_header_bg": "Table Header BG",
|
||||
"table_row_hover": "Table Row Hover",
|
||||
"table_border": "Table Border"
|
||||
},
|
||||
"actions": {
|
||||
"edit": "Edit Palette",
|
||||
"delete": "Delete Palette",
|
||||
"create": "Create Palette",
|
||||
"update": "Update Palette",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"form": {
|
||||
"title": {
|
||||
"create": "Create New Palette",
|
||||
"edit": "Edit Palette"
|
||||
},
|
||||
"fields": {
|
||||
"name": {
|
||||
"label": "Palette Name",
|
||||
"placeholder": "e.g. Dark Mode",
|
||||
"required": "Name is required"
|
||||
},
|
||||
"description": {
|
||||
"label": "Description",
|
||||
"placeholder": "Optional description"
|
||||
},
|
||||
"isDefault": "Set as Default Palette",
|
||||
"sectionTitle": "Color Configuration",
|
||||
"required": "Required"
|
||||
}
|
||||
},
|
||||
"deleteModal": {
|
||||
"title": "Delete Palette",
|
||||
"message": "Are you sure you want to delete this palette?",
|
||||
"confirm": "Delete",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
+16
-6
@@ -1,4 +1,4 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,100..900;1,100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -15,7 +15,7 @@
|
||||
/* Default Palette (Navy) - fallback */
|
||||
--primary: #3B82F6;
|
||||
--primary-hover: #2563EB;
|
||||
|
||||
|
||||
--sidebar-bg: #1D2A4B;
|
||||
--sidebar-text: #F1F5F9;
|
||||
--sidebar-text-muted: #94A3B8;
|
||||
@@ -23,14 +23,14 @@
|
||||
--sidebar-active-text: #FFFFFF;
|
||||
--sidebar-hover-bg: rgba(255,255,255,0.2);
|
||||
--sidebar-border: #334155;
|
||||
|
||||
|
||||
--header-bg: #FFFFFF;
|
||||
--header-text: #1F2937;
|
||||
--header-border: #E5E7EB;
|
||||
|
||||
|
||||
--background: #F9FAFB;
|
||||
--background-secondary: #FFFFFF;
|
||||
|
||||
|
||||
--table-header-bg: #F8FAFC;
|
||||
--table-row-hover: #F1F5F9;
|
||||
--table-border: #E2E8F0;
|
||||
@@ -40,16 +40,26 @@
|
||||
--text-secondary: #64748B;
|
||||
--card-bg: #FFFFFF;
|
||||
--card-border: #E2E8F0;
|
||||
|
||||
/* Optional: smoother focus ring for any inputs inside your custom components */
|
||||
--ui-ring: rgba(17, 24, 39, 0.12); /* gray-900/12 */
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-family: "Archivo", sans-serif;
|
||||
@apply text-base antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Optional: smoother focus ring for any inputs inside your custom components */
|
||||
.ui-focus :is(input, textarea, select, button):focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--ui-ring);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Hide number input arrows/spinners */
|
||||
.no-spinner::-webkit-inner-spin-button,
|
||||
.no-spinner::-webkit-outer-spin-button {
|
||||
|
||||
+33
-35
@@ -1,16 +1,12 @@
|
||||
import { toast } from "react-toastify";
|
||||
import { API_BASE_URL } from "../constant";
|
||||
import { AUTH_COOKIE_KEYS, clearAuthCookies, getCookie } from "./authCookies";
|
||||
import { isTokenExpired } from "./jwt";
|
||||
import { clearAuthCookies, refreshLoggedInFlag } from "./authCookies";
|
||||
|
||||
type ApiRequestOptions = Omit<RequestInit, "body"> & {
|
||||
body?: unknown;
|
||||
toast?: boolean;
|
||||
/** Optional custom success message to show in a toast */
|
||||
successMessage?: string;
|
||||
/** Optional custom error message to show in a toast */
|
||||
errorMessage?: string;
|
||||
/** If true, silences all toast notifications for this request */
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
@@ -28,23 +24,39 @@ const hardLogout = () => {
|
||||
};
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
const refreshToken = getCookie(AUTH_COOKIE_KEYS.refresh);
|
||||
if (!refreshToken) throw new Error("No refresh token");
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Refresh failed");
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const waitForRefresh = (): Promise<void> =>
|
||||
new Promise<void>((resolve) => refreshQueue.push(resolve));
|
||||
|
||||
const doRefresh = async () => {
|
||||
if (isRefreshing) {
|
||||
await waitForRefresh();
|
||||
return;
|
||||
}
|
||||
isRefreshing = true;
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
refreshLoggedInFlag();
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshQueue.forEach((cb) => cb());
|
||||
refreshQueue = [];
|
||||
}
|
||||
};
|
||||
|
||||
const request = async <T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
options: ApiRequestOptions = {},
|
||||
_isRetry = false
|
||||
): Promise<T> => {
|
||||
const {
|
||||
body,
|
||||
@@ -58,25 +70,6 @@ const request = async <T>(
|
||||
|
||||
const showToast = shouldToast && !silent;
|
||||
|
||||
const accessToken = getCookie(AUTH_COOKIE_KEYS.access);
|
||||
|
||||
if (accessToken && isTokenExpired(accessToken)) {
|
||||
try {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
await refreshAccessToken();
|
||||
isRefreshing = false;
|
||||
refreshQueue.forEach((cb) => cb());
|
||||
refreshQueue = [];
|
||||
} else {
|
||||
await new Promise<void>((resolve) => refreshQueue.push(resolve));
|
||||
}
|
||||
} catch {
|
||||
hardLogout();
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(headers);
|
||||
let requestBody: BodyInit | undefined;
|
||||
|
||||
@@ -101,8 +94,16 @@ const request = async <T>(
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
// Global 401 handler - ignore for signin requests as they just mean invalid credentials
|
||||
if (response.status === 401 && !url.includes("/api/auth/signin")) {
|
||||
if (!_isRetry) {
|
||||
try {
|
||||
await doRefresh();
|
||||
return request<T>(path, options, true);
|
||||
} catch {
|
||||
hardLogout();
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
}
|
||||
hardLogout();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
@@ -116,20 +117,17 @@ const request = async <T>(
|
||||
: await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
// Extract the specific error message from the backend response
|
||||
const backendError =
|
||||
typeof data === "object" && data !== null
|
||||
? (data as any).detail || (data as any).message
|
||||
: undefined;
|
||||
|
||||
// Prioritize backend error, then the custom errorMessage option, then a default
|
||||
const finalErrorMessage = backendError || errorMessage || "Request failed";
|
||||
|
||||
if (showToast) {
|
||||
toast.error(finalErrorMessage, API_TOAST_OPTIONS);
|
||||
}
|
||||
|
||||
// Throw the actual message so the caller (e.g., SignInForm) can display it
|
||||
throw new Error(finalErrorMessage);
|
||||
}
|
||||
|
||||
@@ -168,4 +166,4 @@ export const apiClient = {
|
||||
|
||||
delete: <T>(path: string, options?: ApiRequestOptions) =>
|
||||
request<T>(path, { ...options, method: "DELETE" }),
|
||||
};
|
||||
};
|
||||
+11
-20
@@ -1,12 +1,5 @@
|
||||
import { type TokenResponse } from "../application/authentication/AuthTypes";
|
||||
|
||||
export const AUTH_COOKIE_KEYS = {
|
||||
access: "access_token",
|
||||
refresh: "refresh_token",
|
||||
};
|
||||
|
||||
const ACCESS_MAX_AGE_SECONDS = 60 * 60; // 1 hour
|
||||
const REFRESH_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
||||
const LOGGED_IN_KEY = "logged_in";
|
||||
const LOGGED_IN_MAX_AGE_SECONDS = 60 * 15;
|
||||
|
||||
const buildCookieOptions = (maxAgeSeconds?: number) => {
|
||||
const options = ["path=/", "samesite=lax"];
|
||||
@@ -41,19 +34,17 @@ export const getCookie = (name: string) => {
|
||||
return decodeURIComponent(rawValue);
|
||||
};
|
||||
|
||||
export const setAuthCookies = (tokens: TokenResponse, rememberMe = false) => {
|
||||
// Store tokens in cookies so they can be reused across page refreshes.
|
||||
const accessMaxAge = rememberMe ? ACCESS_MAX_AGE_SECONDS : undefined;
|
||||
const refreshMaxAge = rememberMe ? REFRESH_MAX_AGE_SECONDS : undefined;
|
||||
|
||||
setCookie(AUTH_COOKIE_KEYS.access, tokens.access_token, accessMaxAge);
|
||||
setCookie(AUTH_COOKIE_KEYS.refresh, tokens.refresh_token, refreshMaxAge);
|
||||
export const setAuthCookies = (_tokens: unknown, rememberMe = false) => {
|
||||
const maxAge = rememberMe ? LOGGED_IN_MAX_AGE_SECONDS : undefined;
|
||||
setCookie(LOGGED_IN_KEY, "1", maxAge);
|
||||
};
|
||||
|
||||
export const clearAuthCookies = () => {
|
||||
// Clear both cookies on logout or auth failure.
|
||||
deleteCookie(AUTH_COOKIE_KEYS.access);
|
||||
deleteCookie(AUTH_COOKIE_KEYS.refresh);
|
||||
deleteCookie(LOGGED_IN_KEY);
|
||||
};
|
||||
|
||||
export const getAccessToken = () => getCookie(AUTH_COOKIE_KEYS.access);
|
||||
export const refreshLoggedInFlag = () => {
|
||||
setCookie(LOGGED_IN_KEY, "1", LOGGED_IN_MAX_AGE_SECONDS);
|
||||
};
|
||||
|
||||
export const getAccessToken = () => getCookie(LOGGED_IN_KEY);
|
||||
@@ -0,0 +1,28 @@
|
||||
export const formatDate = (
|
||||
dateString?: string | null,
|
||||
language: string = "en"
|
||||
) => {
|
||||
if (!dateString) return "";
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString;
|
||||
|
||||
const hasTime = dateString.includes("T") || dateString.includes(":");
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
if (hasTime) {
|
||||
options.hour = "2-digit";
|
||||
options.minute = "2-digit";
|
||||
options.hour12 = false;
|
||||
}
|
||||
|
||||
return date.toLocaleString(language === "ar" ? "ar-EG" : "en-GB", options);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
export const isTokenExpired = (token: string): boolean => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return payload.exp < now;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export const buildQueryString = (params: Record<string, unknown>): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
if (item !== undefined && item !== null && item !== "") {
|
||||
searchParams.append(key, String(item));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const query = searchParams.toString();
|
||||
return query ? `?${query}` : "";
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
export const DEFAULT_TABLE_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const;
|
||||
|
||||
export const SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY =
|
||||
"admin-table-page-size";
|
||||
|
||||
type ResolveTablePageSizeArgs = {
|
||||
storageKey?: string;
|
||||
pageSizeOptions?: readonly number[];
|
||||
defaultPageSize: number;
|
||||
};
|
||||
|
||||
function isValidPageSize(pageSize: number, pageSizeOptions?: readonly number[]) {
|
||||
if (!Number.isFinite(pageSize) || pageSize <= 0) return false;
|
||||
if (!pageSizeOptions || pageSizeOptions.length === 0) return true;
|
||||
return pageSizeOptions.includes(pageSize);
|
||||
}
|
||||
|
||||
export function resolveStoredTablePageSize({
|
||||
storageKey,
|
||||
pageSizeOptions,
|
||||
defaultPageSize,
|
||||
}: ResolveTablePageSizeArgs) {
|
||||
if (!isValidPageSize(defaultPageSize, pageSizeOptions)) {
|
||||
return pageSizeOptions?.[0] ?? 10;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined" || !storageKey) {
|
||||
return defaultPageSize;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(storageKey);
|
||||
if (!rawValue) return defaultPageSize;
|
||||
|
||||
const parsedValue = Number(rawValue);
|
||||
return isValidPageSize(parsedValue, pageSizeOptions)
|
||||
? parsedValue
|
||||
: defaultPageSize;
|
||||
} catch {
|
||||
return defaultPageSize;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistTablePageSize(
|
||||
storageKey: string | undefined,
|
||||
pageSize: number,
|
||||
pageSizeOptions?: readonly number[]
|
||||
) {
|
||||
if (typeof window === "undefined" || !storageKey) return;
|
||||
if (!isValidPageSize(pageSize, pageSizeOptions)) return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(pageSize));
|
||||
} catch {
|
||||
// Ignore storage failures so pagination keeps working normally.
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -4,11 +4,18 @@ import SignInPage from "../application/authentication/SignInPage";
|
||||
import SignUpPage from "../application/authentication/SignUpPage";
|
||||
import ResetPasswordPage from "../application/authentication/ResetPasswordPage";
|
||||
import Roles from "../application/roles";
|
||||
import LogsPage from "../application/logs/LogsPage";
|
||||
import Dashboard from "../application/dashboard/Dashboard";
|
||||
import ProfilePage from "../application/profile/ProfilePage";
|
||||
import Tenants from "../application/tenants";
|
||||
import ProtectedRoutes from "./ProtectedRoutes";
|
||||
import Theme from "../application/theme";
|
||||
import SettingsPage from "../application/settings/SettingsPage";
|
||||
import Users from "../application/users";
|
||||
import Modules from "../application/modules/admin";
|
||||
import TenantModuleAssignment from "../application/modules/admin/components/TenantModuleAssignment";
|
||||
import Subscriptions from "../application/subscriptions";
|
||||
|
||||
const AppRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -22,16 +29,23 @@ const AppRoutes = () => {
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/tenants/*" element={<Tenants />} />
|
||||
<Route path="/subscriptions/*" element={<Subscriptions />} />
|
||||
<Route path="/roles/*" element={<Roles />} />
|
||||
<Route path="/theme/*" element={<Theme />} />
|
||||
{/* Redirect root to dashboard if logged in (ProtectedRoutes handles auth check usually, or we redirect to signin if not) */}
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/users/*" element={<Users />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
|
||||
{/* Admin Module Routes */}
|
||||
<Route path="/admin/modules/*" element={<Modules />} />
|
||||
<Route path="/admin/tenants/:tenantId/modules" element={<TenantModuleAssignment />} />
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch all - Redirect to dashboard which will handle auth or show dashboard */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
export default AppRoutes;
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Toast container */
|
||||
.Toastify__toast-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Toast base */
|
||||
.Toastify__toast {
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: #0f172a;
|
||||
color: #e5e7eb;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.Toastify__toast--success {
|
||||
border-left: 4px solid #22c55e;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.Toastify__toast--error {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
/* Info */
|
||||
.Toastify__toast--info {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.Toastify__toast--warning {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.Toastify__progress-bar {
|
||||
height: 3px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.Toastify__close-button {
|
||||
color: #94a3b8;
|
||||
opacity: 0.7;
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.Toastify__close-button:hover {
|
||||
color: #e5e7eb;
|
||||
opacity: 1;
|
||||
}
|
||||
+12
-3
@@ -1,8 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(),tailwindcss()],
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
console.log(`\n 🔌 Backend API URL: \x1b[36m${env.VITE_API_BASE_URL}\x1b[0m\n`);
|
||||
|
||||
return {
|
||||
server: {
|
||||
port: 5174,
|
||||
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user