47 Commits
Author SHA1 Message Date
furqan 4bf09862c8 Merge pull request 'changes done on subscription module with saas' (#25) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/25
2026-04-30 05:06:22 +00:00
azeeee05 710dff90db changes done on subscription module with saas 2026-04-29 11:45:50 +03:00
furqan 11793efed9 Merge pull request 'azeem' (#24) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/24
2026-04-25 05:46:31 +00:00
azeeee05 3961978bf4 done loader changes 2026-04-25 08:34:57 +03:00
azeeee05 fc0147f046 done ui enhancement 2026-04-25 08:23:16 +03:00
furqan e763ad457c Merge pull request 'ameenah' (#23) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/23
2026-04-23 10:38:14 +00:00
amee 99c1ca4b1e feat: hide/show columns filter, export button hidden, pagination 2026-04-20 11:08:55 +05:30
amee 51bac652df feat: tenant-subscription
fix: deleted updatemodule
2026-04-18 16:31:52 +05:30
furqan 10c53e7d50 Merge pull request 'filter functionality generic filter implementation' (#22) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/22
2026-04-17 05:16:43 +00:00
amee 7e8dab160b filter functionality generic filter implementation 2026-04-17 10:35:19 +05:30
furqan be63c7c07d Merge pull request 'feat: implemented subscription module' (#21) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/21
2026-04-09 06:31:58 +00:00
Furqan-14 2a4a9cb260 feat: implemented subscription module 2026-04-09 12:01:08 +05:30
furqan 6ff7354e7a Merge pull request 'bug list fixes!' (#20) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/20
2026-04-09 06:15:07 +00:00
amee 6075f1ad10 bug list fixes! 2026-04-09 11:26:50 +05:30
furqan 7ddacc0d9a Merge pull request 'fix: .gitignore logs removed.' (#19) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/19
2026-04-03 09:55:52 +00:00
amee 82082cb862 fix: .gitignore logs removed. 2026-04-03 15:23:19 +05:30
furqan b93b71b1b1 Merge pull request 'ameenah' (#18) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/18
2026-04-03 09:38:38 +00:00
amee acee988018 fix: environment modal bg and logs in sidebar, 2026-04-03 12:16:22 +05:30
amee 5d9851c6c6 fix: modal bg transparency, static key removal from dropdown and trust type just the HMAC 2026-03-30 13:07:05 +05:30
amee d94b9ac76c test 2026-03-30 12:29:18 +05:30
furqan 4ae98a00a3 Merge pull request 'furqan' (#16) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/16
2026-02-17 06:28:13 +00:00
Furqan-14 f0114314a6 fix: stronger handling of data 2026-02-17 11:55:12 +05:30
Furqan-14 eaab2cea89 fix: security fix 2026-02-16 15:30:33 +05:30
furqan 917f197b88 Merge pull request 'furqan' (#15) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/15
2026-02-02 17:40:38 +00:00
Furqan-14 0c4e01ff1f Fix authentication folder casing to lowercase 2026-02-02 23:09:35 +05:30
Furqan-14 9d0e299e38 fix: arabic lang issues 2026-02-02 23:06:31 +05:30
furqan dc2b9b7746 Merge pull request 'furqan' (#14) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/14
2026-02-02 12:13:33 +00:00
Furqan-14 8eee23da7d fix: build errors 2026-02-02 17:42:19 +05:30
Furqan-14 806582604d feat: Implement module-based access control system with integrated authentication, authorization, and security utilities. 2026-02-02 17:40:13 +05:30
furqan 70d62da0c9 Merge pull request 'feat: added the base for module integrations' (#12) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/12
2026-01-20 12:08:59 +00:00
Furqan-14 3132b9262c feat: added the base for module integrations 2026-01-20 17:38:12 +05:30
furqan d139f42651 Merge pull request 'furqan' (#11) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/11
2026-01-19 10:39:14 +00:00
Furqan-14 3bb264fd1c fix: index html 2026-01-19 16:08:49 +05:30
furqan 04b55dad7e Merge pull request 'dev' (#10) from dev into furqan
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/10
2026-01-19 10:24:47 +00:00
furqan 3c34264423 Merge pull request 'azeem' (#9) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/9
2026-01-19 10:24:32 +00:00
azeeee05 8013650c9a issues fixed on tenants 2026-01-19 15:52:16 +05:30
azeeee05 5a8973ffe8 added toastify 2026-01-19 15:16:29 +05:30
furqan 91f9d1f7da Merge pull request 'dev' (#8) from dev into furqan
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/8
2026-01-19 09:25:01 +00:00
furqan 9026a382aa Merge pull request 'users added' (#7) from azeem into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/7
2026-01-19 09:24:40 +00:00
azeeee05 0df736d3bc users added 2026-01-19 14:39:07 +05:30
furqan 6174def815 Merge pull request 'feat: Added Settings Page and Multi Language Support' (#6) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/6
2026-01-19 08:27:24 +00:00
Furqan-14 944d597a7f feat: Added Settings Page and Multi Language Support 2026-01-19 13:56:57 +05:30
furqan c93b11033b Merge pull request 'fix: updated config for i18n' (#5) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/5
2026-01-19 07:35:33 +00:00
Furqan-14 b40291cb67 fix: updated config for i18n 2026-01-19 13:05:08 +05:30
furqan 4fded15646 Merge pull request 'furqan' (#4) from furqan into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/4
2026-01-19 07:25:18 +00:00
Furqan-14 1382c74225 fix: updated lock 2026-01-19 12:54:59 +05:30
furqan a0bf3b07a8 Merge pull request 'dev' (#2) from dev into furqan
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/2
2026-01-19 07:23:26 +00:00
98 changed files with 8027 additions and 1872 deletions
+2
View File
@@ -0,0 +1,2 @@
# Development environment
VITE_API_BASE_URL=https://saas-dev.maskantech.in
+2
View File
@@ -0,0 +1,2 @@
# Local environment
VITE_API_BASE_URL=http://localhost:8000
+2
View File
@@ -0,0 +1,2 @@
# Production environment
VITE_API_BASE_URL=https://api.yourdomain.com
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=https://saas-test.maskantech.in
-2
View File
@@ -1,6 +1,4 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+2 -2
View File
@@ -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>
+95 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
};
@@ -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 }),
};
@@ -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,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";
@@ -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,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 =
+190 -13
View File
@@ -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>
);
+312
View File
@@ -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
};
};
+16
View File
@@ -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 }),
};
+4 -4
View File
@@ -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>
+9 -11
View File
@@ -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}`);
},
+2
View File
@@ -14,6 +14,8 @@ export type RoleAccess = {
category: string;
name: string;
parent_id?: string | null;
module_id?: string;
module_name?: string;
};
export type RoleCreateRequest = {
+31 -13
View File
@@ -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;
+156 -57
View File
@@ -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>
);
};
+106
View File
@@ -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;
+13
View File
@@ -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;
+13 -12
View File
@@ -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}`
+22 -2
View File
@@ -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;
};
};
+298 -47
View File
@@ -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;
+441 -96
View File
@@ -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;
+3 -3
View File
@@ -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);
},
+8 -1
View File
@@ -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;
+126
View File
@@ -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}`);
},
};
+66
View File
@@ -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;
+14
View File
@@ -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),
};
}
+2 -2
View File
@@ -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;
+342 -26
View File
@@ -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;
+4 -1
View File
@@ -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,
};
+37 -79
View File
@@ -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>
+7 -6
View File
@@ -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>
+213 -183
View File
@@ -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;
+1 -14
View File
@@ -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>
);
}
}
+1 -1
View File
@@ -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
View File
@@ -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;
+5 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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": "مرفوض",
+7 -2
View File
@@ -1,4 +1,9 @@
{
"title": "لوحة المعلومات قيد التطوير",
"message": "نحن نعمل على إنشاء تجربة لوحة معلومات قوية لمنحك رؤى أفضل. يرجى التحقق مرة أخرى قريبا!"
"title": "مرحباً بعودتك",
"subtitle": "أدِر نظام SaaS الخاص بك من مركز واحد.",
"allModules": "جميع الوحدات",
"searchPlaceholder": "البحث في الوحدات المتاحة...",
"actions": {
"launch": "تشغيل الوحدة"
}
}
-50
View File
@@ -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": "أدخل اسم فئة المنتج"
}
}
-85
View File
@@ -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": "التخليص الجمركي"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"title": "سجل النظام",
"exportFileName": "audit_logs.csv",
"columns": {
"timestamp": "الطابع الزمني",
"ipAddress": "عنوان IP",
"performedBy": "تم التنفيذ بواسطة",
"description": "الوصف",
"action": "الإجراء",
"module": "الوحدة"
},
"messages": {
"loadError": "تعذر تحميل السجلات"
}
}
+72
View File
@@ -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": "إدارة الأدوار"
}
-234
View File
@@ -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": "الأوزان (الفعلي / الحجمي)"
}
}
-50
View File
@@ -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": "تم حذف التغليف بنجاح"
}
}
+73
View File
@@ -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 -1
View File
@@ -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",
+7 -2
View File
@@ -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"
}
}
-50
View File
@@ -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"
}
}
-85
View File
@@ -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"
}
}
+15
View File
@@ -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"
}
}
+72
View File
@@ -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"
}
-209
View File
@@ -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"
}
}
-50
View File
@@ -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"
}
}
+73
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+28
View File
@@ -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;
}
};
-9
View File
@@ -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;
}
};
+21
View File
@@ -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}` : "";
};
+57
View File
@@ -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
View File
@@ -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;
+55
View File
@@ -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
View File
@@ -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()],
}
})